← All templates

Analytics dashboard

Reporting windows, acquisition filters, revenue and conversion charts, exact tables and CSV export.

Illustrative data · September 2026 · USD. No live service is connected.

Performance overview

One window. One source of truth.

2026-09-17 – 2026-09-30 · Recorded days: 14 / 14

Revenue
$60,644.00
Visits
23,778
Converted visits
1,210
Conversion rate
5.09%

Revenue against target

Daily revenue, displayed as currency. The dashed line is the target.

Revenue: $60,644.00 · Target: $75,810.00

View exact data
Revenue against target
DateRevenueTarget
2026-09-17$3,682.00$5,064.00
2026-09-18$3,842.00$5,118.00
2026-09-19$4,292.00$5,172.00
2026-09-20$4,542.00$5,226.00
2026-09-21$3,640.00$5,280.00
2026-09-22$4,040.00$5,334.00
2026-09-23$4,142.00$5,388.00
2026-09-24$4,642.00$5,442.00
2026-09-25$4,842.00$5,496.00
2026-09-26$3,890.00$5,550.00
2026-09-27$4,340.00$5,604.00
2026-09-28$4,550.00$5,658.00
2026-09-29$5,000.00$5,712.00
2026-09-30$5,200.00$5,766.00

Acquisition mix

Channel totals in the selected period. Every bar starts at zero.

Visits: 23,778

View exact data
Acquisition mix
Acquisition channelVisitsConverted visitsConversion rate
Organic9,4703864.08%
Direct8,0114205.24%
Referral6,2974046.42%

Visit conversion

Converted visits divided by visits. The vertical scale stays at 0–100%.

5.09% · 1,210 / 23,778

View exact data
Visit conversion
DateVisitsConverted visitsConversion rate
2026-09-171,557734.69%
2026-09-181,547774.98%
2026-09-191,622865.3%
2026-09-201,612915.65%
2026-09-211,602724.49%
2026-09-221,677804.77%
2026-09-231,667834.98%
2026-09-241,742935.34%
2026-09-251,732975.6%
2026-09-261,722774.47%
2026-09-271,797864.79%
2026-09-281,787915.09%
2026-09-291,8621005.37%
2026-09-301,8521045.62%

Missing days are gaps, not zero. A zero-visit conversion rate is undefined (—).

Totals cover available records; this is not a completeness guarantee. The window ends at the latest supplied date.

Install

Set up the base theme and Pretendard once using the installation guide. Adding these templates does not overwrite your theme, global CSS or page routes.

Installation: template-analyticsnpx shadcn@latest add @neumorphism-ui/template-analytics

Usage

Usage
"use client";
import { AnalyticsDashboard, type AnalyticsRecord } from "@/components/blocks/analytics-dashboard";

export function Example({ records }: { records: AnalyticsRecord[] }) {
  return <AnalyticsDashboard records={records} locale="en" currency="USD" />;
}

Connect your application

This is a read-only view. Supply records from your authenticated service. Use one row per date/channel, nonnegative integer visits and convertedVisits, and currency in hundredths (revenueCents/targetCents). The window ends at the last supplied date. All example values are synthetic.

Installed source

@components/blocks/analytics-dashboard.tsx
@components/blocks/analytics-dashboard.tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import { Select, SelectItem } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { RevenueChart } from "@/components/blocks/revenue-chart";
import { ChannelChart } from "@/components/blocks/channel-chart";
import { ConversionChart } from "@/components/blocks/conversion-chart";
import { summarizeAnalytics, AnalyticsDataError, analyticsCsv, type AnalyticsRecord } from "@/lib/analytics-model";
import { analyticsCopy, analyticsFormats, type AnalyticsLocale } from "@/lib/analytics-copy";
import { demoAnalytics } from "./analytics-demo";

export interface AnalyticsDashboardProps {
  records: readonly AnalyticsRecord[];
  locale?: AnalyticsLocale;
  currency?: string;
}

/** Read-only reporting: callers own authenticated loading and business data.
 * Props are live inputs; replacing records recalculates this view immediately.
 */
export function AnalyticsDashboard({ records, locale = "en", currency = "USD" }: AnalyticsDashboardProps) {
  const [days, setDays] = React.useState(14);
  const [channel, setChannel] = React.useState<string | undefined>();
  const t = analyticsCopy[locale];
  const result = React.useMemo(() => {
    try { return { data: summarizeAnalytics(records, days, channel), error: false as const }; }
    catch (error) {
      if (!(error instanceof AnalyticsDataError)) throw error;
      return { data: null, error: true as const };
    }
  }, [records, days, channel]);
  const channels = React.useMemo(() => [...new Set(records.map(record => record.channel))].sort(), [records]);
  if (result.error) return <Alert variant="destructive" data-slot="analytics-error"><AlertDescription>{t.invalid}</AlertDescription></Alert>;
  const data = result.data;
  const f = analyticsFormats(locale, currency);
  const metrics = [
    { key: "revenue", label: t.revenue, value: data.observedDays ? f.money(data.totals.revenueCents) : "—" },
    { key: "visits", label: t.visits, value: data.observedDays ? f.number(data.totals.visits) : "—" },
    { key: "converted", label: t.conversions, value: data.observedDays ? f.number(data.totals.convertedVisits) : "—" },
    { key: "conversion", label: t.conversion, value: f.percent(data.conversion) },
  ];
  function exportCsv() {
    const url = URL.createObjectURL(new Blob([analyticsCsv(data)], { type: "text/csv;charset=utf-8" }));
    const link = document.createElement("a");
    link.href = url;
    link.download = `analytics-${data.start}-${data.end}.csv`;
    document.body.append(link);
    link.click();
    link.remove();
    // The download needs a task to consume the URL before it is released.
    window.setTimeout(() => URL.revokeObjectURL(url), 1000);
  }
  return <section data-slot="analytics-dashboard" className="grid min-w-0 gap-6 text-[var(--foreground)]">
    <header className="flex flex-wrap items-start justify-between gap-4"><div className="grid gap-2"><h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">{t.title}</h2><p className="text-sm text-[var(--muted-foreground)]">{t.intro}</p></div><Button onClick={exportCsv} disabled={!data.observedDays}>{t.export}</Button></header>
    <div className="flex flex-wrap items-end gap-4 rounded-[var(--neu-radius-surface)] bg-transparent py-4 shadow-none">
      <label className="grid min-w-36 flex-1 gap-2 text-xs font-medium">{t.period}<Select value={days} onChange={event => setDays(Number(event.target.value))}>{[7, 14, 30].map(value => <SelectItem key={value} value={value}>{value} {t.days}</SelectItem>)}</Select></label>
      <label className="grid min-w-44 flex-1 gap-2 text-xs font-medium">{t.channel}<Select value={channel === undefined ? "all" : `channel:${channel}`} onChange={event => setChannel(event.target.value === "all" ? undefined : event.target.value.slice(8))}><SelectItem value="all">{t.all}</SelectItem>{channel !== undefined && !channels.includes(channel) && <SelectItem value={`channel:${channel}`}>{channel}</SelectItem>}{channels.map(value => <SelectItem key={value} value={`channel:${value}`}>{value}</SelectItem>)}</Select></label>
      <Button variant="ghost" onClick={() => { setDays(14); setChannel(undefined); }} disabled={days === 14 && channel === undefined}>{t.reset}</Button>
    </div>
    <p role="status" data-slot="analytics-window" className="text-xs leading-relaxed text-[var(--muted-foreground)]">{data.start && `${data.start} – ${data.end} · `}{t.window}: {data.observedDays} / {days}</p>
    <dl className="grid min-w-0 grid-cols-2 gap-4 xl:grid-cols-4">{metrics.map(metric => <div key={metric.key} className="grid min-w-0 gap-3 rounded-[var(--neu-radius-surface)] border border-[var(--neu-edge)] bg-[var(--neu-surface)] p-4 text-center [box-shadow:var(--neu-shadow-inset)] first:[box-shadow:var(--neu-shadow-raised-sm)] first:text-[var(--neu-accent-ink)] sm:p-5"><dt className="text-xs text-[var(--muted-foreground)]">{metric.label}</dt><dd data-metric={metric.key} className="break-words text-xl font-semibold tracking-tight tabular-nums sm:text-2xl">{metric.value}</dd></div>)}</dl>
    <RevenueChart data={data} locale={locale} currency={currency} />
    <div className="grid min-w-0 gap-6 xl:grid-cols-2"><ChannelChart data={data} locale={locale} /><ConversionChart data={data} locale={locale} /></div>
    <footer className="grid gap-2 text-xs leading-relaxed text-[var(--muted-foreground)]"><p>{t.missing}</p><p>{t.coverage}</p></footer>
  </section>;
}

export function AnalyticsExample({ locale = "en" }: { locale?: AnalyticsLocale }) {
  const [empty, setEmpty] = React.useState(false);
  const t = analyticsCopy[locale];
  return <div className="grid min-w-0 gap-5"><div className="flex flex-wrap items-center justify-between gap-3"><p className="max-w-prose text-xs leading-relaxed text-[var(--muted-foreground)]">{t.sample}</p><label className="flex items-center gap-2 text-xs"><Checkbox checked={empty} onCheckedChange={value => setEmpty(value === true)} />{t.showEmpty}</label></div><AnalyticsDashboard records={empty ? [] : demoAnalytics} locale={locale} /></div>;
}

export { demoAnalytics } from "./analytics-demo";
export type { AnalyticsRecord } from "@/lib/analytics-model";
@components/blocks/analytics-demo.ts
@components/blocks/analytics-demo.ts
import type { AnalyticsRecord } from "@/lib/analytics-model";

/** Deterministic synthetic example; this is not business or live-service data. */
export const demoAnalytics: readonly AnalyticsRecord[] = Array.from({ length: 30 }, (_, index) => {
  const date = new Date(Date.UTC(2026, 8, index + 1)).toISOString().slice(0, 10);
  return ["Organic", "Direct", "Referral"].map((channel, group) => {
    const visits = 230 + (2 - group) * 115 + index * 8 + ((index * 17 + group * 29) % 85);
    const convertedVisits = Math.floor(visits * (0.035 + group * 0.012 + (index % 5) * 0.003));
    return { date, channel, visits, convertedVisits, revenueCents: convertedVisits * (4200 + group * 800), targetCents: 115000 + (2 - group) * 25000 + index * 1800 };
  });
}).flat();