<script setup lang="ts">
import { computed, ref } from 'vue'
import { getReport, ranges, type MetricId, type RangeId } from './data'
import DevicesDonut from './components/DevicesDonut.vue'
import MetricTabs from './components/MetricTabs.vue'
import RangePicker from './components/RangePicker.vue'
import SiteHeader from './components/SiteHeader.vue'
import TopList from './components/TopList.vue'
import TrafficChart from './components/TrafficChart.vue'
const range = ref<RangeId>('30d')
const metric = ref<MetricId>('visitors')
// Swap getReport for a fetch to your analytics API; the UI only depends on the Report shape.
const report = computed(() => getReport(range.value))
const metricLabel = computed(() => report.value.metrics.find(m => m.id === metric.value)?.label ?? '')
const rangeLabel = computed(() => ranges.find(r => r.id === range.value)?.label.toLowerCase() ?? '')
const visitors = computed(() => report.value.metrics.find(m => m.id === 'visitors')?.value ?? 0)
</script>
<template>
<div class="min-h-screen bg-gray-50 text-gray-900 antialiased dark:bg-gray-950 dark:text-gray-100">
<SiteHeader site="fernhollow.example" :live-visitors="report.liveVisitors" />
<main class="mx-auto max-w-6xl space-y-6 px-4 py-8 sm:px-6">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">Traffic overview</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Compared with the previous {{ rangeLabel.replace('last ', '') }}.</p>
</div>
<RangePicker v-model="range" />
</div>
<section class="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-xs dark:border-white/10 dark:bg-gray-900">
<MetricTabs v-model="metric" :metrics="report.metrics" panel-id="traffic-panel" />
<div id="traffic-panel" role="tabpanel" :aria-labelledby="`tab-${metric}`" class="px-3 pt-5 pb-3 sm:px-5">
<TrafficChart :series="report.series" :metric="metric" :label="metricLabel" />
</div>
</section>
<div class="grid gap-6 lg:grid-cols-3">
<TopList title="Top pages" item-label="Page" :rows="report.pages" mono />
<TopList title="Top sources" item-label="Source" :rows="report.referrers" />
<DevicesDonut :devices="report.devices" :visitors="visitors" />
</div>
<p class="pt-2 text-center text-xs text-gray-400 dark:text-gray-500">Showing sample data for a fictional site. Hover or use the arrow keys on the chart to read exact values.</p>
</main>
</div>
</template>
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
export interface Point { x: number; y: number }
/** Tracks an element's content size so charts can draw at real pixel size (crisp strokes and text). */
export function useElementSize(target: Ref<HTMLElement | null>) {
const width = ref(0)
const height = ref(0)
let observer: ResizeObserver | undefined
onMounted(() => {
if (!target.value) return
width.value = target.value.clientWidth
height.value = target.value.clientHeight
observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) { width.value = entry.contentRect.width; height.value = entry.contentRect.height }
})
observer.observe(target.value)
})
onBeforeUnmount(() => observer?.disconnect())
return { width, height }
}
export function useElementWidth(target: Ref<HTMLElement | null>) {
return useElementSize(target).width
}
/** Rounds a raw axis step up to 1, 2, 2.5 or 5 × 10ⁿ so gridlines land on friendly values. */
export function niceStep(raw: number): number {
if (raw <= 0) return 1
const exp = 10 ** Math.floor(Math.log10(raw))
const f = raw / exp
return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * exp
}
/** Picks a friendly step and just enough gridlines to cover `max` (no half-empty charts). */
export function niceScale(max: number, target = 4): { step: number; ticks: number[]; max: number } {
const step = niceStep(max / target)
const count = Math.max(2, Math.ceil(max / step))
return { step, ticks: Array.from({ length: count + 1 }, (_, i) => i * step), max: step * count }
}
const r = (n: number) => Math.round(n * 10) / 10
/**
* Monotone cubic interpolation (Fritsch–Carlson). Smooth like a spline,
* but never overshoots the data, so a curve can't dip below zero or fake a peak.
*/
export function monotonePath(pts: Point[]): string {
const n = pts.length
const first = pts[0]
if (!first) return ''
if (n === 1) return `M${r(first.x)},${r(first.y)}`
const h: number[] = []
const m: number[] = []
for (let i = 0; i < n - 1; i++) {
const a = pts[i]!, b = pts[i + 1]!
h.push(b.x - a.x)
m.push((b.y - a.y) / (b.x - a.x))
}
const t: number[] = [m[0]!]
for (let i = 1; i < n - 1; i++) t.push(m[i - 1]! * m[i]! <= 0 ? 0 : (m[i - 1]! + m[i]!) / 2)
t.push(m[n - 2]!)
for (let i = 0; i < n - 1; i++) {
if (m[i] === 0) { t[i] = 0; t[i + 1] = 0; continue }
const a = t[i]! / m[i]!, b = t[i + 1]! / m[i]!
const s = a * a + b * b
if (s > 9) { const k = 3 / Math.sqrt(s); t[i] = k * a * m[i]!; t[i + 1] = k * b * m[i]! }
}
let d = `M${r(first.x)},${r(first.y)}`
for (let i = 0; i < n - 1; i++) {
const a = pts[i]!, b = pts[i + 1]!, third = h[i]! / 3
d += ` C${r(a.x + third)},${r(a.y + t[i]! * third)} ${r(b.x - third)},${r(b.y - t[i + 1]! * third)} ${r(b.x)},${r(b.y)}`
}
return d
}
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { DeviceShare } from '../data'
const props = defineProps<{ devices: DeviceShare[]; visitors: number }>()
const R = 62
const STROKE = 18
const C = 2 * Math.PI * R
const GAP = 3 // px of empty ring between segments
const swatches = [
{ stroke: 'stroke-sky-500 dark:stroke-sky-400', bg: 'bg-sky-500 dark:bg-sky-400' },
{ stroke: 'stroke-sky-300 dark:stroke-sky-700', bg: 'bg-sky-300 dark:bg-sky-700' },
{ stroke: 'stroke-sky-100 dark:stroke-sky-950', bg: 'bg-sky-100 dark:bg-sky-950' },
]
const segments = computed(() => {
let offset = 0
return props.devices.map((d, i) => {
const length = d.share * C
const seg = { ...d, dash: `${Math.max(0, length - GAP)} ${C}`, offset: -offset, swatch: swatches[i % swatches.length]! }
offset += length
return seg
})
})
const hovered = ref<number | null>(null)
const focus = computed(() => props.devices[hovered.value ?? 0])
const pct = (share: number) => `${Math.round(share * 100)}%`
</script>
<template>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-xs sm:p-6 dark:border-white/10 dark:bg-gray-900" aria-labelledby="devices-title">
<h2 id="devices-title" class="text-base font-semibold text-gray-900 dark:text-white">Devices</h2>
<div class="mt-5 flex flex-col items-center gap-6 sm:flex-row lg:flex-col">
<div class="relative size-40 shrink-0">
<svg viewBox="0 0 160 160" class="size-40 -rotate-90" role="img" :aria-label="devices.map(d => `${d.label} ${pct(d.share)}`).join(', ')">
<circle cx="80" cy="80" :r="R" fill="none" :stroke-width="STROKE" class="stroke-gray-100 dark:stroke-white/5" />
<circle
v-for="(s, i) in segments"
:key="s.id"
cx="80"
cy="80"
:r="R"
fill="none"
:stroke-width="hovered === i ? STROKE + 4 : STROKE"
:stroke-dasharray="s.dash"
:stroke-dashoffset="s.offset"
class="transition-[stroke-dasharray,stroke-dashoffset,stroke-width] duration-500"
:class="s.swatch.stroke"
@pointerenter="hovered = i"
@pointerleave="hovered = null"
/>
</svg>
<div v-if="focus" class="pointer-events-none absolute inset-0 grid place-content-center text-center" aria-hidden="true">
<span class="text-2xl font-semibold text-gray-900 tabular-nums dark:text-white">{{ pct(focus.share) }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ focus.label }}</span>
</div>
</div>
<ul class="w-full space-y-1">
<li
v-for="(s, i) in segments"
:key="s.id"
class="flex items-center gap-3 rounded-lg px-2 py-2 text-sm transition"
:class="hovered === i && 'bg-gray-50 dark:bg-white/5'"
@pointerenter="hovered = i"
@pointerleave="hovered = null"
>
<span class="size-2.5 shrink-0 rounded-full ring-1 ring-black/5 dark:ring-white/10" :class="s.swatch.bg" aria-hidden="true" />
<span class="flex-1 text-gray-700 dark:text-gray-300">{{ s.label }}</span>
<span class="text-xs text-gray-500 tabular-nums dark:text-gray-400">{{ Math.round(s.share * visitors).toLocaleString('en-US') }}</span>
<span class="w-10 text-right font-medium text-gray-900 tabular-nums dark:text-white">{{ pct(s.share) }}</span>
</li>
</ul>
</div>
</section>
</template>
<script setup lang="ts">
import { formatMetric, lowerIsBetter, type MetricId, type MetricSummary } from '../data'
const props = defineProps<{ metrics: MetricSummary[]; panelId: string }>()
const selected = defineModel<MetricId>({ required: true })
function change(m: MetricSummary) {
const pct = m.previous ? ((m.value - m.previous) / m.previous) * 100 : 0
const good = lowerIsBetter(m.id) ? pct <= 0 : pct >= 0
return { pct, good }
}
// Arrow keys move between tabs (automatic activation, per the WAI-ARIA tabs pattern).
function onKey(event: KeyboardEvent) {
const i = props.metrics.findIndex(m => m.id === selected.value)
const n = props.metrics.length
const next = { ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, Home: 0, End: n - 1 }[event.key]
if (next === undefined) return
event.preventDefault()
const metric = props.metrics[next]
if (!metric) return
selected.value = metric.id
;(event.currentTarget as HTMLElement).querySelector<HTMLElement>(`#tab-${metric.id}`)?.focus()
}
</script>
<template>
<div role="tablist" aria-label="Metric" class="grid grid-cols-2 border-b border-gray-200 lg:grid-cols-4 dark:border-white/10" @keydown="onKey">
<button
v-for="(m, i) in metrics"
:id="`tab-${m.id}`"
:key="m.id"
type="button"
role="tab"
:aria-selected="selected === m.id"
:aria-controls="panelId"
:tabindex="selected === m.id ? 0 : -1"
class="relative px-5 py-4 text-left transition outline-none focus-visible:bg-sky-50/60 sm:px-6 dark:focus-visible:bg-sky-500/5"
:class="[
selected === m.id ? 'bg-white dark:bg-gray-900' : 'bg-gray-50/60 hover:bg-white dark:bg-white/[0.015] dark:hover:bg-white/[0.03]',
i % 2 === 1 && 'border-l border-gray-200 dark:border-white/10',
i >= 2 && 'border-t border-gray-200 lg:border-t-0 dark:border-white/10',
i === 2 && 'lg:border-l',
]"
@click="selected = m.id"
>
<span class="block text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">{{ m.label }}</span>
<span class="mt-1.5 flex flex-wrap items-baseline gap-x-2">
<span class="text-2xl font-semibold tracking-tight text-gray-900 tabular-nums dark:text-white">{{ formatMetric(m.id, m.value) }}</span>
<span
class="text-xs font-semibold tabular-nums"
:class="change(m).good ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'"
>
{{ change(m).pct >= 0 ? '↑' : '↓' }} {{ Math.abs(change(m).pct).toFixed(1) }}%
</span>
</span>
<span
class="absolute inset-x-0 bottom-0 h-0.5 transition"
:class="selected === m.id ? 'bg-sky-500' : 'bg-transparent'"
aria-hidden="true"
/>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ranges, type RangeId } from '../data'
const model = defineModel<RangeId>({ required: true })
const open = ref(false)
const activeIndex = ref(0)
const root = ref<HTMLElement | null>(null)
const button = ref<HTMLButtonElement | null>(null)
const list = ref<HTMLUListElement | null>(null)
const current = computed(() => ranges.find(r => r.id === model.value) ?? ranges[0]!)
async function show() {
open.value = true
activeIndex.value = Math.max(0, ranges.findIndex(r => r.id === model.value))
await nextTick()
list.value?.focus()
}
function hide(focusButton = true) {
open.value = false
if (focusButton) button.value?.focus()
}
function choose(index: number) {
const option = ranges[index]
if (option) model.value = option.id
hide()
}
function onButtonKey(event: KeyboardEvent) {
if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key)) { event.preventDefault(); show() }
}
function onListKey(event: KeyboardEvent) {
const last = ranges.length - 1
switch (event.key) {
case 'ArrowDown': event.preventDefault(); activeIndex.value = Math.min(last, activeIndex.value + 1); break
case 'ArrowUp': event.preventDefault(); activeIndex.value = Math.max(0, activeIndex.value - 1); break
case 'Home': event.preventDefault(); activeIndex.value = 0; break
case 'End': event.preventDefault(); activeIndex.value = last; break
case 'Enter': case ' ': event.preventDefault(); choose(activeIndex.value); break
case 'Escape': event.preventDefault(); hide(); break
case 'Tab': hide(false); break
}
}
function onPointerDown(event: PointerEvent) {
if (open.value && root.value && !root.value.contains(event.target as Node)) hide(false)
}
onMounted(() => document.addEventListener('pointerdown', onPointerDown))
onBeforeUnmount(() => document.removeEventListener('pointerdown', onPointerDown))
</script>
<template>
<div ref="root" class="relative">
<button
ref="button"
type="button"
class="inline-flex h-9 items-center gap-2 rounded-lg border border-gray-200 bg-white pr-2.5 pl-3 text-sm font-medium text-gray-800 shadow-xs transition hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-100 dark:hover:bg-white/10"
aria-haspopup="listbox"
:aria-expanded="open"
aria-label="Date range"
@click="open ? hide() : show()"
@keydown="onButtonKey"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" class="size-4 text-gray-400" aria-hidden="true">
<path d="M4.5 5h11a1 1 0 0 1 1 1v9.5a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1ZM3.5 8.5h13M7 3v3.5M13 3v3.5" />
</svg>
{{ current.label }}
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" class="size-4 text-gray-400" aria-hidden="true">
<path d="m6 8 4 4 4-4" />
</svg>
</button>
<Transition enter-from-class="opacity-0 -translate-y-1" leave-to-class="opacity-0" enter-active-class="transition duration-100" leave-active-class="transition duration-75">
<ul
v-if="open"
ref="list"
role="listbox"
tabindex="-1"
aria-label="Date range"
:aria-activedescendant="`range-${ranges[activeIndex]?.id}`"
class="absolute right-0 z-30 mt-2 w-48 rounded-xl border border-gray-200 bg-white p-1 shadow-lg outline-none dark:border-white/10 dark:bg-gray-900"
@keydown="onListKey"
>
<li
v-for="(option, i) in ranges"
:id="`range-${option.id}`"
:key="option.id"
role="option"
:aria-selected="option.id === model"
class="flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm"
:class="[
i === activeIndex ? 'bg-sky-50 text-sky-900 dark:bg-sky-500/15 dark:text-sky-100' : 'text-gray-700 dark:text-gray-300',
option.id === model && 'font-semibold',
]"
@click="choose(i)"
@pointermove="activeIndex = i"
>
{{ option.label }}
<svg v-if="option.id === model" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 text-sky-600 dark:text-sky-400" aria-hidden="true">
<path d="m5 10.5 3.5 3.5L15 7" />
</svg>
</li>
</ul>
</Transition>
</div>
</template>
<script setup lang="ts">
defineProps<{ site: string; liveVisitors: number }>()
</script>
<template>
<header class="border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
<div class="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4 sm:px-6">
<span class="grid size-8 place-items-center rounded-lg bg-sky-500 text-white shadow-sm shadow-sky-500/30">
<svg viewBox="0 0 20 20" class="size-4.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M4 15.5V11M8 15.5V6.5M12 15.5v-6M16 15.5V4" />
</svg>
</span>
<span class="text-[15px] font-semibold tracking-tight text-gray-900 dark:text-white">Tally</span>
<span class="text-gray-300 dark:text-gray-700" aria-hidden="true">/</span>
<span class="min-w-0 truncate text-sm font-medium text-gray-700 dark:text-gray-300">{{ site }}</span>
<p class="ml-auto flex shrink-0 items-center gap-2 text-sm text-gray-600 dark:text-gray-400" aria-live="polite">
<span class="relative flex size-2" aria-hidden="true">
<span class="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-60 motion-reduce:animate-none" />
<span class="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
<span><span class="font-semibold text-gray-900 tabular-nums dark:text-white">{{ liveVisitors }}</span><span class="hidden sm:inline"> current visitors</span><span class="sm:hidden"> online</span></span>
</p>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ListRow } from '../data'
const props = defineProps<{
title: string
/** Header for the label column, e.g. "Page" or "Source". */
itemLabel: string
rows: ListRow[]
mono?: boolean
}>()
const max = computed(() => Math.max(1, ...props.rows.map(r => r.value)))
const total = computed(() => props.rows.reduce((s, r) => s + r.value, 0))
</script>
<template>
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-xs sm:p-6 dark:border-white/10 dark:bg-gray-900" :aria-label="title">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ title }}</h2>
<table class="mt-4 w-full table-fixed text-sm">
<thead>
<tr class="text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
<th scope="col" class="pb-2 text-left font-medium">{{ itemLabel }}</th>
<th scope="col" class="w-20 pb-2 text-right font-medium">Visitors</th>
</tr>
</thead>
<TransitionGroup tag="tbody" move-class="transition duration-300">
<tr v-for="row in rows" :key="row.label">
<td class="py-1 pr-3">
<div class="relative flex h-8 items-center overflow-hidden rounded-md">
<div class="absolute inset-y-0 left-0 rounded-md bg-sky-100 transition-[width] duration-500 dark:bg-sky-500/15" :style="{ width: `${(row.value / max) * 100}%` }" aria-hidden="true" />
<span class="relative truncate px-2.5 text-gray-800 dark:text-gray-200" :class="mono && 'font-mono text-[13px]'" :title="row.label">{{ row.label }}</span>
</div>
</td>
<td class="py-1 text-right font-medium text-gray-900 tabular-nums dark:text-white">
{{ row.value.toLocaleString('en-US') }}
<span class="sr-only">({{ Math.round((row.value / total) * 100) }}%)</span>
</td>
</tr>
</TransitionGroup>
</table>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { formatMetric, type MetricId, type SeriesPoint } from '../data'
import { monotonePath, niceScale, niceStep, useElementWidth } from '../chart'
const props = defineProps<{ series: SeriesPoint[]; metric: MetricId; label: string }>()
const HEIGHT = 300
const pad = { top: 16, right: 16, bottom: 30, left: 52 }
const box = ref<HTMLElement | null>(null)
const width = useElementWidth(box)
const values = computed(() => props.series.map(p => p[props.metric]))
const scale = computed(() => {
const max = Math.max(...values.value, 1)
if (props.metric !== 'duration') return niceScale(max)
// Durations get gridlines on whole minutes rather than e.g. 50-second steps.
const step = 60 * niceStep(max / 60 / 4)
const count = Math.max(2, Math.ceil(max / step))
return { step, ticks: Array.from({ length: count + 1 }, (_, i) => i * step), max: step * count }
})
const ticks = computed(() => scale.value.ticks)
const yMax = computed(() => scale.value.max)
const innerW = computed(() => Math.max(0, width.value - pad.left - pad.right))
const innerH = HEIGHT - pad.top - pad.bottom
const bottom = pad.top + innerH
const x = (i: number) => pad.left + (props.series.length <= 1 ? innerW.value / 2 : (i / (props.series.length - 1)) * innerW.value)
const y = (v: number) => pad.top + innerH - (v / yMax.value) * innerH
const points = computed(() => values.value.map((v, i) => ({ x: x(i), y: y(v) })))
const line = computed(() => monotonePath(points.value))
const area = computed(() => {
const first = points.value[0], last = points.value[points.value.length - 1]
return first && last ? `${line.value} L${last.x},${bottom} L${first.x},${bottom} Z` : ''
})
// Show as many x labels as fit (about one per 72px), always including the last point.
const labelEvery = computed(() => Math.max(1, Math.ceil(props.series.length / Math.max(2, Math.floor(innerW.value / 72)))))
const showLabel = (i: number) => (props.series.length - 1 - i) % labelEvery.value === 0
const active = ref<number | null>(null)
watch(() => props.series, () => { active.value = null })
function onPointerMove(event: PointerEvent) {
const rect = (event.currentTarget as SVGElement).getBoundingClientRect()
const n = props.series.length
active.value = Math.min(n - 1, Math.max(0, Math.round(((event.clientX - rect.left - pad.left) / innerW.value) * (n - 1))))
}
function onKey(event: KeyboardEvent) {
const n = props.series.length
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
event.preventDefault()
const d = event.key === 'ArrowRight' ? 1 : -1
active.value = active.value === null ? (d > 0 ? 0 : n - 1) : (active.value + d + n) % n
}
else if (event.key === 'Escape') active.value = null
}
const hover = computed(() => {
if (active.value === null) return null
const p = props.series[active.value]
if (!p) return null
return { label: p.label, value: p[props.metric], x: x(active.value), y: y(p[props.metric]) }
})
const tooltipLeft = computed(() => (hover.value ? Math.min(Math.max(hover.value.x, 70), width.value - 70) : 0))
</script>
<template>
<div ref="box" class="relative" :style="{ height: `${HEIGHT}px` }">
<svg
v-if="width > 0"
:width="width"
:height="HEIGHT"
class="block touch-pan-y rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-sky-500"
role="img"
tabindex="0"
:aria-label="`${label} over time. Use the left and right arrow keys to read individual values.`"
@pointermove="onPointerMove"
@pointerleave="active = null"
@keydown="onKey"
@blur="active = null"
>
<defs>
<linearGradient id="traffic-fill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stop-color="rgb(14 165 233)" stop-opacity="0.28" />
<stop offset="1" stop-color="rgb(14 165 233)" stop-opacity="0" />
</linearGradient>
</defs>
<g class="stroke-gray-100 dark:stroke-white/[0.06]">
<line v-for="t in ticks" :key="t" :x1="pad.left" :x2="width - pad.right" :y1="y(t)" :y2="y(t)" />
</g>
<g class="fill-gray-400 text-[11px] tabular-nums dark:fill-gray-500">
<text v-for="t in ticks" :key="t" :x="pad.left - 10" :y="y(t)" text-anchor="end" dominant-baseline="middle">{{ formatMetric(metric, t, 'compact') }}</text>
<template v-for="(p, i) in series" :key="p.label">
<text v-if="showLabel(i)" :x="x(i)" :y="HEIGHT - 8" :text-anchor="i === series.length - 1 ? 'end' : 'middle'">{{ p.label }}</text>
</template>
</g>
<path :d="area" fill="url(#traffic-fill)" />
<path :d="line" fill="none" stroke-width="2.25" stroke-linejoin="round" stroke-linecap="round" class="stroke-sky-500 dark:stroke-sky-400" />
<g v-if="hover">
<line :x1="hover.x" :x2="hover.x" :y1="pad.top" :y2="bottom" stroke-dasharray="3 3" class="stroke-gray-300 dark:stroke-gray-600" />
<circle :cx="hover.x" :cy="hover.y" r="5" stroke-width="2.5" class="fill-white stroke-sky-500 dark:fill-gray-900 dark:stroke-sky-400" />
</g>
</svg>
<div
v-if="hover"
class="pointer-events-none absolute top-0 min-w-32 -translate-x-1/2 rounded-lg border border-gray-200 bg-white/95 px-3 py-2 text-xs shadow-lg backdrop-blur dark:border-white/10 dark:bg-gray-800/95"
:style="{ left: `${tooltipLeft}px` }"
aria-live="polite"
>
<p class="text-gray-500 dark:text-gray-400">{{ hover.label }}</p>
<p class="mt-0.5 text-sm font-semibold text-gray-900 tabular-nums dark:text-white">{{ formatMetric(metric, hover.value) }} <span class="text-xs font-normal text-gray-500 dark:text-gray-400">{{ label.toLowerCase() }}</span></p>
</div>
</div>
</template>
// Sample analytics data. Everything is generated deterministically per date range so the
// dashboard feels alive when you switch ranges. Replace `getReport()` with a call to your API.
export type RangeId = '24h' | '7d' | '30d' | '90d'
export type MetricId = 'visitors' | 'pageviews' | 'bounce' | 'duration'
export interface RangeOption { id: RangeId; label: string; short: string }
export const ranges: RangeOption[] = [
{ id: '24h', label: 'Last 24 hours', short: '24h' },
{ id: '7d', label: 'Last 7 days', short: '7d' },
{ id: '30d', label: 'Last 30 days', short: '30d' },
{ id: '90d', label: 'Last 90 days', short: '90d' },
]
export interface SeriesPoint {
/** Short axis label, e.g. "Sep 30" or "14:00". */
label: string
visitors: number
pageviews: number
/** Percent. */
bounce: number
/** Seconds. */
duration: number
}
export interface MetricSummary { id: MetricId; label: string; value: number; previous: number }
export interface ListRow { label: string; value: number }
export interface DeviceShare { id: 'desktop' | 'mobile' | 'tablet'; label: string; share: number }
export interface Report {
range: RangeId
series: SeriesPoint[]
metrics: MetricSummary[]
pages: ListRow[]
referrers: ListRow[]
devices: DeviceShare[]
liveVisitors: number
}
/** Small seeded PRNG (mulberry32) so every range always produces the same numbers. */
function random(seed: number) {
return () => {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const TODAY = new Date(2026, 8, 30) // fixed so the demo is stable
const dayLabel = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
function buildSeries(range: RangeId, offset: number): SeriesPoint[] {
const rand = random(range.length * 97 + offset * 13 + 7)
const hourly = range === '24h'
const count = hourly ? 24 : Number.parseInt(range)
const points: SeriesPoint[] = []
for (let i = 0; i < count; i++) {
const noise = 0.88 + rand() * 0.24
let visitors: number
let label: string
if (hourly) {
const hour = i
// Traffic peaks mid-afternoon, dips overnight.
const diurnal = 0.25 + 0.75 * Math.max(0, Math.sin(((hour - 5) / 24) * Math.PI * 1.35))
visitors = Math.round(120 * diurnal * noise * (offset ? 0.9 : 1))
label = `${String(hour).padStart(2, '0')}:00`
}
else {
const date = new Date(TODAY)
date.setDate(TODAY.getDate() - (count - 1 - i) - offset * count)
const weekend = date.getDay() === 0 || date.getDay() === 6 ? 0.72 : 1
const growth = 1 + ((i - offset * count) / 365) * 0.6
visitors = Math.round(1850 * weekend * growth * noise * (offset ? 0.93 : 1))
label = dayLabel(date)
}
points.push({
label,
visitors,
pageviews: Math.round(visitors * (2.1 + rand() * 0.5)),
bounce: Math.round((39 + rand() * 12 + (offset ? 1.8 : 0)) * 10) / 10,
duration: Math.round(128 + rand() * 60 - (offset ? 9 : 0)),
})
}
return points
}
function summarize(series: SeriesPoint[]) {
const sum = (key: 'visitors' | 'pageviews') => series.reduce((s, p) => s + p[key], 0)
const avg = (key: 'bounce' | 'duration') => series.reduce((s, p) => s + p[key], 0) / Math.max(1, series.length)
return {
// Unique visitors overlap across days; a rough de-duplication keeps totals believable.
visitors: Math.round(sum('visitors') * (series.length > 1 ? 0.82 : 1)),
pageviews: sum('pageviews'),
bounce: Math.round(avg('bounce') * 10) / 10,
duration: Math.round(avg('duration')),
}
}
const pageWeights: [string, number][] = [
['/', 1],
['/guides/raised-bed-soil-mix', 0.62],
['/shop/heirloom-tomato-seeds', 0.48],
['/blog/companion-planting-chart', 0.41],
['/tools/frost-date-calculator', 0.33],
['/pricing', 0.21],
['/blog/compost-in-30-days', 0.17],
]
const referrerWeights: [string, number][] = [
['Direct / none', 1],
['Organic search', 0.86],
['Weekly newsletter', 0.38],
['Social media', 0.29],
['gardenforum.example', 0.17],
['Podcast show notes', 0.09],
]
function rankList(weights: [string, number][], total: number, rand: () => number): ListRow[] {
return weights
.map(([label, w]) => ({ label, value: Math.round(total * w * (0.85 + rand() * 0.3)) }))
.sort((a, b) => b.value - a.value)
}
export function getReport(range: RangeId): Report {
const series = buildSeries(range, 0)
const now = summarize(series)
const before = summarize(buildSeries(range, 1))
const rand = random(range.length * 31 + 3)
const mobile = 0.52 + rand() * 0.1
const tablet = 0.04 + rand() * 0.03
return {
range,
series,
metrics: [
{ id: 'visitors', label: 'Unique visitors', value: now.visitors, previous: before.visitors },
{ id: 'pageviews', label: 'Pageviews', value: now.pageviews, previous: before.pageviews },
{ id: 'bounce', label: 'Bounce rate', value: now.bounce, previous: before.bounce },
{ id: 'duration', label: 'Visit duration', value: now.duration, previous: before.duration },
],
pages: rankList(pageWeights, now.visitors * 0.34, rand),
referrers: rankList(referrerWeights, now.visitors * 0.36, rand),
devices: [
{ id: 'mobile', label: 'Mobile', share: mobile },
{ id: 'desktop', label: 'Desktop', share: 1 - mobile - tablet },
{ id: 'tablet', label: 'Tablet', share: tablet },
],
liveVisitors: 18 + Math.round(rand() * 20),
}
}
const compact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 })
export function formatMetric(id: MetricId, value: number, style: 'full' | 'compact' = 'full'): string {
if (id === 'bounce') return `${value.toFixed(style === 'compact' ? 0 : 1)}%`
if (id === 'duration') {
const m = Math.floor(value / 60), s = Math.round(value % 60)
if (style === 'compact' && s === 0) return m ? `${m}m` : '0'
return `${m}m ${String(s).padStart(2, '0')}s`
}
return style === 'compact' || value >= 100_000 ? compact.format(value) : value.toLocaleString('en-US')
}
/** For bounce rate, lower is better. */
export const lowerIsBetter = (id: MetricId) => id === 'bounce'