'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { AreaChart } from '@/components/ui/area-chart';
import { BarChart } from '@/components/ui/bar-chart';
import { Button } from '@/components/ui/button';
import { DonutChart } from '@/components/ui/donut-chart';
import { Funnel, type FunnelStep } from '@/components/ui/funnel';
import { Gauge } from '@/components/ui/gauge';
import { Heatmap } from '@/components/ui/heatmap';
import { Icon, type IconName } from '@/components/ui/icon';
import { LineChart } from '@/components/ui/line-chart';
import { useScrollFade } from '@/components/ui/motion';
import { Sparkline } from '@/components/ui/sparkline';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type AnalyticsOverviewVariant = 'overview' | 'realtime' | 'conversion' | 'audience' | 'compare';
export interface AnalyticsDay {
/** "2026-10-23" */
date: string;
visitors: number;
views: number;
sessions: number;
/** Sessions with one page only */
bounces: number;
conversions: number;
/** Average session length in seconds */
duration: number;
revenue?: number;
}
export interface AnalyticsShare {
label: string;
value: number;
/** The same in the period before, for the change */
previous?: number;
}
export interface AnalyticsPage {
path: string;
title?: string;
views: number;
previous?: number;
}
export interface AnalyticsFunnel {
/** Segment id, e.g. all, mobile, desktop */
id: string;
label: string;
steps: FunnelStep[];
}
export interface AnalyticsCohort {
/** e.g. "Week of Sep 7" */
label: string;
size: number;
/** Share still active in week 0, 1, 2 …, in percent */
values: number[];
}
export interface AnalyticsGoal {
label: string;
value: number;
target: number;
}
export interface AnalyticsLive {
/** Visitors on the site right now */
now: number;
/** Visitors per minute, oldest first, usually 30 */
minutes: number[];
pages: { path: string; users: number }[];
sources: AnalyticsShare[];
countries: AnalyticsShare[];
/** Recent events, newest first */
events: { id: string; text: string; path: string; place?: string }[];
}
export interface AnalyticsOverviewProps {
variant?: AnalyticsOverviewVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title?: string;
subtitle?: string;
/** ISO currency code for revenue */
currency?: string;
/** overview, compare: one row per day, oldest first, twice as long as the longest range (for the period before) */
days?: AnalyticsDay[];
/** overview: the most viewed pages in the range */
pages?: AnalyticsPage[];
/** overview, compare: where visitors come from */
sources?: AnalyticsShare[];
/** audience */
devices?: AnalyticsShare[];
countries?: AnalyticsShare[];
languages?: AnalyticsShare[];
/** audience: new and returning visitors per day */
visitorTypes?: { date: string; new: number; returning: number }[];
/** audience: visits by weekday (rows) and hour (columns) */
hours?: { rows: string[]; columns: string[]; values: number[][] };
/** conversion */
funnels?: AnalyticsFunnel[];
cohorts?: AnalyticsCohort[];
goals?: AnalyticsGoal[];
/** realtime */
live?: AnalyticsLive;
/** Called on export and range change. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
range: 'Time range',
d7: '7 days',
d30: '30 days',
d90: '90 days',
last: 'Last {n} days',
vsBefore: 'vs. the {n} days before',
comparePrev: 'Show period before',
metrics: 'Key figures',
visitors: 'Visitors',
views: 'Page views',
sessions: 'Sessions',
conversions: 'Conversions',
conversionRate: 'Conversion rate',
bounceRate: 'Bounce rate',
duration: 'Time on site',
revenue: 'Revenue',
current: 'This period',
previous: 'Period before',
topPages: 'Top pages',
page: 'Page',
change: 'Change',
sources: 'Sources',
insightUp: '{metric} rose by {p} against the period before, most of it from {source}.',
insightDown: '{metric} fell by {p} against the period before. {source} still brings the most.',
export: 'Export CSV',
exported: 'The CSV with {n} days is in your downloads.',
live: 'Live',
paused: 'Paused',
pause: 'Pause live updates',
resume: 'Resume live updates',
rightNow: 'Visitors right now',
perMinute: 'Visitors per minute, last 30 minutes',
activePages: 'Active pages',
users: 'Visitors',
stream: 'What just happened',
topCountries: 'Countries',
topSources: 'Sources now',
updated: '{n} visitors right now.',
segment: 'Segment',
funnel: 'Funnel',
fromStart: 'Converted from first step',
biggestDrop: 'Biggest drop',
dropText: '{p} leave between {a} and {b}. That step is the best lever.',
goals: 'Goals this month',
cohorts: 'Retention by week of first visit',
cohortWeek: 'Week {n}',
cohort: 'Cohort',
cohortSize: '{n} visitors',
devices: 'Devices',
countries: 'Countries',
languages: 'Languages',
newVsReturning: 'New and returning',
new: 'New',
returning: 'Returning',
when: 'When visitors come',
share: 'Share',
count: 'Count',
show: 'Show as',
returningShare: '{p} of visitors come back',
compare: 'Compare',
metric: 'Measure',
showChart: 'Show {metric} in the chart',
byChannel: 'Visitors by channel',
better: 'better',
worse: 'worse',
same: 'unchanged',
total: 'Total',
spanRange: '{a} to {b}',
cohortNote: 'Columns count the weeks since the first visit, week 0 is that week itself.',
},
de: {
range: 'Zeitraum',
d7: '7 Tage',
d30: '30 Tage',
d90: '90 Tage',
last: 'Letzte {n} Tage',
vsBefore: 'ggü. den {n} Tagen davor',
comparePrev: 'Zeitraum davor zeigen',
metrics: 'Kennzahlen',
visitors: 'Besucher',
views: 'Seitenaufrufe',
sessions: 'Sitzungen',
conversions: 'Conversions',
conversionRate: 'Conversion-Rate',
bounceRate: 'Absprungrate',
duration: 'Verweildauer',
revenue: 'Umsatz',
current: 'Dieser Zeitraum',
previous: 'Zeitraum davor',
topPages: 'Top-Seiten',
page: 'Seite',
change: 'Veränderung',
sources: 'Quellen',
insightUp: '{metric} stieg um {p} gegenüber dem Zeitraum davor, vor allem über {source}.',
insightDown: '{metric} sank um {p} gegenüber dem Zeitraum davor. {source} bringt weiter am meisten.',
export: 'CSV exportieren',
exported: 'Die CSV mit {n} Tagen liegt in deinen Downloads.',
live: 'Live',
paused: 'Pausiert',
pause: 'Live-Daten anhalten',
resume: 'Live-Daten fortsetzen',
rightNow: 'Besucher gerade',
perMinute: 'Besucher pro Minute, letzte 30 Minuten',
activePages: 'Aktive Seiten',
users: 'Besucher',
stream: 'Gerade passiert',
topCountries: 'Länder',
topSources: 'Quellen jetzt',
updated: '{n} Besucher gerade.',
segment: 'Segment',
funnel: 'Trichter',
fromStart: 'Vom ersten Schritt konvertiert',
biggestDrop: 'Größter Absprung',
dropText: '{p} steigen zwischen {a} und {b} aus. Dort liegt der größte Hebel.',
goals: 'Ziele diesen Monat',
cohorts: 'Bindung nach Woche des ersten Besuchs',
cohortWeek: 'Woche {n}',
cohort: 'Kohorte',
cohortSize: '{n} Besucher',
devices: 'Geräte',
countries: 'Länder',
languages: 'Sprachen',
newVsReturning: 'Neu und wiederkehrend',
new: 'Neu',
returning: 'Wiederkehrend',
when: 'Wann Besucher kommen',
share: 'Anteil',
count: 'Anzahl',
show: 'Zeigen als',
returningShare: '{p} der Besucher kommen wieder',
compare: 'Vergleich',
metric: 'Kennzahl',
showChart: '{metric} im Diagramm zeigen',
byChannel: 'Besucher nach Kanal',
better: 'besser',
worse: 'schlechter',
same: 'gleich',
total: 'Gesamt',
spanRange: '{a} bis {b}',
cohortNote: 'Die Spalten zählen die Wochen seit dem ersten Besuch, Woche 0 ist diese Woche selbst.',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
/*
* An area waits until it scrolls in, then its children rise (ano-up), pop
* (ano-pop) or grow (ano-grow) with --ano-d as delay. ano-in runs when a value
* changes, ano-fly when a live event arrives. The server renders the end state,
* without JavaScript nothing waits longer than 2.5 s, with reduced motion
* nothing moves and the live view updates without animation.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const FX: [string, string, number][] = [
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.94)', 450],
['grow', 'transform:scaleX(0)', 900],
];
const CSS =
FX.map(([k, v]) => `@keyframes ano-${k}{from{${v}}}@keyframes ano-${k}-h{from{${v}}}`).join('') +
'@keyframes ano-fly{from{opacity:0;transform:translateY(-10px)}}@keyframes ano-pulse{0%{opacity:.7;transform:scale(1)}80%,100%{opacity:0;transform:scale(2.6)}}.ano-grow{transform-origin:left}' +
'@media (prefers-reduced-motion:no-preference){' +
FX.map(([k, , d]) => `[data-ano=play] .ano-${k}{animation:ano-${k} ${d}ms var(--ease-kit) var(--ano-d,0ms) both}[data-ano=wait] .ano-${k}{animation:ano-${k}-h 0s 1s both paused}`).join('') +
'.ano-in{animation:ano-up .45s var(--ease-kit) var(--ano-d,0ms) both}.ano-fly{animation:ano-fly .5s var(--ease-kit) both}.ano-pulse{animation:ano-pulse 2.2s var(--ease-kit) infinite}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
FX.map(([k]) => `[data-ano-scope]:not([data-ano]) .ano-${k}{animation:ano-${k}-h 0s 2.5s both}`).join('') +
'}';
function Style() {
return (
<style href="kitb5-analytics-overview" precedence="default">
{CSS}
</style>
);
}
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-ano-scope': '', 'data-ano': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const tagOf = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
type MetricKey = 'visitors' | 'views' | 'sessions' | 'conversions' | 'conversionRate' | 'bounceRate' | 'duration' | 'revenue';
type Fmt = ReturnType<typeof makeFmt>;
function makeFmt(locale: Locale, currency: string) {
const tag = tagOf(locale);
const n0 = new Intl.NumberFormat(tag, { maximumFractionDigits: 0 });
const n1 = new Intl.NumberFormat(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const compact = new Intl.NumberFormat(tag, { notation: 'compact', maximumFractionDigits: 1 });
const money = new Intl.NumberFormat(tag, { style: 'currency', currency, maximumFractionDigits: 0 });
const pct = (n: number, d = 1) => {
const s = (d ? n1 : n0).format(n);
return locale === 'de' ? `${s} %` : `${s}%`;
};
const signed = (n: number) => {
const s = n1.format(Math.abs(n));
const sign = n > 0.05 ? '+' : n < -0.05 ? '−' : '±';
return locale === 'de' ? `${sign}${s} %` : `${sign}${s}%`;
};
const pp = (n: number) => (locale === 'de' ? `${n1.format(n)} Pp.` : `${n1.format(n)} pts`);
const secs = (s: number) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, '0')}`;
const value = (k: MetricKey, v: number) => (k === 'conversionRate' || k === 'bounceRate' ? pct(v, 1) : k === 'duration' ? secs(v) : k === 'revenue' ? money.format(v) : n0.format(v));
const date = (iso: string, o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(tag, { timeZone: 'UTC', ...o }).format(new Date(iso + 'T00:00:00Z'));
return { pp, n: (v: number) => n0.format(v), compact: (v: number) => compact.format(v), pct, signed, value, date, money: (v: number) => money.format(v) };
}
/** Sums of a stretch of days, rates worked out from the sums */
function sumUp(days: AnalyticsDay[]): Record<MetricKey, number> {
const s = days.reduce((a, d) => ({ visitors: a.visitors + d.visitors, views: a.views + d.views, sessions: a.sessions + d.sessions, bounces: a.bounces + d.bounces, conversions: a.conversions + d.conversions, dur: a.dur + d.duration * d.sessions, revenue: a.revenue + (d.revenue ?? 0) }), { visitors: 0, views: 0, sessions: 0, bounces: 0, conversions: 0, dur: 0, revenue: 0 });
return {
visitors: s.visitors,
views: s.views,
sessions: s.sessions,
conversions: s.conversions,
conversionRate: s.visitors ? (s.conversions / s.visitors) * 100 : 0,
bounceRate: s.sessions ? (s.bounces / s.sessions) * 100 : 0,
duration: s.sessions ? s.dur / s.sessions : 0,
revenue: s.revenue,
};
}
const dayValue = (k: MetricKey, d: AnalyticsDay) => (k === 'conversionRate' ? (d.visitors ? (d.conversions / d.visitors) * 100 : 0) : k === 'bounceRate' ? (d.sessions ? (d.bounces / d.sessions) * 100 : 0) : k === 'duration' ? d.duration : k === 'revenue' ? (d.revenue ?? 0) : d[k]);
const change = (a: number, b: number) => (b ? ((a - b) / b) * 100 : 0);
/** Lower is better for these */
const INVERT: Partial<Record<MetricKey, boolean>> = { bounceRate: true };
type P = Omit<AnalyticsOverviewProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; f: Fmt; currency: string };
const focusRing = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const cardCls = 'rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
function Header({ title, subtitle, children }: { title?: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
if (!title && !subtitle && !children) return null;
return (
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
{title && <h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>}
</div>
{children && <div className="flex flex-wrap items-center gap-2 max-md:w-full">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Card({ title, action, children, className, bodyClassName, style }: { title?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string; style?: React.CSSProperties }) {
return (
<section className={cn(cardCls, 'flex min-w-0 flex-col', className)} style={style}>
{(title || action) && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 px-4 pt-4 sm:px-5 sm:pt-5">
{title && <h2 className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</h2>}
{action}
</div>
)}
<div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
</section>
);
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useGlide<HTMLDivElement>(value);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn('relative inline-flex h-11 flex-1 shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-colors duration-200 sm:h-8', focusRing, value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
>
{value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<span className="relative inline-flex items-center gap-1.5">{o.label}</span>
</button>
))}
</div>
);
}
/** Change as a chip, colored by good or bad news, not by direction */
function Delta({ value, invert, f, className }: { value: number; invert?: boolean; f: Fmt; className?: string }) {
const flat = Math.abs(value) < 0.05;
const good = flat ? null : invert ? value < 0 : value > 0;
return (
<span className={cn('inline-flex h-5 shrink-0 items-center gap-0.5 rounded px-1.5 text-[12px] font-medium tabular-nums', good === null ? 'bg-muted text-muted-foreground' : good ? 'bg-[color-mix(in_oklab,var(--success)_13%,transparent)] text-success-foreground' : 'bg-[color-mix(in_oklab,var(--destructive)_11%,transparent)] text-destructive', className)}>
<Icon name={flat ? 'minus' : value > 0 ? 'arrow-up-right' : 'arrow-down-right'} size={12} strokeWidth={2} />
{f.signed(value)}
</span>
);
}
/** A labelled share as a quiet bar, for lists */
function ShareBar({ label, value, max, text, delta, invert, f, i = 0, icon }: { label: string; value: number; max: number; text: string; delta?: number; invert?: boolean; f: Fmt; i?: number; icon?: IconName }) {
return (
<li className="group grid gap-1.5">
<p className="flex items-baseline justify-between gap-3 text-[13px]">
<span className="flex min-w-0 items-center gap-2 truncate text-foreground">
{icon && <Icon name={icon} size={14} className="text-muted-foreground" />}
<span className="truncate">{label}</span>
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums">
<span key={text} className="ano-in text-foreground">
{text}
</span>
{delta !== undefined && <Delta value={delta} invert={invert} f={f} />}
</span>
</p>
<span className="block h-1.5 overflow-hidden rounded-full bg-track">
<span className="ano-grow block h-full rounded-full bg-primary transition-[width] duration-700 ease-[var(--ease-kit)] group-hover:bg-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" style={{ width: `${Math.max(2, Math.round((value / Math.max(1, max)) * 100))}%`, '--ano-d': `${150 + i * 60}ms` } as React.CSSProperties} />
</span>
</li>
);
}
const RANGES = ['7', '30', '90'] as const;
type Range = (typeof RANGES)[number];
/* ------------------------------------------------------------------ */
/* overview */
/* ------------------------------------------------------------------ */
function Overview({ t, f, title, subtitle, days = [], pages = [], sources = [], onAction }: P) {
const id = useId();
const [range, setRange] = useState<Range>('30');
const [metric, setMetric] = useState<MetricKey>('visitors');
const [prev, setPrev] = useState(true);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const [row, fade] = useScrollFade<HTMLDivElement>();
const tabs = useGlide<HTMLDivElement>(metric);
const n = Math.min(Number(range), Math.floor(days.length / 2) || days.length);
const cur = days.slice(-n);
const before = days.slice(-2 * n, -n);
const a = sumUp(cur);
const b = sumUp(before);
const keys: MetricKey[] = ['visitors', 'views', 'conversionRate', 'bounceRate', 'duration'];
const chart = cur.map((d, i) => ({ date: d.date, current: Math.round(dayValue(metric, d) * 10) / 10, ...(prev && before[i] ? { previous: Math.round(dayValue(metric, before[i]) * 10) / 10 } : {}) }));
const fmt: Intl.NumberFormatOptions = metric === 'conversionRate' || metric === 'bounceRate' ? { maximumFractionDigits: 1 } : { maximumFractionDigits: 0 };
const top = [...sources].sort((x, y) => y.value - x.value)[0];
const ch = change(a[metric], b[metric]);
const pick = (i: number) => {
const k = keys[(i + keys.length) % keys.length];
setMetric(k);
requestAnimationFrame(() => {
const el = document.getElementById(`${id}-t-${k}`);
el?.focus();
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
});
};
const maxPage = Math.max(1, ...pages.map((p) => p.views));
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Segmented label={t.range} value={range} onChange={setRange} className="max-md:w-full" options={RANGES.map((r) => ({ value: r, label: t[`d${r}`] }))} />
<Button
size="sm"
variant="outline"
iconLeft={busy ? 'loader' : 'download'}
disabled={busy}
className="max-md:w-full"
onClick={async () => {
setBusy(true);
const csv = ['date,visitors,views,sessions,conversions', ...cur.map((d) => `${d.date},${d.visitors},${d.views},${d.sessions},${d.conversions}`)].join('\n');
if (onAction) await onAction('export', { range: n, csv });
else {
await wait(prefersReducedMotion() ? 0 : 500);
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
const link = document.createElement('a');
link.href = url;
link.download = `analytics-${n}d.csv`;
link.click();
URL.revokeObjectURL(url);
}
setBusy(false);
setMsg(fill(t.exported, { n }));
}}
>
{t.export}
</Button>
</Header>
<div ref={scope} {...enter} className="grid gap-5">
<section className={cn(cardCls, 'ano-up overflow-hidden')}>
{/* The figures are the tabs of the chart below */}
<div
ref={(el) => {
row.current = el;
tabs.current = el;
}}
role="tablist"
aria-label={t.metrics}
className={cn('kit-no-scrollbar relative flex overflow-x-auto border-b border-border lg:grid lg:grid-cols-5 lg:overflow-visible', fade)}
onKeyDown={(e) => {
const i = keys.indexOf(metric);
if (e.key === 'ArrowRight') (e.preventDefault(), pick(i + 1));
if (e.key === 'ArrowLeft') (e.preventDefault(), pick(i - 1));
if (e.key === 'Home') (e.preventDefault(), pick(0));
if (e.key === 'End') (e.preventDefault(), pick(keys.length - 1));
}}
>
{keys.map((k, i) => {
const on = metric === k;
return (
<button
key={k}
id={`${id}-t-${k}`}
type="button"
role="tab"
aria-selected={on}
aria-controls={`${id}-panel`}
tabIndex={on ? 0 : -1}
onClick={() => setMetric(k)}
className={cn('group relative w-[11.5rem] shrink-0 cursor-pointer border-e border-border p-4 text-left transition-colors duration-200 last:border-e-0 lg:w-auto', 'outline-none focus-visible:bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]', on ? 'bg-card' : 'bg-surface hover:bg-card')}
>
{on && <span data-glide aria-hidden className="absolute inset-x-0 top-0 h-0.5 bg-primary" />}
<span className="ano-up block" style={{ '--ano-d': `${i * 60}ms` } as React.CSSProperties}>
<span className="block truncate text-[12.5px] text-muted-foreground">{t[k]}</span>
<span key={range + a[k]} className="ano-in mt-1 block text-[22px] font-semibold tracking-[-0.03em] tabular-nums text-foreground">
{f.value(k, a[k])}
</span>
<span className="mt-1.5 flex items-center justify-between gap-2">
<Delta value={change(a[k], b[k])} invert={INVERT[k]} f={f} />
<span className="w-16 transition-transform duration-300 group-hover:-translate-y-0.5" aria-hidden>
<Sparkline data={cur.map((d) => Math.round(dayValue(k, d) * 10) / 10)} label={t[k]} interactive={false} tone={on ? 'accent' : 'muted'} height={22} endDot={false} animate={false} />
</span>
</span>
</span>
</button>
);
})}
</div>
<div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-t-${metric}`} className="p-4 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-[13px] text-muted-foreground">
<span className="font-medium text-foreground">{t[metric]}</span> · {fill(t.last, { n })}
</p>
<Switch checked={prev} onCheckedChange={setPrev} label={t.comparePrev} size="sm" />
</div>
<LineChart
key={metric + range + prev}
data={chart}
xKey="date"
series={[{ key: 'current', label: t.current }, ...(prev ? [{ key: 'previous', label: t.previous, dashed: true, color: 'var(--muted-foreground)' }] : [])]}
format={fmt}
xFormat={{ day: 'numeric', month: 'short' }}
baseline={metric === 'visitors' || metric === 'views' ? 'zero' : 'auto'}
dots={false}
height={260}
label={`${t[metric]}, ${fill(t.last, { n })}`}
/>
{top && (
<p key={metric + range} className="ano-in mt-4 flex items-start gap-2.5 rounded-lg bg-surface p-3 text-[13.5px] text-foreground">
<Icon name={ch >= 0 ? 'trending-up' : 'trending-down'} size={16} className="mt-0.5 shrink-0 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
{fill(ch >= 0 ? t.insightUp : t.insightDown, { metric: t[metric], p: f.pct(Math.abs(ch)), source: top.label })}
</p>
)}
</div>
</section>
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:items-start">
<Card title={t.topPages} className="ano-up [--ano-d:120ms]" bodyClassName="pt-3 sm:pt-3">
<ol className="grid gap-3.5">
{pages.map((p, i) => (
<ShareBar key={p.path} label={p.title ? `${p.title} · ${p.path}` : p.path} value={p.views} max={maxPage} text={f.n(p.views)} delta={p.previous ? change(p.views, p.previous) : undefined} f={f} i={i} />
))}
</ol>
</Card>
<Card title={t.sources} className="ano-up [--ano-d:180ms]">
<DonutChart data={sources.slice(0, 4).map((s) => ({ label: s.label, value: s.value }))} label={t.sources} centerLabel={t.visitors} size={180} legend="bottom" format={{ maximumFractionDigits: 0 }} />
</Card>
</div>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* realtime */
/* ------------------------------------------------------------------ */
/** Small deterministic random walk, so server and first paint agree */
function rng(seed: number) {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) % 4294967296;
return s / 4294967296;
};
}
function Realtime({ t, f, title, subtitle, live }: P) {
const [on, setOn] = useState(true);
const [state, setState] = useState(() => live ?? { now: 0, minutes: [], pages: [], sources: [], countries: [], events: [] });
const [fresh, setFresh] = useState<string | null>(null);
const [scope, enter] = useEnter<HTMLDivElement>();
const rand = useRef(rng(7));
useEffect(() => {
if (!on || !live) return;
const quiet = prefersReducedMotion();
const timer = setInterval(
() => {
const r = rand.current;
const delta = Math.round((r() - 0.47) * 8);
const moves = new Map(live.pages.map((p) => [p.path, Math.round((r() - 0.48) * 4)]));
const ev = live.events[Math.floor(r() * live.events.length)];
const evId = ev ? `${ev.id}-${Date.now()}` : null;
setState((s) => {
const now = Math.max(1, s.now + delta);
const pages = s.pages.map((p) => ({ ...p, users: Math.max(0, p.users + (moves.get(p.path) ?? 0)) })).sort((x, y) => y.users - x.users);
const events = ev && evId ? [{ ...ev, id: evId }, ...s.events].slice(0, 6) : s.events;
return { ...s, now, minutes: [...s.minutes.slice(1), now], pages, events };
});
setFresh(evId);
},
quiet ? 6000 : 2500,
);
return () => clearInterval(timer);
}, [on, live]);
if (!live) return null;
const s = state;
const maxPage = Math.max(1, ...s.pages.map((p) => p.users));
const peak = Math.max(1, ...s.minutes);
return (
<Main>
<Header title={title} subtitle={subtitle}>
<span className={cn('inline-flex h-9 items-center gap-2 rounded-lg border px-3 text-[13px] font-medium', on ? 'border-[color-mix(in_oklab,var(--success)_35%,var(--border))] text-success-foreground' : 'border-border text-muted-foreground')}>
<span className="relative grid size-2.5 place-items-center">
{on && <span aria-hidden className="ano-pulse absolute inset-0 rounded-full bg-success" />}
<span className={cn('relative size-2 rounded-full', on ? 'bg-success' : 'bg-subtle-foreground')} />
</span>
{on ? t.live : t.paused}
</span>
<Button size="sm" variant="outline" iconLeft={on ? 'pause' : 'play'} onClick={() => setOn(!on)} aria-pressed={!on}>
{on ? t.pause : t.resume}
</Button>
</Header>
<div ref={scope} {...enter} className="grid gap-5">
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
<section className={cn(cardCls, 'ano-up relative overflow-hidden p-5 sm:p-6')}>
<span aria-hidden className="kit-dots pointer-events-none absolute inset-0 opacity-50 [mask-image:radial-gradient(closest-side,black,transparent)]" />
<h2 className="relative text-[13px] font-medium text-muted-foreground">{t.rightNow}</h2>
<p className="relative mt-2 flex items-baseline gap-3">
<span key={s.now} className="ano-in text-[clamp(3rem,2.4rem+2.4vw,4.25rem)] font-semibold leading-none tracking-[-0.05em] tabular-nums text-foreground">
{f.n(s.now)}
</span>
</p>
<p className="relative mt-3 text-[12.5px] text-muted-foreground" aria-live="polite" aria-atomic>
<span className="sr-only">{on ? fill(t.updated, { n: s.now }) : ''}</span>
</p>
<ul className="relative mt-4 grid gap-2.5 border-t border-border pt-4">
{s.sources.slice(0, 3).map((x, i) => (
<ShareBar key={x.label} label={x.label} value={x.value} max={Math.max(...s.sources.map((y) => y.value))} text={f.n(x.value)} f={f} i={i} />
))}
</ul>
</section>
<Card title={t.perMinute} className="ano-up [--ano-d:80ms]">
{/* The bars are drawn here so a new minute can slide in without redrawing a whole chart */}
<div role="img" aria-label={`${t.perMinute}. ${s.minutes.map((m) => f.n(m)).join(', ')}`} className="flex h-[200px] items-end gap-[3px] border-b border-border pb-px sm:gap-1">
{s.minutes.map((m, i) => (
<span key={i} className="flex h-full flex-1 items-end">
<span className={cn('ano-grow block w-full origin-bottom rounded-t-[3px] transition-[height] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', i === s.minutes.length - 1 ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_32%,var(--track))]')} style={{ height: `${Math.max(3, Math.round((m / peak) * 100))}%`, '--ano-d': `${i * 18}ms` } as React.CSSProperties} />
</span>
))}
</div>
<p className="mt-2 flex justify-between text-[12px] tabular-nums text-subtle-foreground">
<span>−30 min</span>
<span>{t.live}</span>
</p>
</Card>
</div>
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 md:grid-cols-2 xl:grid-cols-3">
<Card title={t.activePages} className="ano-up [--ano-d:140ms]">
<ol className="grid gap-3.5">
{s.pages.slice(0, 6).map((p, i) => (
<ShareBar key={p.path} label={p.path} value={p.users} max={maxPage} text={f.n(p.users)} f={f} i={i} />
))}
</ol>
</Card>
<Card title={t.topCountries} className="ano-up [--ano-d:200ms]">
<ol className="grid gap-3.5">
{s.countries.slice(0, 6).map((c, i) => (
<ShareBar key={c.label} label={c.label} value={c.value} max={Math.max(...s.countries.map((x) => x.value))} text={f.n(c.value)} f={f} i={i} />
))}
</ol>
</Card>
<Card title={t.stream} className="ano-up [--ano-d:260ms] md:col-span-2 xl:col-span-1">
<ol className="relative grid gap-0">
{s.events.slice(0, 6).map((e) => (
<li key={e.id} className={cn('flex items-start gap-3 border-b border-border py-2.5 first:pt-0 last:border-b-0 last:pb-0', e.id === fresh && 'ano-fly')}>
<span className={cn('mt-1.5 size-2 shrink-0 rounded-full', e.id === fresh ? 'bg-primary' : 'bg-border-strong')} />
<span className="min-w-0 text-[13px]">
<span className="block text-foreground">{e.text}</span>
<span className="block truncate text-[12px] text-muted-foreground">{[e.path, e.place].filter(Boolean).join(' · ')}</span>
</span>
</li>
))}
</ol>
</Card>
</div>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* conversion */
/* ------------------------------------------------------------------ */
function Conversion({ t, f, title, subtitle, funnels = [], cohorts = [], goals = [] }: P) {
const [seg, setSeg] = useState(funnels[0]?.id ?? '');
const [scope, enter] = useEnter<HTMLDivElement>();
const fu = funnels.find((x) => x.id === seg) ?? funnels[0];
if (!fu) return null;
const steps = fu.steps;
const rate = steps.length > 1 && steps[0].value ? (steps[steps.length - 1].value / steps[0].value) * 100 : 0;
let drop = { i: 1, p: 0 };
steps.forEach((s, i) => {
if (!i || !steps[i - 1].value) return;
const p = (1 - s.value / steps[i - 1].value) * 100;
if (p > drop.p) drop = { i, p };
});
const weeks = Math.max(0, ...cohorts.map((c) => c.values.length));
return (
<Main>
<Header title={title} subtitle={subtitle}>
{funnels.length > 1 && <Segmented label={t.segment} value={seg} onChange={setSeg} className="max-md:w-full" options={funnels.map((x) => ({ value: x.id, label: x.label }))} />}
</Header>
<div ref={scope} {...enter} className="grid gap-5">
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
<Card title={t.funnel} className="ano-up">
<div key={seg} className="ano-in">
<Funnel steps={steps} label={`${t.funnel} · ${fu.label}`} height={220} format={{ maximumFractionDigits: 0 }} />
</div>
</Card>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-1">
<section className={cn(cardCls, 'ano-up p-5 [--ano-d:80ms]')}>
<h2 className="text-[13px] font-medium text-muted-foreground">{t.fromStart}</h2>
<p key={seg} className="ano-in mt-1 text-[34px] font-semibold tracking-[-0.04em] tabular-nums text-foreground">
{f.pct(rate, 1)}
</p>
<p className="mt-1 text-[12.5px] tabular-nums text-muted-foreground">
{f.n(steps[steps.length - 1]?.value ?? 0)} / {f.n(steps[0]?.value ?? 0)}
</p>
</section>
<section className={cn(cardCls, 'ano-up p-5 [--ano-d:140ms]')}>
<h2 className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
<Icon name="trending-down" size={15} className="text-destructive" />
{t.biggestDrop}
</h2>
<p key={seg} className="ano-in mt-2 text-pretty text-[14px] leading-relaxed text-foreground">
{fill(t.dropText, { p: f.pct(drop.p, 0), a: steps[drop.i - 1]?.label ?? '', b: steps[drop.i]?.label ?? '' })}
</p>
</section>
</div>
</div>
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
{cohorts.length > 0 && (
<Card title={t.cohorts} className="ano-up [--ano-d:160ms]">
<Heatmap
variant="matrix"
rows={cohorts.map((c) => `${c.label} · ${fill(t.cohortSize, { n: f.n(c.size) })}`)}
columns={Array.from({ length: weeks }, (_, i) => String(i))}
values={cohorts.map((c) => Array.from({ length: weeks }, (_, i) => c.values[i] ?? 0))}
format={{ style: 'unit', unit: 'percent', maximumFractionDigits: 0 }}
max={100}
rowLabel={t.cohort}
label={t.cohorts}
/>
<p className="mt-3 text-[12px] text-subtle-foreground">{t.cohortNote}</p>
</Card>
)}
{goals.length > 0 && (
<Card title={t.goals} className="ano-up [--ano-d:220ms]">
<Gauge variant="row" framed={false} label={t.goals} items={goals.map((g) => ({ label: g.label, value: g.value, max: g.target, target: g.target, note: `${f.n(g.value)} / ${f.n(g.target)}` }))} />
</Card>
)}
</div>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* audience */
/* ------------------------------------------------------------------ */
const DEVICE_ICON: IconName[] = ['monitor', 'tablet', 'laptop', 'watch'];
function Audience({ t, f, title, subtitle, devices = [], countries = [], languages = [], visitorTypes = [], hours }: P) {
const [as, setAs] = useState<'share' | 'count'>('share');
const [scope, enter] = useEnter<HTMLDivElement>();
const langTotal = languages.reduce((s, x) => s + x.value, 0) || 1;
const vt = visitorTypes.reduce((a, d) => ({ n: a.n + d.new, r: a.r + d.returning }), { n: 0, r: 0 });
const retShare = vt.n + vt.r ? (vt.r / (vt.n + vt.r)) * 100 : 0;
const maxLang = Math.max(1, ...languages.map((x) => x.value));
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="grid gap-5">
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] xl:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)_minmax(0,1fr)]">
<Card title={t.devices} className="ano-up">
<DonutChart data={devices.slice(0, 4)} label={t.devices} centerLabel={t.visitors} size={170} legend="bottom" format={{ maximumFractionDigits: 0 }} />
</Card>
<Card title={t.countries} className="ano-up [--ano-d:80ms]">
<BarChart data={countries.slice(0, 6).map((c) => ({ country: c.label, visitors: c.value }))} xKey="country" series={[{ key: 'visitors', label: t.visitors }]} orientation="horizontal" valueLabels rowHeight={36} highlight={countries[0]?.label} label={t.countries} format={{ maximumFractionDigits: 0 }} />
</Card>
<Card
title={t.languages}
className="ano-up [--ano-d:140ms] md:col-span-2 xl:col-span-1"
action={<Segmented label={t.show} value={as} onChange={setAs} options={[{ value: 'share', label: t.share }, { value: 'count', label: t.count }]} />}
>
<ol className="grid gap-3.5">
{languages.map((x, i) => (
<ShareBar key={x.label} label={x.label} value={x.value} max={as === 'share' ? langTotal : maxLang} text={as === 'share' ? f.pct((x.value / langTotal) * 100, 1) : f.n(x.value)} f={f} i={i} icon={i === 0 ? 'languages' : undefined} />
))}
</ol>
</Card>
</div>
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 xl:grid-cols-2">
<Card title={t.newVsReturning} className="ano-up [--ano-d:160ms]" action={<span className="text-[12.5px] text-muted-foreground">{fill(t.returningShare, { p: f.pct(retShare, 0) })}</span>}>
<AreaChart data={visitorTypes} xKey="date" series={[{ key: 'returning', label: t.returning }, { key: 'new', label: t.new }]} stacked xFormat={{ day: 'numeric', month: 'short' }} format={{ maximumFractionDigits: 0 }} height={240} label={t.newVsReturning} />
</Card>
{hours && (
<Card title={t.when} className="ano-up [--ano-d:220ms]">
<Heatmap variant="matrix" rows={hours.rows} columns={hours.columns} values={hours.values} format={{ maximumFractionDigits: 0 }} label={t.when} />
</Card>
)}
</div>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* compare */
/* ------------------------------------------------------------------ */
function Compare({ t, f, title, subtitle, days = [], sources = [] }: P) {
const id = useId();
const [range, setRange] = useState<Range>('30');
const [metric, setMetric] = useState<MetricKey>('visitors');
const [scope, enter] = useEnter<HTMLDivElement>();
const n = Math.min(Number(range), Math.floor(days.length / 2) || days.length);
const cur = days.slice(-n);
const before = days.slice(-2 * n, -n);
const a = sumUp(cur);
const b = sumUp(before);
const keys: MetricKey[] = ['visitors', 'views', 'sessions', 'conversions', 'conversionRate', 'bounceRate', 'duration', ...(days.some((d) => d.revenue) ? (['revenue'] as MetricKey[]) : [])];
const span = (xs: AnalyticsDay[]) => (xs.length ? fill(t.spanRange, { a: f.date(xs[0].date, { day: 'numeric', month: 'short' }), b: f.date(xs[xs.length - 1].date, { day: 'numeric', month: 'short' }) }) : '');
const chart = cur.map((d, i) => ({ day: i + 1, current: Math.round(dayValue(metric, d) * 10) / 10, previous: before[i] ? Math.round(dayValue(metric, before[i]) * 10) / 10 : null }));
const verdict = (k: MetricKey) => {
const c = change(a[k], b[k]);
if (Math.abs(c) < 0.05) return t.same;
return (INVERT[k] ? c < 0 : c > 0) ? t.better : t.worse;
};
const diff = (k: MetricKey) => {
const d = a[k] - b[k];
const s = k === 'conversionRate' || k === 'bounceRate' ? f.pp(Math.abs(d)) : f.value(k, Math.abs(d));
return `${d > 0 ? '+' : d < 0 ? '−' : '±'}${s}`;
};
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Segmented label={t.range} value={range} onChange={setRange} className="max-md:w-full" options={RANGES.map((r) => ({ value: r, label: t[`d${r}`] }))} />
</Header>
<div ref={scope} {...enter} className="grid gap-5">
{/* The two periods side by side */}
<div className="ano-up grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-border bg-border">
{[
{ k: t.current, v: span(cur), dot: 'bg-primary' },
{ k: t.previous, v: span(before), dot: 'border-2 border-dashed border-muted-foreground' },
].map((x) => (
<div key={x.k} className="bg-card px-4 py-3 sm:px-5">
<p className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
<span className={cn('size-2.5 rounded-full', x.dot)} />
{x.k}
</p>
<p key={x.v} className="ano-in mt-0.5 text-[14px] font-medium tabular-nums text-foreground">
{x.v}
</p>
</div>
))}
</div>
<div className="grid grid-cols-[minmax(0,1fr)] gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] xl:items-start">
<section aria-labelledby={`${id}-tbl`} className={cn(cardCls, 'ano-up overflow-hidden [--ano-d:80ms]')}>
<h2 id={`${id}-tbl`} className="px-4 pt-4 text-[14px] font-semibold text-foreground sm:px-5 sm:pt-5">
{t.compare}
</h2>
{/* Tablet and desktop: a table, each row puts its measure in the chart */}
<table className="mt-2 hidden w-full border-collapse text-[13.5px] sm:table">
<thead>
<tr className="border-b border-border text-left text-[12px] text-muted-foreground">
<th scope="col" className="px-5 py-2 font-medium">{t.metric}</th>
<th scope="col" className="px-3 py-2 text-right font-medium">{t.current}</th>
<th scope="col" className="px-3 py-2 text-right font-medium">{t.previous}</th>
<th scope="col" className="px-5 py-2 text-right font-medium">{t.change}</th>
</tr>
</thead>
<tbody key={range}>
{keys.map((k, i) => {
const on = metric === k;
return (
<tr key={k} className={cn('ano-in border-b border-border last:border-b-0', on ? 'bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'hover:bg-surface')} style={{ '--ano-d': `${i * 30}ms` } as React.CSSProperties}>
<th scope="row" className="px-2 py-1 text-left font-normal">
<button type="button" aria-pressed={on} aria-label={fill(t.showChart, { metric: t[k] })} onClick={() => setMetric(k)} className={cn('flex h-10 w-full cursor-pointer items-center gap-2 rounded-md px-3 text-left transition-colors', focusRing, on ? 'font-medium text-foreground' : 'text-muted-foreground hover:text-foreground')}>
<span className={cn('h-4 w-0.5 rounded-full transition-colors', on ? 'bg-primary' : 'bg-transparent')} />
{t[k]}
</button>
</th>
<td className="px-3 py-2 text-right font-medium tabular-nums text-foreground">{f.value(k, a[k])}</td>
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{f.value(k, b[k])}</td>
<td className="px-5 py-2 text-right">
<span className="inline-flex items-center gap-2">
<span className="text-[12px] tabular-nums text-subtle-foreground max-lg:hidden">{diff(k)}</span>
<Delta value={change(a[k], b[k])} invert={INVERT[k]} f={f} />
</span>
</td>
</tr>
);
})}
</tbody>
</table>
{/* Phones: one row per measure, tap to chart it */}
<ul key={`m${range}`} className="mt-2 grid sm:hidden">
{keys.map((k) => {
const on = metric === k;
return (
<li key={k} className="border-t border-border">
<button type="button" aria-pressed={on} onClick={() => setMetric(k)} className={cn('grid min-h-14 w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 px-4 py-2.5 text-left', 'outline-none focus-visible:bg-[color-mix(in_oklab,var(--primary)_8%,transparent)] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]', on && 'bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]')}>
<span className="min-w-0">
<span className={cn('block text-[13.5px]', on ? 'font-medium text-foreground' : 'text-muted-foreground')}>{t[k]}</span>
<span className="block text-[12.5px] tabular-nums text-subtle-foreground">
{f.value(k, b[k])} → <span className="font-medium text-foreground">{f.value(k, a[k])}</span>
</span>
</span>
<span className="flex flex-col items-end gap-0.5">
<Delta value={change(a[k], b[k])} invert={INVERT[k]} f={f} />
<span className="text-[11.5px] text-subtle-foreground">{verdict(k)}</span>
</span>
</button>
</li>
);
})}
</ul>
</section>
<div className="grid gap-5">
<Card title={t[metric]} className="ano-up [--ano-d:140ms]" action={<Delta value={change(a[metric], b[metric])} invert={INVERT[metric]} f={f} />}>
<LineChart key={metric + range} data={chart} xKey="day" xLabel={t.range} series={[{ key: 'current', label: t.current }, { key: 'previous', label: t.previous, dashed: true, color: 'var(--muted-foreground)' }]} format={metric === 'conversionRate' || metric === 'bounceRate' ? { maximumFractionDigits: 1 } : { maximumFractionDigits: 0 }} baseline="auto" dots={false} height={230} label={`${t[metric]}, ${t.current} / ${t.previous}`} />
</Card>
{sources.length > 0 && (
<Card title={t.byChannel} className="ano-up [--ano-d:200ms]">
<BarChart data={sources.map((s) => ({ channel: s.label, current: s.value, previous: s.previous ?? 0 }))} xKey="channel" series={[{ key: 'current', label: t.current }, { key: 'previous', label: t.previous, color: 'color-mix(in oklab, var(--muted-foreground) 55%, transparent)' }]} format={{ maximumFractionDigits: 0 }} height={230} label={t.byChannel} />
</Card>
)}
</div>
</div>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* AnalyticsOverview */
/* ------------------------------------------------------------------ */
/**
* Analytics screens that go past a dashboard: key figures as tabs with the
* period before, a live view that ticks, a funnel with cohorts and goals, the
* audience by device, country, language and hour, and a period comparison.
* <AnalyticsOverview variant="overview" days={[…]} pages={[…]} sources={[…]} />
*/
export function AnalyticsOverview({ variant = 'overview', sidebar, labels, className, currency = 'EUR', ...props }: AnalyticsOverviewProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const f = useMemo(() => makeFmt(locale, currency), [locale, currency]);
const p: P = { ...props, t, locale, f, currency };
const body = (() => {
switch (variant) {
case 'realtime':
return <Realtime {...p} />;
case 'conversion':
return <Conversion {...p} />;
case 'audience':
return <Audience {...p} />;
case 'compare':
return <Compare {...p} />;
default:
return <Overview {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}