Skip to content
UI Kit
Accent color

Analytics dashboard

Analytics in five screens. Key figures with the period before, a live view, a funnel with cohorts and goals, the audience and a period comparison.

5 variantsOne of 2 components in Analytics and reports

The numbers, and what changed

An analytics dashboard should answer three questions in seconds, how many, compared with when, and what to look at next. These five screens put every figure next to the period before and draw their charts with the chart components of the kit.

Analytics overview has a range switch and five figures with change and sparkline that act as tabs for the chart, the period before runs as a dashed line, with an insight, top pages and sources. Realtime shows visitors right now with a quiet pulse, the last 30 minutes as bars, active pages, countries and an event stream that pauses on request. Conversion and retention draws a funnel per segment with the biggest drop named, goals as small gauges and a cohort heatmap by week of first visit. Audience shows devices as a ring, countries as bars, languages, new and returning visitors and the hours they come as a heatmap. Period comparison reads every change as better or worse and sets channels side by side.

Pick the five figures your team acts on and keep vanity numbers out. Name the biggest drop in the funnel in words, then nobody has to search for it. Realtime is a good second tab, people check it on launch days, the overview carries the daily work.

Variants

Analytics overviewNew in 1.2

Range switch, five figures with change and sparkline as tabs for the chart, the period before as a dashed line, an insight, top pages and sources.

RealtimeNew in 1.2

Visitors right now with a quiet pulse, the last 30 minutes as bars, active pages, countries and an event stream that ticks live and pauses on request.

Conversion and retentionNew in 1.2

A funnel per segment with the biggest drop named, the overall rate, goals as small gauges and a cohort heatmap by week of first visit.

AudienceNew in 1.2

Devices as a ring, countries as bars, languages by share or count, new and returning visitors over time and the hours they come as a heatmap.

Period comparisonNew in 1.2

This period against the one before, every measure with both values and the change read as better or worse, a chart per measure and channels side by side.

Details

What Analytics overview brings along, read from the registry and the source.

Area
App
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/analytics-overview.tsx
npm packages
None
Languages
English and German, 69 texts of its own
Studio
Adjust in code

Props

The AnalyticsOverviewProps interface from components/ui/analytics-overview.tsx, with the comments from the code.

  • variantoptional
    AnalyticsOverviewVariant

    overviewrealtimeconversionaudiencecompare

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titleoptional
    string
  • subtitleoptional
    string
  • currencyoptional
    string

    ISO currency code for revenue

  • daysoptional
    AnalyticsDay[]

    overview, compare: one row per day, oldest first, twice as long as the longest range (for the period before)

  • pagesoptional
    AnalyticsPage[]

    overview: the most viewed pages in the range

  • sourcesoptional
    AnalyticsShare[]

    overview, compare: where visitors come from

  • devicesoptional
    AnalyticsShare[]

    audience

  • countriesoptional
    AnalyticsShare[]
  • languagesoptional
    AnalyticsShare[]
  • visitorTypesoptional
    { date: string; new: number; returning: number }[]

    audience: new and returning visitors per day

  • hoursoptional
    { rows: string[]; columns: string[]; values: number[][] }

    audience: visits by weekday (rows) and hour (columns)

  • funnelsoptional
    AnalyticsFunnel[]

    conversion

  • cohortsoptional
    AnalyticsCohort[]
  • goalsoptional
    AnalyticsGoal[]
  • liveoptional
    AnalyticsLive

    realtime

  • onActionoptional
    (action: string, data: Record<string, unknown>) => void | Promise<void>

    Called on export and range change. Without it the block simulates the request.

Built-in texts

Analytics overview writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 69 texts
rangeEnglish Time rangeGerman Zeitraum
d7English 7 daysGerman 7 Tage
d30English 30 daysGerman 30 Tage
d90English 90 daysGerman 90 Tage
lastEnglish Last {n} daysGerman Letzte {n} Tage
vsBeforeEnglish vs. the {n} days beforeGerman ggü. den {n} Tagen davor
comparePrevEnglish Show period beforeGerman Zeitraum davor zeigen
metricsEnglish Key figuresGerman Kennzahlen
visitorsEnglish VisitorsGerman Besucher
viewsEnglish Page viewsGerman Seitenaufrufe
sessionsEnglish SessionsGerman Sitzungen
conversionsEnglish ConversionsGerman Conversions
conversionRateEnglish Conversion rateGerman Conversion-Rate
bounceRateEnglish Bounce rateGerman Absprungrate
durationEnglish Time on siteGerman Verweildauer
revenueEnglish RevenueGerman Umsatz
currentEnglish This periodGerman Dieser Zeitraum
previousEnglish Period beforeGerman Zeitraum davor
topPagesEnglish Top pagesGerman Top-Seiten
pageEnglish PageGerman Seite
changeEnglish ChangeGerman Veränderung
sourcesEnglish SourcesGerman Quellen
insightUpEnglish {metric} rose by {p} against the period before, most of it from {source}.German {metric} stieg um {p} gegenüber dem Zeitraum davor, vor allem über {source}.
insightDownEnglish {metric} fell by {p} against the period before. {source} still brings the most.German {metric} sank um {p} gegenüber dem Zeitraum davor. {source} bringt weiter am meisten.
exportEnglish Export CSVGerman CSV exportieren
exportedEnglish The CSV with {n} days is in your downloads.German Die CSV mit {n} Tagen liegt in deinen Downloads.
liveEnglish LiveGerman Live
pausedEnglish PausedGerman Pausiert
pauseEnglish Pause live updatesGerman Live-Daten anhalten
resumeEnglish Resume live updatesGerman Live-Daten fortsetzen
rightNowEnglish Visitors right nowGerman Besucher gerade
perMinuteEnglish Visitors per minute, last 30 minutesGerman Besucher pro Minute, letzte 30 Minuten
activePagesEnglish Active pagesGerman Aktive Seiten
usersEnglish VisitorsGerman Besucher
streamEnglish What just happenedGerman Gerade passiert
topCountriesEnglish CountriesGerman Länder
topSourcesEnglish Sources nowGerman Quellen jetzt
updatedEnglish {n} visitors right now.German {n} Besucher gerade.
segmentEnglish SegmentGerman Segment
funnelEnglish FunnelGerman Trichter
fromStartEnglish Converted from first stepGerman Vom ersten Schritt konvertiert
biggestDropEnglish Biggest dropGerman Größter Absprung
dropTextEnglish {p} leave between {a} and {b}. That step is the best lever.German {p} steigen zwischen {a} und {b} aus. Dort liegt der größte Hebel.
goalsEnglish Goals this monthGerman Ziele diesen Monat
cohortsEnglish Retention by week of first visitGerman Bindung nach Woche des ersten Besuchs
cohortWeekEnglish Week {n}German Woche {n}
cohortEnglish CohortGerman Kohorte
cohortSizeEnglish {n} visitorsGerman {n} Besucher
devicesEnglish DevicesGerman Geräte
countriesEnglish CountriesGerman Länder
languagesEnglish LanguagesGerman Sprachen
newVsReturningEnglish New and returningGerman Neu und wiederkehrend
newEnglish NewGerman Neu
returningEnglish ReturningGerman Wiederkehrend
whenEnglish When visitors comeGerman Wann Besucher kommen
shareEnglish ShareGerman Anteil
countEnglish CountGerman Anzahl
showEnglish Show asGerman Zeigen als
returningShareEnglish {p} of visitors come backGerman {p} der Besucher kommen wieder
compareEnglish CompareGerman Vergleich
metricEnglish MeasureGerman Kennzahl
showChartEnglish Show {metric} in the chartGerman {metric} im Diagramm zeigen
byChannelEnglish Visitors by channelGerman Besucher nach Kanal
betterEnglish betterGerman besser
worseEnglish worseGerman schlechter
sameEnglish unchangedGerman gleich
totalEnglish TotalGerman Gesamt
spanRangeEnglish {a} to {b}German {a} bis {b}
cohortNoteEnglish Columns count the weeks since the first visit, week 0 is that week itself.German Die Spalten zählen die Wochen seit dem ersten Besuch, Woche 0 ist diese Woche selbst.

Accessibility

Read from the source of the component.

  • Analytics overview handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
  • Sets aria-checked, aria-controls, aria-label, aria-labelledby, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
  • Uses the roles img, radio, radiogroup, status, tab, tablist and tabpanel.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,096 lines
components/ui/analytics-overview.tsx
'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>
  );
}

Frequent questions

How do I install Analytics overview?

With aiw add @ai-website/analytics-overview. The command puts components/ui/analytics-overview.tsx into your project, the code is yours from then on. It brings along area-chart, bar-chart, button, donut-chart, funnel, gauge, heatmap, icon, line-chart, motion, sparkline, switch and sidebar. It needs no further npm packages.

Which variants does Analytics overview have?

5 variants, Analytics overview, Realtime, Conversion and retention, Audience and Period comparison. You pick one with the variant prop, for example variant="realtime".

Can I translate the texts of Analytics overview?

Yes. Analytics overview carries 69 texts of its own in English and German, such as “Time range”, “7 days” and “30 days”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Analytics overview?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Analytics overview with aiw_components and install it with aiw_add @ai-website/analytics-overview. Name the variant in your prompt, for example variant="overview", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard