Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js

SaaS Analytics Dashboard in Next.js with SVG Charts Demo

A subscription analytics dashboard over 18 months of seeded, fictional data. Pick a date range or set your own, filter by plan, region and acquisition channel, and every KPI, chart and table recalculates. Hover or arrow-key through the charts, sort the signup table, read retention by cohort, copy a link to the exact view or export it as CSV.

Read the case study →

Interactive demo

samiullah.tech/lab/saas-analytics-dashboard
Date range

2026-08-03 → 2026-10-31 · 90 days · all accounts · fictional data, seeded

  • MRR

    +22.7% (improved) vs prev.

  • Active accounts

    +18.1% (improved) vs prev.

  • New signups

    +9.7% (improved) vs prev.

  • Churn rate

    +0.2 pts (worse) vs prev.

  • ARPA

    +3.9% (improved) vs prev.

MRR · end of each week

MRR
PeriodMRR
2026-08-03 – 2026-08-09$170,150
2026-08-10 – 2026-08-16$172,464
2026-08-17 – 2026-08-23$175,380
2026-08-24 – 2026-08-30$179,407
2026-08-31 – 2026-09-06$182,524
2026-09-07 – 2026-09-13$185,802
2026-09-14 – 2026-09-20$183,754
2026-09-21 – 2026-09-27$183,975
2026-09-28 – 2026-10-04$187,190
2026-10-05 – 2026-10-11$189,460
2026-10-12 – 2026-10-18$196,437
2026-10-19 – 2026-10-25$201,076
2026-10-26 – 2026-10-31$202,917

MRR by plan · 2026-10-31

Total$203k

New signups by plan

  • Starter
  • Growth
  • Scale
New signups by plan
PeriodStarterGrowthScale
2026-08-03 – 2026-08-092697
2026-08-10 – 2026-08-162684
2026-08-17 – 2026-08-2316103
2026-08-24 – 2026-08-3016104
2026-08-31 – 2026-09-0614104
2026-09-07 – 2026-09-132563
2026-09-14 – 2026-09-2023120
2026-09-21 – 2026-09-2720131
2026-09-28 – 2026-10-042174
2026-10-05 – 2026-10-112385
2026-10-12 – 2026-10-181787
2026-10-19 – 2026-10-2522154
2026-10-26 – 2026-10-311974

Churn in range

Accounts lost
232
MRR lost
$23,072
New MRR
$60,613

Paid-ads signups churn about a third faster than organic in this data set. Compare them with the Channel filter.

Retention by signup month · share of each cohort still paying

CohortSizeM0M1M2M3M4M5M6M7
Mar 2617597%90%86%81%78%72%67%62%
Apr 2613695%92%84%77%73%68%61%
May 2611896%89%84%82%79%75%
Jun 2615297%90%84%78%74%
Jul 2614098%91%84%81%
Aug 2614997%95%88%
Sep 2614597%93%
Oct 2615197%

Signups in range (441)

1/56
AccountRegionStatus
Acme Logistics #3066ScaleAsia-Pacific$11962026-10-31active
Placeholder Dental #3065StarterEurope$292026-10-30active
Test Dental #3064StarterNorth America$292026-10-30active
Demo Dental #3063StarterNorth America$292026-10-30active
Sample Dental #3062GrowthAsia-Pacific$992026-10-30active
Example Dental #3061StarterNorth America$292026-10-30active
Acme Dental #3060StarterNorth America$292026-10-30active
Placeholder Supply #3059StarterNorth America$292026-10-29active

## How it works

data.ts builds the accounts with a seeded PRNG (mulberry32) that only uses integer maths and comparisons, so the server and every browser produce the same numbers and the page can render the charts on the server without a hydration mismatch.

metrics.ts turns accounts into daily deltas (MRR and active count go up on signup, down on churn) and prefix-sums them once per segment. Any range is then a couple of array lookups, which keeps filter changes instant. Ranges up to a month bucket by day, up to four months by week, and longer by calendar month.

The charts are hand-written SVG. Each has a hover and keyboard tooltip (focus a chart and use the arrow keys), an aria-label summary, and a hidden table with every value. The current view is stored in the URL query, so a copied link opens the same range and segment.

## View source

The code running in the window above, exactly as deployed.

app/lab/saas-analytics-dashboard/data.ts142 lines
/**
 * Deterministic fictional data for the analytics demo: ~2,300 subscription accounts over 18 months.
 * A seeded PRNG means the server render and the browser render produce identical numbers, so the charts can be
 * computed during render without hydration mismatches, and every screenshot is repeatable.
 */

export const PLANS = ["starter", "growth", "scale"] as const;
export const REGIONS = ["na", "eu", "apac"] as const;
export const CHANNELS = ["organic", "paid", "referral"] as const;
export type Plan = (typeof PLANS)[number];
export type Region = (typeof REGIONS)[number];
export type Channel = (typeof CHANNELS)[number];

export const PLAN_LABEL: Record<Plan, string> = { starter: "Starter", growth: "Growth", scale: "Scale" };
export const REGION_LABEL: Record<Region, string> = { na: "North America", eu: "Europe", apac: "Asia-Pacific" };
export const CHANNEL_LABEL: Record<Channel, string> = { organic: "Organic", paid: "Paid ads", referral: "Referral" };
export const PRICE: Record<Plan, number> = { starter: 29, growth: 99, scale: 299 };

/** Day 0 is 2025-05-01 (UTC). The data ends on 2026-10-31. */
export const START_MS = Date.UTC(2025, 4, 1);
export const DAYS = Math.round((Date.UTC(2026, 9, 31) - START_MS) / 86_400_000) + 1; // 549

export type Account = {
  id: number;
  name: string;
  plan: Plan;
  region: Region;
  channel: Channel;
  seats: number;
  /** Monthly recurring revenue in USD. */
  mrr: number;
  signupDay: number;
  /** Day the subscription ended, or null if still active at the end of the data. */
  churnDay: number | null;
};

/** mulberry32: tiny, fast, good enough for fake data. */
export function rng(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function pick<T extends string>(r: number, items: readonly T[], weights: number[]): T {
  let acc = 0;
  for (let i = 0; i < items.length; i++) {
    acc += weights[i]!;
    if (r < acc) return items[i]!;
  }
  return items[items.length - 1]!;
}

/**
 * Binomial(20, mean/20) as a stand-in for Poisson. It only uses rand() and comparisons (no Math.exp/log),
 * so every JS engine produces exactly the same numbers and server and client renders match.
 */
function countEvents(mean: number, rand: () => number) {
  const p = mean / 20;
  let k = 0;
  for (let i = 0; i < 20; i++) if (rand() < p) k++;
  return k;
}

const PREFIX = ["Acme", "Example", "Sample", "Demo", "Test", "Placeholder"];
const SUFFIX = ["Dental", "Logistics", "Studio", "Clinic", "Bakery", "Labs", "Legal", "Fitness", "Travel", "Supply"];

/** Monthly churn hazard per plan; paid-ads signups churn faster. */
const HAZARD: Record<Plan, number> = { starter: 0.065, growth: 0.035, scale: 0.018 };

export function generate(seed = 20260101): Account[] {
  const rand = rng(seed);
  const out: Account[] = [];
  for (let d = 0; d < DAYS; d++) {
    const weekday = new Date(START_MS + d * 86_400_000).getUTCDay();
    const weekend = weekday === 0 || weekday === 6 ? 0.55 : 1;
    const trend = 2.4 + 3.6 * (d / DAYS);
    const launchBump = d >= 300 && d < 314 ? 2.2 : 1; // a fictional product launch in late Feb 2026
    const n = countEvents(trend * weekend * launchBump, rand);
    for (let i = 0; i < n; i++) {
      const id = 1000 + out.length;
      const plan = pick(rand(), PLANS, [0.58, 0.31, 0.11]);
      const region = pick(rand(), REGIONS, [0.45, 0.36, 0.19]);
      const channel = pick(rand(), CHANNELS, [0.46, 0.34, 0.2]);
      const seats = plan === "scale" ? 1 + Math.floor(rand() * 4) : 1;
      const hazard = HAZARD[plan] * (channel === "paid" ? 1.35 : channel === "referral" ? 0.8 : 1);
      // Geometric lifetime: each month the account survives with probability (1 - hazard).
      let months = 0;
      while (months < 60 && rand() >= hazard) months++;
      const lifetime = months * 30 + 1 + Math.floor(rand() * 30);
      const churnDay = d + lifetime < DAYS ? d + lifetime : null;
      out.push({
        id,
        name: `${PREFIX[id % PREFIX.length]} ${SUFFIX[Math.floor(id / PREFIX.length) % SUFFIX.length]} #${id}`,
        plan,
        region,
        channel,
        seats,
        mrr: PRICE[plan] * seats,
        signupDay: d,
        churnDay,
      });
    }
  }
  return out;
}

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

/** Formatting without Intl so server and browser output are byte-identical. */
export function dayDate(day: number) {
  return new Date(START_MS + day * 86_400_000);
}
export function isoDay(day: number) {
  return dayDate(day).toISOString().slice(0, 10);
}
export function dayFromIso(iso: string): number | null {
  const ms = Date.parse(iso + "T00:00:00Z");
  if (Number.isNaN(ms)) return null;
  return Math.round((ms - START_MS) / 86_400_000);
}
export function shortDate(day: number) {
  const d = dayDate(day);
  return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
}
export function monthLabel(day: number, withYear = true) {
  const d = dayDate(day);
  return `${MONTHS[d.getUTCMonth()]}${withYear ? ` ${String(d.getUTCFullYear()).slice(2)}` : ""}`;
}
/** Month index since May 2025 for a day. */
export function monthOf(day: number) {
  const d = dayDate(day);
  return (d.getUTCFullYear() - 2025) * 12 + d.getUTCMonth() - 4;
}
export function monthStartDay(m: number) {
  return Math.round((Date.UTC(2025, 4 + m, 1) - START_MS) / 86_400_000);
}
app/lab/saas-analytics-dashboard/metrics.ts199 lines
/**
 * Aggregations for the dashboard. Each function is the in-memory version of a SQL query the production API
 * would run (see the comments). Work is O(accounts + days): daily deltas, then prefix sums.
 */
import { CHANNELS, DAYS, PLANS, REGIONS, isoDay, monthLabel, monthOf, monthStartDay, shortDate, type Account, type Channel, type Plan, type Region } from "./data";

export type Segment = { plan: Plan | "all"; region: Region | "all"; channel: Channel | "all" };
export type Range = { from: number; to: number };

export const PRESETS = [
  { id: "7d", label: "7D", days: 7 },
  { id: "30d", label: "30D", days: 30 },
  { id: "90d", label: "90D", days: 90 },
  { id: "12m", label: "12M", days: 365 },
] as const;
export type PresetId = (typeof PRESETS)[number]["id"];

export function presetRange(id: PresetId): Range {
  const p = PRESETS.find((x) => x.id === id)!;
  return { from: DAYS - p.days, to: DAYS - 1 };
}

export const clampRange = (r: Range): Range => {
  const from = Math.max(0, Math.min(r.from, DAYS - 1));
  const to = Math.max(from, Math.min(r.to, DAYS - 1));
  return { from, to };
};

export const inSegment = (a: Account, s: Segment) =>
  (s.plan === "all" || a.plan === s.plan) && (s.region === "all" || a.region === s.region) && (s.channel === "all" || a.channel === s.channel);

/** Daily series for one segment. SQL: generate_series(days) LEFT JOIN subscriptions, window SUM over deltas. */
export type Daily = {
  mrr: number[]; // MRR at the end of each day
  active: number[]; // active accounts at the end of each day
  signups: Record<Plan, number[]>;
  churned: number[];
  churnedMrr: number[];
};

export function daily(accounts: Account[], seg: Segment): Daily {
  const mrrDelta = new Array<number>(DAYS + 1).fill(0);
  const activeDelta = new Array<number>(DAYS + 1).fill(0);
  const signups = Object.fromEntries(PLANS.map((p) => [p, new Array<number>(DAYS).fill(0)])) as Record<Plan, number[]>;
  const churned = new Array<number>(DAYS).fill(0);
  const churnedMrr = new Array<number>(DAYS).fill(0);
  for (const a of accounts) {
    if (!inSegment(a, seg)) continue;
    mrrDelta[a.signupDay]! += a.mrr;
    activeDelta[a.signupDay]! += 1;
    signups[a.plan][a.signupDay]! += 1;
    if (a.churnDay !== null) {
      mrrDelta[a.churnDay]! -= a.mrr;
      activeDelta[a.churnDay]! -= 1;
      churned[a.churnDay]! += 1;
      churnedMrr[a.churnDay]! += a.mrr;
    }
  }
  const mrr: number[] = [];
  const active: number[] = [];
  let m = 0;
  let n = 0;
  for (let d = 0; d < DAYS; d++) {
    m += mrrDelta[d]!;
    n += activeDelta[d]!;
    mrr.push(m);
    active.push(n);
  }
  return { mrr, active, signups, churned, churnedMrr };
}

const sum = (xs: number[], from: number, to: number) => {
  let t = 0;
  for (let i = Math.max(0, from); i <= to; i++) t += xs[i] ?? 0;
  return t;
};

export type Kpi = { id: string; label: string; value: number; previous: number | null; format: "usd" | "int" | "pct"; goodWhenUp: boolean; hint: string };

/** Headline numbers for a range, each compared with the period of the same length right before it. */
export function kpis(d: Daily, r: Range): Kpi[] {
  const len = r.to - r.from + 1;
  const prevTo = r.from - 1;
  const prevFrom = r.from - len;
  const hasPrev = prevFrom >= 0;
  const at = (xs: number[], i: number) => (i >= 0 ? xs[i] ?? 0 : 0);
  const signups = (from: number, to: number) => PLANS.reduce((t, p) => t + sum(d.signups[p], from, to), 0);
  // Churn rate = accounts lost in the period / accounts active at its start.
  const churnRate = (from: number, to: number) => {
    const base = at(d.active, from - 1);
    return base > 0 ? sum(d.churned, from, to) / base : 0;
  };
  const arpa = (i: number) => (at(d.active, i) ? at(d.mrr, i) / at(d.active, i) : 0);
  return [
    { id: "mrr", label: "MRR", value: at(d.mrr, r.to), previous: hasPrev ? at(d.mrr, prevTo) : null, format: "usd", goodWhenUp: true, hint: "Monthly recurring revenue at the end of the range" },
    { id: "active", label: "Active accounts", value: at(d.active, r.to), previous: hasPrev ? at(d.active, prevTo) : null, format: "int", goodWhenUp: true, hint: "Paying accounts at the end of the range" },
    { id: "signups", label: "New signups", value: signups(r.from, r.to), previous: hasPrev ? signups(prevFrom, prevTo) : null, format: "int", goodWhenUp: true, hint: "Accounts created inside the range" },
    { id: "churn", label: "Churn rate", value: churnRate(r.from, r.to), previous: hasPrev ? churnRate(prevFrom, prevTo) : null, format: "pct", goodWhenUp: false, hint: "Accounts lost ÷ accounts active at the start" },
    { id: "arpa", label: "ARPA", value: arpa(r.to), previous: hasPrev ? arpa(prevTo) : null, format: "usd", goodWhenUp: true, hint: "Average revenue per account" },
  ];
}

export type Bucket = { start: number; end: number; label: string; longLabel: string };

/** Day buckets up to a month, weeks up to ~4 months, calendar months beyond. SQL: date_trunc(<unit>, day). */
export function buckets(r: Range): Bucket[] {
  const len = r.to - r.from + 1;
  const out: Bucket[] = [];
  if (len <= 31) {
    for (let d = r.from; d <= r.to; d++) out.push({ start: d, end: d, label: shortDate(d), longLabel: isoDay(d) });
  } else if (len <= 120) {
    for (let d = r.from; d <= r.to; d += 7) {
      const end = Math.min(d + 6, r.to);
      out.push({ start: d, end, label: shortDate(d), longLabel: `${isoDay(d)} – ${isoDay(end)}` });
    }
  } else {
    for (let m = monthOf(r.from); m <= monthOf(r.to); m++) {
      const start = Math.max(r.from, monthStartDay(m));
      const end = Math.min(r.to, monthStartDay(m + 1) - 1);
      out.push({ start, end, label: monthLabel(start), longLabel: `${isoDay(start)} – ${isoDay(end)}` });
    }
  }
  return out;
}

export type Row = { bucket: Bucket; mrr: number; signups: Record<Plan, number>; churned: number; churnedMrr: number };

export function series(d: Daily, r: Range): Row[] {
  return buckets(r).map((b) => ({
    bucket: b,
    mrr: d.mrr[b.end] ?? 0,
    signups: Object.fromEntries(PLANS.map((p) => [p, sum(d.signups[p], b.start, b.end)])) as Record<Plan, number>,
    churned: sum(d.churned, b.start, b.end),
    churnedMrr: sum(d.churnedMrr, b.start, b.end),
  }));
}

/** MRR at a day split by a dimension. SQL: SELECT plan, SUM(mrr) … WHERE active GROUP BY plan. */
export function breakdown(accounts: Account[], seg: Segment, day: number, by: "plan" | "region" | "channel") {
  const keys = by === "plan" ? PLANS : by === "region" ? REGIONS : CHANNELS;
  const totals = new Map<string, number>(keys.map((k) => [k, 0]));
  for (const a of accounts) {
    if (!inSegment(a, seg) || a.signupDay > day || (a.churnDay !== null && a.churnDay <= day)) continue;
    totals.set(a[by], (totals.get(a[by]) ?? 0) + a.mrr);
  }
  return keys.map((k) => ({ key: k as string, value: totals.get(k) ?? 0 }));
}

/**
 * Monthly signup cohorts and the share still active N months later.
 * SQL: GROUP BY date_trunc('month', signup_at), months_between(...), COUNT(*) FILTER (WHERE active).
 */
export function cohorts(accounts: Account[], seg: Segment, endDay: number, count = 8) {
  const lastMonth = monthOf(endDay);
  const first = Math.max(0, lastMonth - count + 1);
  const rows: { label: string; size: number; retained: (number | null)[] }[] = [];
  for (let m = first; m <= lastMonth; m++) {
    const start = monthStartDay(m);
    const end = monthStartDay(m + 1) - 1;
    const members = accounts.filter((a) => inSegment(a, seg) && a.signupDay >= start && a.signupDay <= end);
    const retained: (number | null)[] = [];
    for (let k = 0; k < count; k++) {
      const checkDay = monthStartDay(m + k + 1) - 1; // end of month m+k
      if (m + k > lastMonth || checkDay > endDay + 31) {
        retained.push(null);
        continue;
      }
      const day = Math.min(checkDay, endDay);
      const alive = members.filter((a) => a.churnDay === null || a.churnDay > day).length;
      retained.push(members.length ? alive / members.length : null);
    }
    rows.push({ label: monthLabel(start), size: members.length, retained });
  }
  return rows;
}

export function toCsv(rows: Row[]): string {
  const header = ["period_start", "period_end", "mrr_usd", ...PLANS.map((p) => `new_${p}`), "churned_accounts", "churned_mrr_usd"];
  const lines = rows.map((r) => [isoDay(r.bucket.start), isoDay(r.bucket.end), r.mrr, ...PLANS.map((p) => r.signups[p]), r.churned, r.churnedMrr].join(","));
  return [header.join(","), ...lines].join("\n");
}

export function formatValue(v: number, f: Kpi["format"], compact = false): string {
  if (f === "pct") return `${(v * 100).toFixed(1)}%`;
  if (f === "usd") {
    if (compact && Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(v >= 100_000 ? 0 : 1)}k`;
    return `$${Math.round(v).toLocaleString("en-US")}`;
  }
  return Math.round(v).toLocaleString("en-US");
}

/** Round an axis maximum up to 1, 2 or 5 × 10^n so tick labels are readable. */
export function niceMax(v: number): number {
  if (v <= 0) return 1;
  const exp = Math.pow(10, Math.floor(Math.log10(v)));
  const f = v / exp;
  return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 5 ? 5 : 10) * exp;
}
app/lab/saas-analytics-dashboard/charts.tsx301 lines
"use client";

import { useState } from "react";
import { niceMax } from "./metrics";

/**
 * Small SVG charts with no chart library. Each one has a hover/keyboard tooltip, an aria-label summary and a
 * hidden data table, so the numbers are reachable without seeing the chart.
 */

const W = 640;
const H = 220;
const PAD = { l: 52, r: 12, t: 12, b: 26 };
const IW = W - PAD.l - PAD.r;
const IH = H - PAD.t - PAD.b;

export const SERIES_COLORS = ["var(--accent-2)", "var(--accent)", "var(--accent-3)"] as const;

type Point = { label: string; longLabel: string };

function useActive(n: number) {
  const [active, setActive] = useState<number | null>(null);
  const onKey = (e: React.KeyboardEvent) => {
    if (!n) return;
    if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
      e.preventDefault();
      setActive((a) => Math.max(0, Math.min(n - 1, (a ?? (e.key === "ArrowRight" ? -1 : n)) + (e.key === "ArrowRight" ? 1 : -1))));
    } else if (e.key === "Home") setActive(0);
    else if (e.key === "End") setActive(n - 1);
    else if (e.key === "Escape") setActive(null);
  };
  const onMove = (e: React.PointerEvent<SVGSVGElement>) => {
    const r = e.currentTarget.getBoundingClientRect();
    const x = ((e.clientX - r.left) / r.width) * W - PAD.l;
    const i = Math.round((x / IW) * Math.max(1, n - 1));
    setActive(Math.max(0, Math.min(n - 1, i)));
  };
  return { active, setActive, onKey, onMove };
}

function Grid({ max, format }: { max: number; format: (v: number) => string }) {
  const ticks = [0, 0.25, 0.5, 0.75, 1];
  return (
    <g aria-hidden="true">
      {ticks.map((t) => {
        const y = PAD.t + IH - t * IH;
        return (
          <g key={t}>
            <line x1={PAD.l} x2={W - PAD.r} y1={y} y2={y} stroke="var(--border)" strokeWidth={1} strokeDasharray={t === 0 ? undefined : "2 4"} />
            <text x={PAD.l - 8} y={y + 4} textAnchor="end" fontSize={11} fill="var(--fg-muted)" className="font-mono">
              {format(max * t)}
            </text>
          </g>
        );
      })}
    </g>
  );
}

function XLabels({ points, x }: { points: Point[]; x: (i: number) => number }) {
  const step = Math.max(1, Math.ceil(points.length / 7));
  return (
    <g aria-hidden="true">
      {points.map((p, i) => {
        const last = points.length - 1;
        const show = i === last || (i % step === 0 && last - i >= step / 2);
        return show ? (
          <text key={i} x={x(i)} y={H - 6} textAnchor={i === 0 ? "start" : i === last ? "end" : "middle"} fontSize={11} fill="var(--fg-muted)">
            {p.label}
          </text>
        ) : null;
      })}
    </g>
  );
}

function Tooltip({ leftPct, children }: { leftPct: number; children: React.ReactNode }) {
  const flip = leftPct > 65;
  return (
    <div
      className="pointer-events-none absolute top-1 z-10 min-w-36 rounded-md border border-border bg-bg-elev px-2.5 py-1.5 text-xs shadow-lg"
      style={flip ? { right: `${100 - leftPct + 2}%` } : { left: `${leftPct + 2}%` }}
    >
      {children}
    </div>
  );
}

export function LineChart({ points, values, format, title }: { points: Point[]; values: number[]; format: (v: number, compact?: boolean) => string; title: string }) {
  const n = values.length;
  const max = niceMax(Math.max(...values, 1));
  const x = (i: number) => PAD.l + (n <= 1 ? IW / 2 : (i / (n - 1)) * IW);
  const y = (v: number) => PAD.t + IH - (v / max) * IH;
  const { active, setActive, onKey, onMove } = useActive(n);
  const d = values.map((v, i) => `${i ? "L" : "M"}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
  const first = values[0] ?? 0;
  const last = values.at(-1) ?? 0;
  return (
    <figure className="relative">
      <svg
        viewBox={`0 0 ${W} ${H}`}
        className="h-auto w-full touch-pan-y outline-none focus-visible:ring-2 focus-visible:ring-accent"
        role="img"
        tabIndex={0}
        aria-label={`${title}: from ${format(first)} to ${format(last)} across ${n} periods. Use arrow keys to read each value.`}
        onPointerMove={onMove}
        onPointerLeave={() => setActive(null)}
        onKeyDown={onKey}
        onBlur={() => setActive(null)}
      >
        <Grid max={max} format={(v) => format(v, true)} />
        <path d={`${d} L${x(n - 1)},${PAD.t + IH} L${x(0)},${PAD.t + IH} Z`} fill="var(--accent)" fillOpacity={0.1} />
        <path d={d} fill="none" stroke="var(--accent)" strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
        <XLabels points={points} x={x} />
        {active !== null && (
          <g aria-hidden="true">
            <line x1={x(active)} x2={x(active)} y1={PAD.t} y2={PAD.t + IH} stroke="var(--fg-muted)" strokeDasharray="3 3" />
            <circle cx={x(active)} cy={y(values[active] ?? 0)} r={5} fill="var(--accent)" stroke="var(--bg)" strokeWidth={2} />
          </g>
        )}
      </svg>
      {active !== null && points[active] && (
        <Tooltip leftPct={(x(active) / W) * 100}>
          <p className="font-mono text-fg-muted">{points[active].longLabel}</p>
          <p className="mt-0.5 font-semibold">{format(values[active] ?? 0)}</p>
        </Tooltip>
      )}
      <DataTable caption={title} headers={["Period", title]} rows={points.map((p, i) => [p.longLabel, format(values[i] ?? 0)])} />
    </figure>
  );
}

export function StackedBars({
  points,
  series,
  title,
}: {
  points: Point[];
  series: { key: string; label: string; color: string; values: number[] }[];
  title: string;
}) {
  const n = points.length;
  const totals = points.map((_, i) => series.reduce((t, s) => t + (s.values[i] ?? 0), 0));
  const max = niceMax(Math.max(...totals, 1));
  const band = IW / Math.max(1, n);
  const bw = Math.max(2, Math.min(28, band - 3));
  const x = (i: number) => PAD.l + band * i + band / 2;
  const h = (v: number) => (v / max) * IH;
  const { active, setActive, onKey, onMove } = useActive(n);
  const moveBand = (e: React.PointerEvent<SVGSVGElement>) => {
    const r = e.currentTarget.getBoundingClientRect();
    const i = Math.floor((((e.clientX - r.left) / r.width) * W - PAD.l) / band);
    if (i >= 0 && i < n) setActive(i);
    else onMove(e);
  };
  return (
    <figure className="relative">
      <svg
        viewBox={`0 0 ${W} ${H}`}
        className="h-auto w-full touch-pan-y outline-none focus-visible:ring-2 focus-visible:ring-accent"
        role="img"
        tabIndex={0}
        aria-label={`${title}: ${totals.reduce((a, b) => a + b, 0)} in total across ${n} periods. Use arrow keys to read each period.`}
        onPointerMove={moveBand}
        onPointerLeave={() => setActive(null)}
        onKeyDown={onKey}
        onBlur={() => setActive(null)}
      >
        <Grid max={max} format={(v) => String(Math.round(v))} />
        {active !== null && <rect x={x(active) - band / 2} y={PAD.t} width={band} height={IH} fill="var(--fg-muted)" fillOpacity={0.08} aria-hidden="true" />}
        {points.map((_, i) => {
          let acc = 0;
          return (
            <g key={i}>
              {series.map((s) => {
                const v = s.values[i] ?? 0;
                if (!v) return null;
                const top = PAD.t + IH - h(acc + v);
                const height = h(v);
                acc += v;
                // 1px surface gap between stacked segments keeps them distinguishable without colour.
                return <rect key={s.key} x={x(i) - bw / 2} y={top} width={bw} height={Math.max(0, height - 1)} rx={Math.min(2, bw / 4)} fill={s.color} />;
              })}
            </g>
          );
        })}
        <XLabels points={points} x={x} />
      </svg>
      {active !== null && points[active] && (
        <Tooltip leftPct={(x(active) / W) * 100}>
          <p className="font-mono text-fg-muted">{points[active].longLabel}</p>
          <ul className="mt-1 space-y-0.5">
            {series.map((s) => (
              <li key={s.key} className="flex items-center gap-2">
                <span className="size-2 rounded-sm" style={{ background: s.color }} aria-hidden="true" />
                {s.label}
                <span className="ml-auto pl-3 font-mono">{s.values[active] ?? 0}</span>
              </li>
            ))}
            <li className="flex border-t border-border pt-0.5 font-semibold">
              Total<span className="ml-auto font-mono">{totals[active]}</span>
            </li>
          </ul>
        </Tooltip>
      )}
      <DataTable caption={title} headers={["Period", ...series.map((s) => s.label)]} rows={points.map((p, i) => [p.longLabel, ...series.map((s) => String(s.values[i] ?? 0))])} />
    </figure>
  );
}

export function Donut({ items, format, title }: { items: { key: string; label: string; value: number; color: string }[]; format: (v: number) => string; title: string }) {
  const total = items.reduce((t, i) => t + i.value, 0);
  const [hover, setHover] = useState<string | null>(null);
  const R = 70;
  const C = 2 * Math.PI * R;
  let offset = 0;
  const current = items.find((i) => i.key === hover);
  return (
    <figure className="flex flex-wrap items-center gap-6">
      <svg viewBox="0 0 180 180" className="size-40 shrink-0" role="img" aria-label={`${title}: ${items.map((i) => `${i.label} ${format(i.value)}`).join(", ")}`}>
        <circle cx={90} cy={90} r={R} fill="none" stroke="var(--bg-muted)" strokeWidth={22} />
        {total > 0 &&
          items.map((i) => {
            const len = (i.value / total) * C;
            const gap = items.filter((x) => x.value > 0).length > 1 ? 2 : 0;
            const el = (
              <circle
                key={i.key}
                cx={90}
                cy={90}
                r={R}
                fill="none"
                stroke={i.color}
                strokeWidth={hover === i.key ? 26 : 22}
                strokeDasharray={`${Math.max(0, len - gap)} ${C}`}
                strokeDashoffset={-offset}
                transform="rotate(-90 90 90)"
                onPointerEnter={() => setHover(i.key)}
                onPointerLeave={() => setHover(null)}
                style={{ transition: "stroke-width 120ms" }}
              />
            );
            offset += len;
            return el;
          })}
        <text x={90} y={86} textAnchor="middle" fontSize={11} fill="var(--fg-muted)">
          {current ? current.label : "Total"}
        </text>
        <text x={90} y={106} textAnchor="middle" fontSize={17} fontWeight={600} fill="var(--fg)">
          {format(current ? current.value : total)}
        </text>
      </svg>
      <ul className="min-w-40 flex-1 space-y-2 text-sm">
        {items.map((i) => (
          <li key={i.key}>
            <button
              type="button"
              onMouseEnter={() => setHover(i.key)}
              onMouseLeave={() => setHover(null)}
              onFocus={() => setHover(i.key)}
              onBlur={() => setHover(null)}
              className={`flex w-full items-center gap-2 rounded px-1 text-left ${hover === i.key ? "bg-bg-muted" : ""}`}
            >
              <span className="size-2.5 rounded-sm" style={{ background: i.color }} aria-hidden="true" />
              <span>{i.label}</span>
              <span className="ml-auto font-mono text-xs text-fg-muted">{total ? Math.round((i.value / total) * 100) : 0}%</span>
              <span className="w-16 text-right font-mono text-xs">{format(i.value)}</span>
            </button>
          </li>
        ))}
      </ul>
    </figure>
  );
}

function DataTable({ caption, headers, rows }: { caption: string; headers: string[]; rows: string[][] }) {
  return (
    <table className="sr-only">
      <caption>{caption}</caption>
      <thead>
        <tr>
          {headers.map((h) => (
            <th key={h} scope="col">
              {h}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((r, i) => (
          <tr key={i}>
            {r.map((c, j) => (
              <td key={j}>{c}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}
app/lab/saas-analytics-dashboard/Demo.tsx449 lines
"use client";

import { useEffect, useMemo, useState } from "react";
import { Donut, LineChart, SERIES_COLORS, StackedBars } from "./charts";
import { CHANNELS, CHANNEL_LABEL, DAYS, PLANS, PLAN_LABEL, REGIONS, REGION_LABEL, dayFromIso, generate, isoDay, type Channel, type Plan, type Region } from "./data";
import { PRESETS, breakdown, clampRange, cohorts, daily, formatValue, inSegment, kpis, presetRange, series, toCsv, type PresetId, type Range, type Segment } from "./metrics";

const KEY = "lab:saas-analytics-dashboard:v1";
const ACCOUNTS = generate();
const DEFAULT_SEG: Segment = { plan: "all", region: "all", channel: "all" };

type View = { preset: PresetId | "custom"; range: Range; seg: Segment };
const DEFAULT_VIEW: View = { preset: "90d", range: presetRange("90d"), seg: DEFAULT_SEG };

/** URL query <-> view. Shareable links: ?range=90d&plan=growth or ?from=2026-01-01&to=2026-03-31. */
function readQuery(q: URLSearchParams): View | null {
  if (![...q.keys()].some((k) => ["range", "from", "to", "plan", "region", "channel"].includes(k))) return null;
  const pick = <T extends string>(v: string | null, allowed: readonly T[]): T | "all" => (v && (allowed as readonly string[]).includes(v) ? (v as T) : "all");
  const seg: Segment = { plan: pick(q.get("plan"), PLANS), region: pick(q.get("region"), REGIONS), channel: pick(q.get("channel"), CHANNELS) };
  const from = dayFromIso(q.get("from") ?? "");
  const to = dayFromIso(q.get("to") ?? "");
  if (from !== null && to !== null) return { preset: "custom", range: clampRange({ from, to }), seg };
  const preset = PRESETS.find((p) => p.id === q.get("range"))?.id ?? "90d";
  return { preset, range: presetRange(preset), seg };
}
function writeQuery(v: View): string {
  const q = new URLSearchParams();
  if (v.preset === "custom") {
    q.set("from", isoDay(v.range.from));
    q.set("to", isoDay(v.range.to));
  } else q.set("range", v.preset);
  (["plan", "region", "channel"] as const).forEach((k) => v.seg[k] !== "all" && q.set(k, v.seg[k]));
  return q.toString();
}

type SortKey = "signup" | "mrr" | "plan";

export default function Demo() {
  const [view, setView] = useState<View>(DEFAULT_VIEW);
  const [ready, setReady] = useState(false);
  const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: "signup", dir: -1 });
  const [page, setPage] = useState(0);
  const [toast, setToast] = useState("");

  // Restore: URL first (shared link), then the last view from localStorage.
  useEffect(() => {
    let v = readQuery(new URLSearchParams(window.location.search));
    if (!v) {
      try {
        const raw = localStorage.getItem(KEY);
        if (raw) v = readQuery(new URLSearchParams(raw));
      } catch {
        /* ignore */
      }
    }
    if (v) setView(v);
    setReady(true);
  }, []);

  useEffect(() => {
    if (!ready) return;
    const qs = writeQuery(view);
    try {
      localStorage.setItem(KEY, qs);
    } catch {
      /* ignore */
    }
    window.history.replaceState(null, "", `${window.location.pathname}?${qs}`);
  }, [view, ready]);

  const { range, seg } = view;
  const d = useMemo(() => daily(ACCOUNTS, seg), [seg]);
  const tiles = useMemo(() => kpis(d, range), [d, range]);
  const rows = useMemo(() => series(d, range), [d, range]);
  const by = seg.plan === "all" ? "plan" : seg.region === "all" ? "region" : "channel";
  const split = useMemo(() => breakdown(ACCOUNTS, seg, range.to, by), [seg, range.to, by]);
  const cohortRows = useMemo(() => cohorts(ACCOUNTS, seg, range.to), [seg, range.to]);
  const recent = useMemo(() => {
    const list = ACCOUNTS.filter((a) => inSegment(a, seg) && a.signupDay >= range.from && a.signupDay <= range.to);
    const cmp = {
      signup: (a: (typeof list)[number], b: (typeof list)[number]) => a.signupDay - b.signupDay || a.id - b.id,
      mrr: (a: (typeof list)[number], b: (typeof list)[number]) => a.mrr - b.mrr || a.id - b.id,
      plan: (a: (typeof list)[number], b: (typeof list)[number]) => PLANS.indexOf(a.plan) - PLANS.indexOf(b.plan) || a.id - b.id,
    }[sort.key];
    return list.sort((a, b) => cmp(a, b) * sort.dir);
  }, [seg, range, sort]);

  const PER_PAGE = 8;
  const pages = Math.max(1, Math.ceil(recent.length / PER_PAGE));
  const pageSafe = Math.min(page, pages - 1);
  const totalSignups = rows.reduce((t, r) => t + PLANS.reduce((s, p) => s + r.signups[p], 0), 0);
  const points = rows.map((r) => r.bucket);

  const update = (patch: Partial<View>) => {
    setView((v) => ({ ...v, ...patch }));
    setPage(0);
  };
  const setSeg = (k: keyof Segment, value: string) => update({ seg: { ...seg, [k]: value } as Segment });

  function exportCsv() {
    const blob = new Blob([toCsv(rows)], { type: "text/csv" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `metrics_${isoDay(range.from)}_${isoDay(range.to)}.csv`;
    a.click();
    URL.revokeObjectURL(a.href);
    flash(`Exported ${rows.length} rows as CSV.`);
  }
  async function copyLink() {
    try {
      await navigator.clipboard.writeText(window.location.href);
      flash("Link to this view copied.");
    } catch {
      flash("Clipboard blocked. Copy the address bar instead.");
    }
  }
  function flash(msg: string) {
    setToast(msg);
    setTimeout(() => setToast(""), 2500);
  }

  const control = "rounded-md border border-border bg-bg-elev px-2 py-1.5 text-sm";
  const segColors = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length]!;
  const splitLabel = (k: string) => (by === "plan" ? PLAN_LABEL[k as Plan] : by === "region" ? REGION_LABEL[k as Region] : CHANNEL_LABEL[k as Channel]);
  const sortBtn = (key: SortKey, label: string) => (
    <button
      type="button"
      onClick={() => setSort((s) => ({ key, dir: s.key === key ? (s.dir === 1 ? -1 : 1) : -1 }))}
      className="inline-flex items-center gap-1 hover:text-fg"
    >
      {label}
      <span aria-hidden="true">{sort.key === key ? (sort.dir === 1 ? "↑" : "↓") : "↕"}</span>
    </button>
  );

  return (
    <div className="space-y-4 p-3 sm:p-5">
      {/* ---------- filters ---------- */}
      <div className="flex flex-wrap items-end gap-3 rounded-lg border border-border bg-bg-elev p-3">
        <div>
          <span className="block text-xs font-medium text-fg-muted" id="range-label">
            Date range
          </span>
          <div role="group" aria-labelledby="range-label" className="mt-1 inline-flex overflow-hidden rounded-md border border-border">
            {PRESETS.map((p) => (
              <button
                key={p.id}
                type="button"
                aria-pressed={view.preset === p.id}
                onClick={() => update({ preset: p.id, range: presetRange(p.id) })}
                className={`px-2.5 py-1.5 font-mono text-xs ${view.preset === p.id ? "bg-accent text-accent-fg" : "hover:bg-bg-muted"}`}
              >
                {p.label}
              </button>
            ))}
          </div>
        </div>
        <label>
          <span className="block text-xs font-medium text-fg-muted">From</span>
          <input
            type="date"
            min={isoDay(0)}
            max={isoDay(DAYS - 1)}
            value={isoDay(range.from)}
            onChange={(e) => {
              const from = dayFromIso(e.target.value);
              if (from !== null) update({ preset: "custom", range: clampRange({ from, to: Math.max(from, range.to) }) });
            }}
            className={control + " mt-1"}
          />
        </label>
        <label>
          <span className="block text-xs font-medium text-fg-muted">To</span>
          <input
            type="date"
            min={isoDay(0)}
            max={isoDay(DAYS - 1)}
            value={isoDay(range.to)}
            onChange={(e) => {
              const to = dayFromIso(e.target.value);
              if (to !== null) update({ preset: "custom", range: clampRange({ from: Math.min(range.from, to), to }) });
            }}
            className={control + " mt-1"}
          />
        </label>
        <label>
          <span className="block text-xs font-medium text-fg-muted">Plan</span>
          <select value={seg.plan} onChange={(e) => setSeg("plan", e.target.value)} className={control + " mt-1"}>
            <option value="all">All plans</option>
            {PLANS.map((p) => (
              <option key={p} value={p}>
                {PLAN_LABEL[p]}
              </option>
            ))}
          </select>
        </label>
        <label>
          <span className="block text-xs font-medium text-fg-muted">Region</span>
          <select value={seg.region} onChange={(e) => setSeg("region", e.target.value)} className={control + " mt-1"}>
            <option value="all">All regions</option>
            {REGIONS.map((r) => (
              <option key={r} value={r}>
                {REGION_LABEL[r]}
              </option>
            ))}
          </select>
        </label>
        <label>
          <span className="block text-xs font-medium text-fg-muted">Channel</span>
          <select value={seg.channel} onChange={(e) => setSeg("channel", e.target.value)} className={control + " mt-1"}>
            <option value="all">All channels</option>
            {CHANNELS.map((c) => (
              <option key={c} value={c}>
                {CHANNEL_LABEL[c]}
              </option>
            ))}
          </select>
        </label>
        <div className="flex flex-wrap gap-2 sm:ml-auto">
          <button type="button" onClick={exportCsv} className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg hover:opacity-90">
            Export CSV
          </button>
          <button type="button" onClick={copyLink} className="rounded-md border border-border px-3 py-1.5 text-sm hover:border-accent hover:text-accent">
            Copy link
          </button>
          <button type="button" onClick={() => update(DEFAULT_VIEW)} className="rounded-md border border-border px-3 py-1.5 font-mono text-xs hover:border-danger hover:text-danger">
            reset demo
          </button>
        </div>
      </div>
      <p className="font-mono text-xs text-fg-muted" aria-live="polite">
        {isoDay(range.from)} → {isoDay(range.to)} · {range.to - range.from + 1} days · {seg.plan === "all" && seg.region === "all" && seg.channel === "all" ? "all accounts" : "filtered segment"} ·
        fictional data, seeded
        {toast && <span className="ml-2 text-accent">{toast}</span>}
      </p>

      {/* ---------- KPI tiles ---------- */}
      <ul className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
        {tiles.map((k) => {
          const delta = k.previous === null ? null : k.format === "pct" ? k.value - k.previous : k.previous === 0 ? null : (k.value - k.previous) / k.previous;
          const up = delta !== null && delta > 0.0005;
          const down = delta !== null && delta < -0.0005;
          const good = (up && k.goodWhenUp) || (down && !k.goodWhenUp);
          const bad = (down && k.goodWhenUp) || (up && !k.goodWhenUp);
          return (
            <li key={k.id} className={`rounded-lg border border-border bg-bg-elev p-3 ${k.id === "arpa" ? "col-span-2 md:col-span-1" : ""}`} title={k.hint}>
              <p className="text-xs text-fg-muted">{k.label}</p>
              <p className="mt-1 text-2xl font-semibold tracking-tight">{ready ? formatValue(k.value, k.format) : <span className="inline-block h-7 w-20 animate-pulse rounded bg-bg-muted" />}</p>
              <p className={`mt-1 font-mono text-xs ${good ? "text-accent" : bad ? "text-danger" : "text-fg-muted"}`}>
                {delta === null ? (
                  "no previous period"
                ) : (
                  <>
                    <span aria-hidden="true">{up ? "▲" : down ? "▼" : "■"}</span>{" "}
                    {k.format === "pct" ? `${delta >= 0 ? "+" : ""}${(delta * 100).toFixed(1)} pts` : `${delta >= 0 ? "+" : ""}${(delta * 100).toFixed(1)}%`}
                    <span className="sr-only"> {good ? "(improved)" : bad ? "(worse)" : "(flat)"}</span>
                    <span className="text-fg-muted"> vs prev.</span>
                  </>
                )}
              </p>
            </li>
          );
        })}
      </ul>

      {/* ---------- charts ---------- */}
      <div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
        <section aria-labelledby="mrr-h" className="rounded-lg border border-border bg-bg-elev p-3">
          <h3 id="mrr-h" className="text-sm font-semibold">
            MRR <span className="font-normal text-fg-muted">· end of each {points.length && points[0]!.start === points[0]!.end ? "day" : rows.length > 1 && rows[1]!.bucket.start - rows[0]!.bucket.start === 7 ? "week" : "month"}</span>
          </h3>
          <LineChart title="MRR" points={points} values={rows.map((r) => r.mrr)} format={(v, c) => formatValue(v, "usd", c)} />
        </section>
        <section aria-labelledby="split-h" className="rounded-lg border border-border bg-bg-elev p-3">
          <h3 id="split-h" className="mb-3 text-sm font-semibold">
            MRR by {by} <span className="font-normal text-fg-muted">· {isoDay(range.to)}</span>
          </h3>
          <Donut
            title={`MRR by ${by}`}
            format={(v) => formatValue(v, "usd", true)}
            items={split.map((s, i) => ({ key: s.key, label: splitLabel(s.key), value: s.value, color: segColors(i) }))}
          />
        </section>
      </div>

      <div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
        <section aria-labelledby="su-h" className="rounded-lg border border-border bg-bg-elev p-3">
          <div className="flex flex-wrap items-center gap-3">
            <h3 id="su-h" className="text-sm font-semibold">
              New signups by plan
            </h3>
            <ul className="ml-auto flex gap-3 text-xs text-fg-muted" aria-label="Legend">
              {PLANS.map((p, i) => (
                <li key={p} className="flex items-center gap-1.5">
                  <span className="size-2.5 rounded-sm" style={{ background: segColors(i) }} aria-hidden="true" />
                  {PLAN_LABEL[p]}
                </li>
              ))}
            </ul>
          </div>
          {totalSignups === 0 ? (
            <p className="grid h-48 place-items-center text-sm text-fg-muted">No signups in this segment and range. Try a wider range.</p>
          ) : (
            <StackedBars
              title="New signups by plan"
              points={points}
              series={PLANS.map((p, i) => ({ key: p, label: PLAN_LABEL[p], color: segColors(i), values: rows.map((r) => r.signups[p]) }))}
            />
          )}
        </section>
        <section aria-labelledby="churn-h" className="rounded-lg border border-border bg-bg-elev p-3">
          <h3 id="churn-h" className="text-sm font-semibold">
            Churn in range
          </h3>
          <dl className="mt-3 space-y-3 text-sm">
            {[
              ["Accounts lost", rows.reduce((t, r) => t + r.churned, 0).toLocaleString("en-US")],
              ["MRR lost", formatValue(rows.reduce((t, r) => t + r.churnedMrr, 0), "usd")],
              ["New MRR", formatValue(recent.reduce((t, a) => t + a.mrr, 0), "usd")],
            ].map(([k, v]) => (
              <div key={k} className="flex justify-between border-b border-border pb-2">
                <dt className="text-fg-muted">{k}</dt>
                <dd className="font-mono">{v}</dd>
              </div>
            ))}
          </dl>
          <p className="mt-3 text-xs text-fg-muted">Paid-ads signups churn about a third faster than organic in this data set. Compare them with the Channel filter.</p>
        </section>
      </div>

      {/* ---------- cohort table ---------- */}
      <section aria-labelledby="coh-h" className="rounded-lg border border-border bg-bg-elev p-3">
        <h3 id="coh-h" className="text-sm font-semibold">
          Retention by signup month <span className="font-normal text-fg-muted">· share of each cohort still paying</span>
        </h3>
        <div className="mt-3 overflow-x-auto">
          <table className="w-full min-w-[36rem] border-separate border-spacing-0.5 text-center font-mono text-xs">
            <thead className="text-fg-muted">
              <tr>
                <th scope="col" className="py-1 text-left font-normal">
                  Cohort
                </th>
                <th scope="col" className="font-normal">
                  Size
                </th>
                {cohortRows[0]?.retained.map((_, k) => (
                  <th key={k} scope="col" className="font-normal">
                    M{k}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {cohortRows.map((c) => (
                <tr key={c.label}>
                  <th scope="row" className="py-1 pr-2 text-left font-normal">
                    {c.label}
                  </th>
                  <td className="text-fg-muted">{c.size}</td>
                  {c.retained.map((r, k) => (
                    <td
                      key={k}
                      className={`rounded px-1 py-1.5 ${r !== null && r >= 0.6 ? "text-accent-fg" : ""}`}
                      style={r === null ? undefined : { background: `color-mix(in oklab, var(--accent) ${Math.round(15 + r * 85)}%, var(--bg-elev))` }}
                    >
                      {r === null ? "" : `${Math.round(r * 100)}%`}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </section>

      {/* ---------- accounts table ---------- */}
      <section aria-labelledby="acc-h" className="rounded-lg border border-border bg-bg-elev p-3">
        <div className="flex flex-wrap items-center gap-2">
          <h3 id="acc-h" className="text-sm font-semibold">
            Signups in range <span className="font-normal text-fg-muted">({recent.length})</span>
          </h3>
          <div className="ml-auto flex items-center gap-2 font-mono text-xs">
            <button type="button" disabled={pageSafe === 0} onClick={() => setPage(pageSafe - 1)} className="rounded border border-border px-2 py-0.5 disabled:opacity-40" aria-label="Previous page">
              ←
            </button>
            <span>
              {pageSafe + 1}/{pages}
            </span>
            <button type="button" disabled={pageSafe >= pages - 1} onClick={() => setPage(pageSafe + 1)} className="rounded border border-border px-2 py-0.5 disabled:opacity-40" aria-label="Next page">
              →
            </button>
          </div>
        </div>
        <div className="mt-2 overflow-x-auto">
          <table className="w-full min-w-[34rem] text-left text-sm">
            <thead className="font-mono text-xs text-fg-muted">
              <tr className="border-b border-border">
                <th scope="col" className="py-2 pr-3 font-normal">
                  Account
                </th>
                <th scope="col" className="py-2 pr-3 font-normal" aria-sort={sort.key === "plan" ? (sort.dir === 1 ? "ascending" : "descending") : undefined}>
                  {sortBtn("plan", "Plan")}
                </th>
                <th scope="col" className="py-2 pr-3 font-normal">
                  Region
                </th>
                <th scope="col" className="py-2 pr-3 text-right font-normal" aria-sort={sort.key === "mrr" ? (sort.dir === 1 ? "ascending" : "descending") : undefined}>
                  {sortBtn("mrr", "MRR")}
                </th>
                <th scope="col" className="py-2 pr-3 font-normal" aria-sort={sort.key === "signup" ? (sort.dir === 1 ? "ascending" : "descending") : undefined}>
                  {sortBtn("signup", "Signed up")}
                </th>
                <th scope="col" className="py-2 font-normal">
                  Status
                </th>
              </tr>
            </thead>
            <tbody>
              {recent.slice(pageSafe * PER_PAGE, pageSafe * PER_PAGE + PER_PAGE).map((a) => (
                <tr key={a.id} className="border-b border-border">
                  <td className="py-1.5 pr-3">{a.name}</td>
                  <td className="py-1.5 pr-3">{PLAN_LABEL[a.plan]}</td>
                  <td className="py-1.5 pr-3 text-fg-muted">{REGION_LABEL[a.region]}</td>
                  <td className="py-1.5 pr-3 text-right font-mono">${a.mrr}</td>
                  <td className="py-1.5 pr-3 font-mono text-xs">{isoDay(a.signupDay)}</td>
                  <td className="py-1.5 text-xs">
                    {a.churnDay !== null && a.churnDay <= range.to ? (
                      <span className="text-danger">churned {isoDay(a.churnDay)}</span>
                    ) : (
                      <span className="text-accent">active</span>
                    )}
                  </td>
                </tr>
              ))}
              {recent.length === 0 && (
                <tr>
                  <td colSpan={6} className="py-6 text-center text-fg-muted">
                    No accounts signed up in this range and segment.
                  </td>
                </tr>
              )}
            </tbody>
          </table>
        </div>
      </section>
    </div>
  );
}