Zum Inhalt springen
UI Kit
Accent

Analytics Dashboard

Analysen in fünf Screens. Kennzahlen mit dem Zeitraum davor, eine Live-Ansicht, ein Trichter mit Kohorten und Zielen, das Publikum und ein Zeitraumvergleich.

5 VariantsEine von 2 Components in Analysen und Berichte

Die Zahlen, und was sich geändert hat

Ein Analytics Dashboard sollte drei Fragen in Sekunden beantworten, wie viele, verglichen womit und wohin als Nächstes schauen. Diese fünf Screens stellen jede Kennzahl neben den Zeitraum davor und zeichnen ihre Diagramme mit den Chart Components des Kits.

Die „Analyse-Übersicht“ hat eine Zeitraumwahl und fünf Kennzahlen mit Veränderung und Sparkline, die als Reiter das Diagramm steuern, der Zeitraum davor läuft gestrichelt mit, dazu ein Hinweis, Top-Seiten und Quellen. „Echtzeit“ zeigt Besucher gerade mit ruhigem Puls, die letzten 30 Minuten als Balken, aktive Seiten, Länder und einen Ereignisstrom, der sich anhalten lässt. „Conversion und Bindung“ zeichnet einen Trichter je Segment mit dem größten Absprung, Ziele als kleine Messuhren und eine Kohorten-Heatmap nach Woche des ersten Besuchs. Das „Publikum“ zeigt Geräte als Ring, Länder als Balken, Sprachen, neue und wiederkehrende Besucher und ihre Uhrzeiten als Heatmap. Der „Zeitraumvergleich“ liest jede Veränderung als besser oder schlechter und stellt Kanäle nebeneinander.

Wähle die fünf Kennzahlen, nach denen dein Team handelt, und lass Zahlen fürs Ego weg. Nenn den größten Absprung im Trichter in Worten, dann muss niemand suchen. Echtzeit passt gut als zweiter Reiter, an Launch-Tagen schauen alle dort, die Übersicht trägt den Alltag.

Variants

Analyse-ÜbersichtNeu in 1.2

Zeitraum, fünf Kennzahlen mit Veränderung und Sparkline als Reiter fürs Diagramm, der Zeitraum davor gestrichelt, ein Hinweis, Top-Seiten und Quellen.

EchtzeitNeu in 1.2

Besucher gerade mit ruhigem Puls, die letzten 30 Minuten als Balken, aktive Seiten, Länder und ein Ereignisstrom, der live tickt und sich anhalten lässt.

Conversion und BindungNeu in 1.2

Ein Trichter je Segment mit dem größten Absprung, die Gesamtrate, Ziele als kleine Messuhren und eine Kohorten-Heatmap nach Woche des ersten Besuchs.

PublikumNeu in 1.2

Geräte als Ring, Länder als Balken, Sprachen nach Anteil oder Anzahl, neue und wiederkehrende Besucher im Verlauf und ihre Uhrzeiten als Heatmap.

ZeitraumvergleichNeu in 1.2

Dieser Zeitraum gegen den davor, jede Kennzahl mit beiden Werten und der Veränderung als besser oder schlechter, Diagramm je Kennzahl und Kanäle im Vergleich.

Details

Was Analyse-Übersicht mitbringt, aus Registry und Quellcode gelesen.

Bereich
App
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/analytics-overview.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 69 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle AnalyticsOverviewProps aus components/ui/analytics-overview.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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.

Eingebaute Texte

Diese Texte schreibt Analyse-Übersicht selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 69 Texte anzeigen
rangeEnglisch Time rangeDeutsch Zeitraum
d7Englisch 7 daysDeutsch 7 Tage
d30Englisch 30 daysDeutsch 30 Tage
d90Englisch 90 daysDeutsch 90 Tage
lastEnglisch Last {n} daysDeutsch Letzte {n} Tage
vsBeforeEnglisch vs. the {n} days beforeDeutsch ggü. den {n} Tagen davor
comparePrevEnglisch Show period beforeDeutsch Zeitraum davor zeigen
metricsEnglisch Key figuresDeutsch Kennzahlen
visitorsEnglisch VisitorsDeutsch Besucher
viewsEnglisch Page viewsDeutsch Seitenaufrufe
sessionsEnglisch SessionsDeutsch Sitzungen
conversionsEnglisch ConversionsDeutsch Conversions
conversionRateEnglisch Conversion rateDeutsch Conversion-Rate
bounceRateEnglisch Bounce rateDeutsch Absprungrate
durationEnglisch Time on siteDeutsch Verweildauer
revenueEnglisch RevenueDeutsch Umsatz
currentEnglisch This periodDeutsch Dieser Zeitraum
previousEnglisch Period beforeDeutsch Zeitraum davor
topPagesEnglisch Top pagesDeutsch Top-Seiten
pageEnglisch PageDeutsch Seite
changeEnglisch ChangeDeutsch Veränderung
sourcesEnglisch SourcesDeutsch Quellen
insightUpEnglisch {metric} rose by {p} against the period before, most of it from {source}.Deutsch {metric} stieg um {p} gegenüber dem Zeitraum davor, vor allem über {source}.
insightDownEnglisch {metric} fell by {p} against the period before. {source} still brings the most.Deutsch {metric} sank um {p} gegenüber dem Zeitraum davor. {source} bringt weiter am meisten.
exportEnglisch Export CSVDeutsch CSV exportieren
exportedEnglisch The CSV with {n} days is in your downloads.Deutsch Die CSV mit {n} Tagen liegt in deinen Downloads.
liveEnglisch LiveDeutsch Live
pausedEnglisch PausedDeutsch Pausiert
pauseEnglisch Pause live updatesDeutsch Live-Daten anhalten
resumeEnglisch Resume live updatesDeutsch Live-Daten fortsetzen
rightNowEnglisch Visitors right nowDeutsch Besucher gerade
perMinuteEnglisch Visitors per minute, last 30 minutesDeutsch Besucher pro Minute, letzte 30 Minuten
activePagesEnglisch Active pagesDeutsch Aktive Seiten
usersEnglisch VisitorsDeutsch Besucher
streamEnglisch What just happenedDeutsch Gerade passiert
topCountriesEnglisch CountriesDeutsch Länder
topSourcesEnglisch Sources nowDeutsch Quellen jetzt
updatedEnglisch {n} visitors right now.Deutsch {n} Besucher gerade.
segmentEnglisch SegmentDeutsch Segment
funnelEnglisch FunnelDeutsch Trichter
fromStartEnglisch Converted from first stepDeutsch Vom ersten Schritt konvertiert
biggestDropEnglisch Biggest dropDeutsch Größter Absprung
dropTextEnglisch {p} leave between {a} and {b}. That step is the best lever.Deutsch {p} steigen zwischen {a} und {b} aus. Dort liegt der größte Hebel.
goalsEnglisch Goals this monthDeutsch Ziele diesen Monat
cohortsEnglisch Retention by week of first visitDeutsch Bindung nach Woche des ersten Besuchs
cohortWeekEnglisch Week {n}Deutsch Woche {n}
cohortEnglisch CohortDeutsch Kohorte
cohortSizeEnglisch {n} visitorsDeutsch {n} Besucher
devicesEnglisch DevicesDeutsch Geräte
countriesEnglisch CountriesDeutsch Länder
languagesEnglisch LanguagesDeutsch Sprachen
newVsReturningEnglisch New and returningDeutsch Neu und wiederkehrend
newEnglisch NewDeutsch Neu
returningEnglisch ReturningDeutsch Wiederkehrend
whenEnglisch When visitors comeDeutsch Wann Besucher kommen
shareEnglisch ShareDeutsch Anteil
countEnglisch CountDeutsch Anzahl
showEnglisch Show asDeutsch Zeigen als
returningShareEnglisch {p} of visitors come backDeutsch {p} der Besucher kommen wieder
compareEnglisch CompareDeutsch Vergleich
metricEnglisch MeasureDeutsch Kennzahl
showChartEnglisch Show {metric} in the chartDeutsch {metric} im Diagramm zeigen
byChannelEnglisch Visitors by channelDeutsch Besucher nach Kanal
betterEnglisch betterDeutsch besser
worseEnglisch worseDeutsch schlechter
sameEnglisch unchangedDeutsch gleich
totalEnglisch TotalDeutsch Gesamt
spanRangeEnglisch {a} to {b}Deutsch {a} bis {b}
cohortNoteEnglisch Columns count the weeks since the first visit, week 0 is that week itself.Deutsch Die Spalten zählen die Wochen seit dem ersten Besuch, Woche 0 ist diese Woche selbst.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Analyse-Übersicht reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Setzt aria-checked, aria-controls, aria-label, aria-labelledby, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen img, radio, radiogroup, status, tab, tablist und tabpanel.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.096 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Analyse-Übersicht?

Mit aiw add @ai-website/analytics-overview. Der Befehl legt die Datei components/ui/analytics-overview.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind area-chart, bar-chart, button, donut-chart, funnel, gauge, heatmap, icon, line-chart, motion, sparkline, switch und sidebar. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Analyse-Übersicht?

5 Varianten, Analyse-Übersicht, Echtzeit, Conversion und Bindung, Publikum und Zeitraumvergleich. Du wählst sie mit der Prop variant, zum Beispiel variant="realtime".

Kann ich die Texte von Analyse-Übersicht übersetzen?

Ja. Analyse-Übersicht bringt 69 eigene Texte auf Deutsch und Englisch mit, etwa „Zeitraum“, „7 Tage“ und „30 Tage“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Analyse-Übersicht einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Analyse-Übersicht mit aiw_components und installieren sie mit aiw_add @ai-website/analytics-overview. Nenn im Prompt die Variante, etwa variant="overview", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert