Skip to contentVibraUI

Analytics overview

A product analytics dashboard: sidebar shell, four headline metrics with sparklines, traffic over time, sources, top pages, team activity, and the newest signups.

Open the live page

The page is a server component inside AppShell: it reads its rows through db, hands the newest signups to the table as props, and passes a server action for signing out. The SaaS dashboard's nav.ts is the whole navigation, as plain data. Only the parts that read the selected time range, or hold their own state, are client islands, and the range control in the page header drives the stat cards, the traffic chart, the source split, and the top pages through one small context. Signups, the audit trail behind the activity feed, the notification bell and the signed-in owner all come from db; the daily traffic series has no entity of its own, so it is generated from seeded("saas-overview") against REFERENCE_DATE. Composes AppShell, PageHeader, ChartTimeRange, ExportMenu, StatCardGroup, StatCard, Sparkline, DashboardGrid, ChartCard, AreaChart, DonutChart, Widget, RankList, ActivityFeed, DataTable, UserCell, StatusBadge, DateCell, and DataTableRowActions.

Preview

Install

npx shadcn@latest add @vibra/saas-overview

Needs the @vibra registry in your components.json — set it up once.

Source

app/saas/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { DashboardGrid, DashboardGridItem } from "@/components/ui/dashboard-grid"
import { PageHeader } from "@/components/ui/page-header"

import { signOut } from "./actions"
import { OverviewRangeProvider, OverviewToolbar } from "./components/overview-range"
import { OverviewStats } from "./components/overview-stats"
import { RecentActivity } from "./components/recent-activity"
import { RecentSignups } from "./components/recent-signups"
import { TopPages } from "./components/top-pages"
import { TrafficChart } from "./components/traffic-chart"
import { TrafficSources } from "./components/traffic-sources"
import {
  currentUser,
  lastUpdated,
  overviewStatsByRange,
  planNames,
  recentSignups,
  shellNotifications,
  topPagesByRange,
  trafficByRange,
  trafficSourcesByRange,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/saas/nav"

/**
 * Analytics overview. The page itself is a server component: it reads its rows
 * through `db` and hands them to the shell, and only the parts that read the
 * selected range, or hold their own state, are client islands. Those are handed
 * every range's rows as props — none of them imports `data.ts`, so the sample
 * store stays on the server.
 */
export default function DashboardPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.overview}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      {/* Linked: the page draws the tiles and the chart together, so the tiles
          are tabs and the chart is their panel. */}
      <OverviewRangeProvider linked>
        <PageHeader
          title="Overview"
          description="Traffic and signups for northwind.example."
          meta={lastUpdated()}
          actions={<OverviewToolbar />}
        />

        <OverviewStats stats={overviewStatsByRange()} />

        <DashboardGrid>
          <DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
            <TrafficChart traffic={trafficByRange()} asOf={lastUpdated()} />
          </DashboardGridItem>
          <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
            <TrafficSources sources={trafficSourcesByRange()} />
          </DashboardGridItem>

          <DashboardGridItem colSpan={{ base: 12, lg: 6 }}>
            <TopPages pages={topPagesByRange()} />
          </DashboardGridItem>
          <DashboardGridItem colSpan={{ base: 12, lg: 6 }}>
            <RecentActivity />
          </DashboardGridItem>

          <DashboardGridItem colSpan={12}>
            <RecentSignups rows={recentSignups()} planNames={planNames()} />
          </DashboardGridItem>
        </DashboardGrid>
      </OverviewRangeProvider>
    </AppShell>
  )
}
app/saas/data.ts
/**
 * What this page reads. Rows come from `db`, so swapping a repository for a
 * real store is the whole migration; the daily series behind the charts have
 * no entity of their own, so they are generated once from `seeded("dashboard-01")`
 * — deterministic, measured against `REFERENCE_DATE`, never a clock.
 */
import { getInitials } from "@/lib/format"
import {
  previousPeriod,
  traced,
  trailingPeriod,
  type Metric,
  type Provenance,
} from "@/lib/metric"
import {
  db,
  REFERENCE_DATE,
  seeded,
  type Customer,
  type Member,
} from "@/lib/sample-data"

export type { Customer }

export type RangeKey = "7d" | "30d" | "90d"

const RANGE_DAYS: Record<RangeKey, number> = { "7d": 7, "30d": 30, "90d": 90 }

/** How many days a range covers, for copy like "vs previous 30 days". */
export function rangeDays(range: RangeKey): number {
  return RANGE_DAYS[range]
}

const DAY_MS = 86_400_000
// Twice the longest range, so every range can be compared with the one before it.
const SERIES_DAYS = 180

type Day = { date: string; visitors: number; signups: number; sessionSeconds: number }

/** Midnight UTC on the last complete day before "now". */
const LAST_DAY =
  Date.UTC(
    REFERENCE_DATE.getUTCFullYear(),
    REFERENCE_DATE.getUTCMonth(),
    REFERENCE_DATE.getUTCDate()
  ) - DAY_MS

const SERIES_START = LAST_DAY - (SERIES_DAYS - 1) * DAY_MS

// Two days near the end of the window when a launch post ran, so the chart has
// something to explain rather than only a trend.
const SPIKE_FROM = SERIES_DAYS - 26

/**
 * Half a year of daily traffic: weekends run a little over half a weekday, the
 * whole window climbs, and the launch post shows up as a two-day spike.
 */
function generateDays(): Day[] {
  const rand = seeded("dashboard-01")

  return Array.from({ length: SERIES_DAYS }, (_, index) => {
    const at = new Date(SERIES_START + index * DAY_MS)
    const weekend = at.getUTCDay() === 0 || at.getUTCDay() === 6
    const growth = 1 + (index / SERIES_DAYS) * 0.7
    const spike = index >= SPIKE_FROM && index < SPIKE_FROM + 2 ? 1.42 : 1
    const base = (weekend ? 520 : 1_020) * growth * spike
    const visitors = Math.round(base * (0.88 + rand() * 0.24))
    // Signups track visitors at a rate that drifts a little day to day.
    const rate = 0.032 + rand() * 0.009
    const sessionSeconds = Math.round((196 + (index / SERIES_DAYS) * 62) * (0.94 + rand() * 0.12))

    return {
      date: at.toISOString().slice(0, 10),
      visitors,
      signups: Math.max(1, Math.round(visitors * rate)),
      sessionSeconds,
    }
  })
}

const DAYS = generateDays()

/** The `days` days ending `offset` spans back: `span(30, 1)` is the previous 30 days. */
function span(days: number, offset = 0): Day[] {
  const end = DAYS.length - offset * days
  return DAYS.slice(Math.max(0, end - days), end)
}

const sum = (rows: readonly Day[], key: "visitors" | "signups" | "sessionSeconds"): number =>
  rows.reduce((total, row) => total + row[key], 0)

// The middle session, not the average one: a handful of very long sessions drags
// a mean somewhere no reader ever sat. Even-length windows take the midpoint of
// the two middles, which is what "median" means for an even count.
const median = (rows: readonly Day[], key: "sessionSeconds"): number => {
  if (rows.length === 0) return 0
  const sorted = rows.map((row) => row[key]).sort((a, b) => a - b)
  const middle = Math.floor(sorted.length / 2)
  return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
}

/** The four measures the overview plots, each keyed as its stat card is. */
export type MetricKey = "visitors" | "signups" | "rate" | "session"

/**
 * One day, in both periods: the four measures as they were, and the same four
 * over the window immediately before — aligned by position, so the compare
 * ghost lines up day for day with the series it sits behind.
 */
export type TrafficPoint = Record<MetricKey | `${MetricKey}Before`, number> & { date: string }

const measures = (day: Day) => ({
  visitors: day.visitors,
  signups: day.signups,
  // A fraction, not a percentage: the chart's own formatter prints the sign.
  rate: day.signups / day.visitors,
  session: day.sessionSeconds,
})

/** The daily measures inside a range, oldest first, each beside the period before it. */
export function trafficPoints(range: RangeKey): TrafficPoint[] {
  const days = RANGE_DAYS[range]
  const now = span(days)
  const before = span(days, 1)

  return now.map((day, index) => {
    const previous = before[index] ?? day
    const was = measures(previous)
    return {
      date: day.date,
      ...measures(day),
      visitorsBefore: was.visitors,
      signupsBefore: was.signups,
      rateBefore: was.rate,
      sessionBefore: was.session,
    }
  })
}

/**
 * The day the launch post ran, or undefined when it falls outside the range —
 * the peak of the two-day spike the generator writes in, read back off the rows
 * rather than written out a second time as a literal.
 */
export function launchDay(range: RangeKey): string | undefined {
  const window = span(RANGE_DAYS[range])
  const spike = DAYS.slice(SPIKE_FROM, SPIKE_FROM + 2)
  const peak = spike.reduce((most, day) => (day.visitors > most.visitors ? day : most), spike[0])
  return window.some((day) => day.date === peak.date) ? peak.date : undefined
}

export type OverviewStat = {
  key: string
  label: string
  /** The number itself, carrying the kind it is read in. */
  metric: Metric
  /** The same measure over the range immediately before this one. */
  previous: number
  /** What the change is measured against, taken from the period itself. */
  compareLabel: string
  /** Where the number came from — the sampled rows and the formula, never the whole window. */
  provenance: Provenance
  spark: number[]
  sparkType: "area" | "bar" | "line"
}

/**
 * The four headline numbers for a range, each traced back to the days it was
 * computed from. `traced` runs the sum over every day in the window and keeps
 * only the first few rows, so a card can show its work without the page
 * handing a quarter of daily traffic to the browser. The rows go in newest
 * first, which is the end of the window a reader checks.
 */
export function overviewStats(range: RangeKey): OverviewStat[] {
  const days = RANGE_DAYS[range]
  const now = span(days)
  const before = span(days, 1)
  const recent = [...now].reverse()
  // REFERENCE_DATE is this page's "now"; the metric lib keeps none of its own.
  const compareLabel = `vs ${previousPeriod(trailingPeriod(days, REFERENCE_DATE)).label.toLowerCase()}`

  const visitors = traced("daily traffic", recent, "sum(visitors)", (rows) => sum(rows, "visitors"), {
    columns: ["date", "visitors"],
  })
  const signups = traced("daily traffic", recent, "sum(signups)", (rows) => sum(rows, "signups"), {
    columns: ["date", "signups"],
  })
  const rate = traced(
    "daily traffic",
    recent,
    "sum(signups) ÷ sum(visitors)",
    (rows) => sum(rows, "signups") / sum(rows, "visitors"),
    { columns: ["date", "visitors", "signups"] }
  )
  const session = traced(
    "daily sessions",
    recent,
    "median(sessionSeconds)",
    (rows) => median(rows, "sessionSeconds"),
    { columns: ["date", "sessionSeconds"] }
  )

  const wasVisitors = sum(before, "visitors")
  const wasSignups = sum(before, "signups")

  return [
    {
      key: "visitors",
      label: "Visitors",
      metric: { kind: "count", value: visitors.value },
      previous: wasVisitors,
      compareLabel,
      provenance: visitors.provenance,
      spark: now.map((day) => day.visitors),
      sparkType: "area",
    },
    {
      key: "signups",
      label: "Signups",
      metric: { kind: "count", value: signups.value },
      previous: wasSignups,
      compareLabel,
      provenance: signups.provenance,
      spark: now.map((day) => day.signups),
      sparkType: "bar",
    },
    {
      key: "rate",
      label: "Signup rate",
      metric: { kind: "percent", value: rate.value, precision: 2 },
      previous: wasSignups / wasVisitors,
      compareLabel,
      provenance: rate.provenance,
      spark: now.map((day) => Math.round((day.signups / day.visitors) * 1000) / 10),
      sparkType: "line",
    },
    {
      key: "session",
      label: "Median session",
      metric: { kind: "duration", value: session.value, unit: "s" },
      previous: median(before, "sessionSeconds"),
      compareLabel,
      provenance: session.provenance,
      spark: now.map((day) => day.sessionSeconds),
      sparkType: "line",
    },
  ]
}

/** One reading for each range the toolbar offers. */
export type ByRange<T> = Record<RangeKey, T>

const byRange = <T,>(read: (range: RangeKey) => T): ByRange<T> => ({
  "7d": read("7d"),
  "30d": read("30d"),
  "90d": read("90d"),
})

/**
 * Every range the toolbar offers, computed here rather than in the island that
 * renders them: the range is client state, the numbers are not.
 */
export function overviewStatsByRange(): ByRange<OverviewStat[]> {
  return byRange(overviewStats)
}

/** What the traffic chart draws for one range: its days, and the launch post when it falls inside. */
export type TrafficView = { days: number; points: TrafficPoint[]; launch?: string }

/**
 * The chart's rows for every range, handed to the island as a prop — the same
 * reason as the tiles': an island that imported this module would take `db`,
 * and every row behind it, into the browser.
 */
export function trafficByRange(): ByRange<TrafficView> {
  return byRange((range) => ({ days: rangeDays(range), points: trafficPoints(range), launch: launchDay(range) }))
}

/** Where one range's visitors came from. */
export type SourcesView = { days: number; sources: { name: string; value: number }[] }

export function trafficSourcesByRange(): ByRange<SourcesView> {
  return byRange((range) => ({ days: rangeDays(range), sources: trafficSources(range) }))
}

/** One range's most-read pages, and how many pages saw any traffic in it. */
export type PagesView = { days: number; pages: { label: string; value: number }[]; withTraffic: number }

export function topPagesByRange(): ByRange<PagesView> {
  return byRange((range) => ({ days: rangeDays(range), pages: topPages(range), withTraffic: pagesWithTraffic(range) }))
}

// Where visitors arrive from, largest first. The names are the block's own
// vocabulary; the split comes off the same generator as the traffic.
const SOURCE_NAMES = ["Organic search", "Direct", "Referral", "Paid social", "Email"]
const SOURCE_WEIGHTS = shares("dashboard-01-sources", SOURCE_NAMES.length, 0.42)

/** `count` shares of 1, each one `decay` times the share before it, plus a little noise. */
function shares(name: string, count: number, decay: number): number[] {
  const rand = seeded(name)
  const raw = Array.from({ length: count }, (_, index) => decay ** index * (0.9 + rand() * 0.2))
  const total = raw.reduce((sum, value) => sum + value, 0)
  return raw.map((value) => value / total)
}

/** Where a range's visitors came from, largest share first. */
export function trafficSources(range: RangeKey): { name: string; value: number }[] {
  const visitors = sum(span(RANGE_DAYS[range]), "visitors")
  return SOURCE_NAMES.map((name, index) => ({
    name,
    value: Math.round(visitors * SOURCE_WEIGHTS[index]),
  }))
}

const PAGE_PATHS = [
  "/",
  "/pricing",
  "/docs/quickstart",
  "/changelog",
  "/blog/observability-budgets",
  "/docs/api/events",
]

// Each page in the list takes this share of the one above it, and the tail
// below the list keeps following the same curve.
const PAGE_DECAY = 0.62
const PAGE_WEIGHTS = shares("dashboard-01-pages", PAGE_PATHS.length, PAGE_DECAY)

/**
 * How many pages saw any traffic at all. `topPages` is the head of a curve that
 * falls by `PAGE_DECAY` a step, so the count is the step at which it finally
 * drops below one view — which is why a shorter range reaches fewer pages.
 */
export function pagesWithTraffic(range: RangeKey): number {
  const [busiest] = topPages(range)
  const steps = Math.floor(Math.log(busiest.value) / Math.log(1 / PAGE_DECAY)) + 1
  return Math.max(PAGE_PATHS.length, steps)
}

/** The six most-read pages in a range, by views. */
export function topPages(range: RangeKey): { label: string; value: number }[] {
  // A visitor reads about 2.4 pages, so views run ahead of visitors.
  const views = sum(span(RANGE_DAYS[range]), "visitors") * 2.4
  return PAGE_PATHS.map((label, index) => ({
    label,
    value: Math.round(views * PAGE_WEIGHTS[index]),
  }))
}

/** What the team changed, newest first — the workspace's own audit trail. */
export function recentActivity() {
  // Read per call, never held at module scope: a teammate renamed since the
  // server started is the name on the next render.
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  return db.auditEvents
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 8)
    .map((event) => ({
      id: event.id,
      actor: {
        name: members.get(event.actor)?.name ?? "A teammate",
        src: members.get(event.actor)?.avatarUrl,
      },
      action: event.action,
      target: `${event.resource.replace(/_/g, " ")} ${event.resourceId}`,
      time: event.at,
      meta: event.diff?.[0]
        ? `${event.diff[0].field}: ${event.diff[0].from} → ${event.diff[0].to}`
        : undefined,
    }))
}

// The plans as they are written for a reader, keyed by the value a customer row
// carries: "team" → "Team".
const PLAN_NAMES = new Map(db.plans.all().map((plan) => [plan.name.toLowerCase(), plan.name]))

/** A customer's plan, as the pricing page writes it. */
export function planName(plan: Customer["plan"]): string {
  return PLAN_NAMES.get(plan) ?? plan
}

const PLANS: Customer["plan"][] = ["free", "starter", "team", "enterprise"]

/** Every plan a customer row can carry, as the pricing page writes it — for an island to look up. */
export function planNames(): Record<Customer["plan"], string> {
  return Object.fromEntries(PLANS.map((plan) => [plan, planName(plan)])) as Record<Customer["plan"], string>
}

/** The newest workspaces, across every plan. */
export function recentSignups(): Customer[] {
  return db.customers
    .all()
    .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
    .slice(0, 12)
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

// Fixed to UTC so the line reads the same wherever the page is rendered.
const UPDATED_AT = new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
  hourCycle: "h23",
  timeZone: "UTC",
})

/** When the numbers on this page were last collected. */
export function lastUpdated(): string {
  return `Last updated ${UPDATED_AT.format(REFERENCE_DATE)} UTC`
}
app/saas/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

/**
 * The one thing this page changes. A server action so the page can stay a
 * server component and still hand the shell something to call, and a `Result`
 * so the caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/saas/components/activity-scroller.tsx
"use client"

import * as React from "react"

/**
 * The activity feed's scroll box. A box that scrolls is one the keyboard has
 * to reach — the arrow keys scroll only what has the focus, and nothing in the
 * feed takes it (axe scrollable-region-focusable) — so, by Table's rule, while
 * the entries overflow it the box is a tab stop and a region named `label`,
 * and while they fit it is neither. The name stands apart from the card's, so
 * the page never holds two regions of one name.
 */
export function ActivityScroller({ label, children }: { label: string; children: React.ReactNode }) {
  const box = React.useRef<HTMLDivElement>(null)
  const [scrolling, setScrolling] = React.useState(false)

  React.useEffect(() => {
    const node = box.current
    if (!node) return
    const measure = () => setScrolling(node.scrollHeight > node.clientHeight)
    measure()
    if (typeof ResizeObserver === "undefined") return
    const observer = new ResizeObserver(measure)
    observer.observe(node)
    if (node.firstElementChild) observer.observe(node.firstElementChild)
    return () => observer.disconnect()
  }, [])

  return (
    <div
      ref={box}
      data-slot="activity-scroller"
      {...(scrolling ? { tabIndex: 0, role: "region", "aria-label": label } : null)}
      className="relative max-h-72 overflow-y-auto rounded-sm focus-ring-inset"
    >
      {children}
    </div>
  )
}
app/saas/components/overview-range.tsx
"use client"

import * as React from "react"

import { ChartTimeRange } from "@/components/ui/chart-time-range"
import { ExportMenu, type ExportFormat } from "@/components/ui/export-menu"

import { type MetricKey, type RangeKey } from "../data"

/** The ranges this page offers. UI vocabulary, so it lives with the control. */
const RANGE_OPTIONS: { value: RangeKey; label: string }[] = [
  { value: "7d", label: "7d" },
  { value: "30d", label: "30d" },
  { value: "90d", label: "90d" },
]

/** The id the tiles point their `aria-controls` at, and the export reads its plot from. */
export const OVERVIEW_CHART_ID = "overview-chart"

type OverviewState = { range: RangeKey; metric: MetricKey; compare: boolean }

type OverviewActions = {
  setRange: (range: RangeKey) => void
  setMetric: (metric: MetricKey) => void
  setCompare: (compare: boolean) => void
}

/** What "export this view" means, filled in by whatever is holding the rows. */
export type OverviewExporter = (format: ExportFormat) => void | Promise<void>

const StateContext = React.createContext<OverviewState>({
  range: "30d",
  metric: "visitors",
  compare: false,
})
const ActionsContext = React.createContext<OverviewActions>({
  setRange: () => {},
  setMetric: () => {},
  setCompare: () => {},
})
// A box rather than a value: the toolbar sits in the page header and the rows
// sit in the chart card, so the two are not in the same subtree. Registering
// through a ref keeps the toolbar from re-rendering when the exporter changes,
// and — the reason it matters here — keeps this module's imports type-only, so
// a client island never pulls the sample-data store into the browser with it.
const ExportContext = React.createContext<{ current: OverviewExporter | null }>({ current: null })
// Whether the tiles and the chart are drawn together. Declared by whoever
// mounts them — the page does, a widget drawn alone does not — and never read
// off the DOM, so the first render already names only what is there.
const LinkedContext = React.createContext(false)

/** The range every number on this page is measured over. */
export function useOverviewRange(): RangeKey {
  return React.useContext(StateContext).range
}

/** Which of the four measures the chart is plotting, and whether it is compared. */
export function useOverviewView(): OverviewState {
  return React.useContext(StateContext)
}

/**
 * Whether the tiles and the chart under them are both mounted, so the tiles
 * may say what they drive: on the page their group is named for the chart
 * below. A card drawn without its partner speaks only for itself.
 */
export function useOverviewLinked(): boolean {
  return React.useContext(LinkedContext)
}

/** The setters behind the toolbar and the metric tiles. */
export function useOverviewActions(): OverviewActions {
  return React.useContext(ActionsContext)
}

/** Lets the part that holds the rows answer the toolbar's Export menu. */
export function useRegisterOverviewExport(exporter: OverviewExporter) {
  const slotRef = React.useContext(ExportContext)
  React.useEffect(() => {
    slotRef.current = exporter
    return () => {
      if (slotRef.current === exporter) slotRef.current = null
    }
  }, [slotRef, exporter])
}

/**
 * Holds what the page is scoped to: the range, the measure the chart plots, and
 * whether the period before it is drawn behind. The parts that read it are
 * client components; everything else stays on the server and passes through as
 * children.
 *
 * `linked` says the tiles and the chart are both inside: the page sets it,
 * because it draws both. A widget drawn alone leaves it off, and then no card
 * points at an id its partner would have carried.
 */
export function OverviewRangeProvider({
  children,
  linked = false,
}: {
  children: React.ReactNode
  linked?: boolean
}) {
  const [state, setState] = React.useState<OverviewState>({
    range: "30d",
    metric: "visitors",
    compare: false,
  })
  const exporterRef = React.useRef<OverviewExporter | null>(null)

  const actions = React.useMemo<OverviewActions>(
    () => ({
      setRange: (range) => setState((current) => ({ ...current, range })),
      setMetric: (metric) => setState((current) => ({ ...current, metric })),
      setCompare: (compare) => setState((current) => ({ ...current, compare })),
    }),
    []
  )

  return (
    <LinkedContext.Provider value={linked}>
      <ExportContext.Provider value={exporterRef}>
        <ActionsContext.Provider value={actions}>
          <StateContext.Provider value={state}>{children}</StateContext.Provider>
        </ActionsContext.Provider>
      </ExportContext.Provider>
    </LinkedContext.Provider>
  )
}

/** The page header's controls: the range, the compare switch, and an export. */
export function OverviewToolbar() {
  const { range, compare } = useOverviewView()
  const { setRange, setCompare } = useOverviewActions()
  const exporterRef = React.useContext(ExportContext)

  return (
    <>
      <ChartTimeRange
        size="sm"
        value={range}
        onValueChange={(value) => setRange(value as RangeKey)}
        options={RANGE_OPTIONS}
        compare={compare}
        onCompareChange={setCompare}
      />
      <ExportMenu
        size="sm"
        formats={["csv", "png", "pdf"]}
        // The exporter is read when the item is chosen, not while rendering:
        // the chart registers it on mount, and a menu opened before that simply
        // has nothing to hand over yet.
        onExport={(format) => exporterRef.current?.(format)}
      />
    </>
  )
}
app/saas/components/overview-stats.tsx
"use client"

import { MetricValue } from "@/components/ui/metric-value"
import { Sparkline } from "@/components/ui/sparkline"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type MetricKey, type OverviewStat, type RangeKey } from "../data"
import { useOverviewActions, useOverviewLinked, useOverviewView } from "./overview-range"

/**
 * The four headline numbers, and the row that picks what the chart under them
 * plots.
 *
 * The range is client state, so this is an island — but the numbers themselves
 * arrive computed, each with the sampled rows behind it, from the server
 * component above. On the page, pressing a tile re-binds the chart rather than
 * opening a second view. The tiles are toggle buttons in a group, not tabs:
 * each one carries its own Explain button and a sparkline, and a tablist may
 * hold nothing but its tabs. The chart below is a region named by its title,
 * which follows the pressed tile.
 */
export function OverviewStats({ stats }: { stats: Record<RangeKey, OverviewStat[]> }) {
  const { range, metric } = useOverviewView()
  const { setMetric } = useOverviewActions()
  const linked = useOverviewLinked()

  return (
    <StatCardGroup
      data-widget="widget-saas-overview-overview-stats"
      columns={4}
      selectable
      value={metric}
      onValueChange={(value) => setMetric(value as MetricKey)}
      aria-label={linked ? "Measure plotted below" : "Headline numbers"}
    >
      {stats[range].map((stat) => (
        <StatCard
          key={stat.key}
          id={stat.key}
          label={stat.label}
          value={
            <MetricValue
              metric={stat.metric}
              previous={stat.previous}
              compareLabel={stat.compareLabel}
              // Names this card's explain button, so the four of them read as
              // four different buttons rather than four copies of one.
              label={stat.label}
              provenance={stat.provenance}
            />
          }
          footer={
            <Sparkline
              data={stat.spark}
              type={stat.sparkType}
              height={32}
              showLast
              // One point a day, so the series is its own count of days.
              aria-label={`${stat.label}, last ${stat.spark.length} days`}
            />
          }
        />
      ))}
    </StatCardGroup>
  )
}
app/saas/components/recent-activity.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { ActivityFeed } from "@/components/ui/activity-feed"
import { Widget } from "@/components/ui/widget"

import { recentActivity } from "../data"
import { ActivityScroller } from "./activity-scroller"

/** A server component: the feed reads a fixed "now", so nothing here needs state but its scroll box. */
export function RecentActivity() {
  return (
    <Widget
      data-widget="widget-saas-overview-recent-activity"
      title="Activity"
      description="What the team changed"
      className="h-full"
    >
      <ActivityScroller label="Activity feed">
        <ActivityFeed items={recentActivity()} groupByDay now={REFERENCE_DATE} />
      </ActivityScroller>
    </Widget>
  )
}
app/saas/components/recent-signups.tsx
"use client"

import * as React from "react"
import { ArrowRightIcon, MailIcon, UserRoundXIcon } from "lucide-react"

import {
  DataTable,
  DataTableColumnHeader,
  DataTableRowActions,
  type DataTableColumnDef,
} from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { DateCell, TruncateCell } from "@/components/ui/table-cells"
import { UserCell } from "@/components/ui/user-cell"
import { Widget } from "@/components/ui/widget"

import { type Customer } from "../data"

/** The two account states StatusBadge has no default variant for. */
const STATUS_MAP = { trial: "info", churned: "danger", suspended: "warning" } as const

export type RecentSignupsProps = {
  rows: Customer[]
  /** Each plan as the pricing page writes it, looked up on the server: this island never reads the store. */
  planNames: Record<Customer["plan"], string>
}

export function RecentSignups({ rows, planNames }: RecentSignupsProps) {
  // The title names the table too, so a screen reader announces it by name.
  const titleId = React.useId()
  const columns = React.useMemo<DataTableColumnDef<Customer>[]>(
    () => [
      {
        accessorKey: "name",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Person" />,
        cell: ({ row }) => (
          <UserCell size="sm" name={row.original.name} email={row.original.email} src={row.original.avatarUrl} />
        ),
        meta: { label: "Person" },
      },
      {
        accessorKey: "company",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Company" />,
        cell: ({ row }) => <TruncateCell maxWidth={200}>{row.original.company}</TruncateCell>,
        meta: { label: "Company" },
      },
      {
        accessorKey: "plan",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Plan" />,
        cell: ({ row }) => (
          <span className="text-muted-foreground">{planNames[row.original.plan] ?? row.original.plan}</span>
        ),
        meta: { label: "Plan" },
      },
      {
        accessorKey: "status",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
        cell: ({ row }) => <StatusBadge status={row.original.status} map={STATUS_MAP} />,
        meta: { label: "Status" },
      },
      {
        accessorKey: "createdAt",
        header: ({ column }) => <DataTableColumnHeader column={column} title="Joined" />,
        cell: ({ row }) => (
          <DateCell date={row.original.createdAt} className="text-muted-foreground" />
        ),
        meta: { align: "right", label: "Joined" },
      },
      {
        id: "actions",
        size: 44,
        enableSorting: false,
        enableHiding: false,
        cell: ({ row }) => (
          <DataTableRowActions
            label={`Open menu for ${row.original.name}`}
            actions={[
              { label: "Open workspace", icon: <ArrowRightIcon />, onSelect: () => {} },
              { label: "Send welcome email", icon: <MailIcon />, onSelect: () => {} },
              {
                label: "Block account",
                icon: <UserRoundXIcon />,
                destructive: true,
                separatorBefore: true,
                onSelect: () => {},
              },
            ]}
          />
        ),
      },
    ],
    [planNames]
  )

  return (
    <Widget
      titleId={titleId}
      data-widget="widget-saas-overview-recent-signups"
      title="Recent signups"
      description="Newest workspaces, across every plan"
    >
      <DataTable
        aria-labelledby={titleId}
        size="sm"
        columns={columns}
        data={rows}
        pageSize={6}
        enableRowSelection={false}
        getRowId={(customer) => customer.id}
        initialSorting={[{ id: "createdAt", desc: true }]}
      />
    </Widget>
  )
}
app/saas/components/top-pages.tsx
"use client"

import { formatCompact } from "@/lib/format"
import { RankList } from "@/components/ui/rank-list"
import { Widget } from "@/components/ui/widget"

import { type ByRange, type PagesView } from "../data"
import { useOverviewRange } from "./overview-range"

/** The most-read pages of the range the toolbar picked, out of every range the server read. */
export function TopPages({ pages: byRange }: { pages: ByRange<PagesView> }) {
  const range = useOverviewRange()
  const { days, pages, withTraffic } = byRange[range]

  return (
    <Widget
      data-widget="widget-saas-overview-top-pages"
      title="Top pages"
      description={`By views, last ${days} days`}
      className="h-full"
      footer={`${pages.length} of ${withTraffic} pages with traffic`}
    >
      <RankList
        items={pages}
        showRank
        color="chart-1"
        format={(value) => formatCompact(value)}
      />
    </Widget>
  )
}
app/saas/components/traffic-chart.tsx
"use client"

import * as React from "react"

import {
  chartSurface,
  downloadBlob,
  downloadText,
  exportFilename,
  printPage,
  rowsToCsv,
  svgToPng,
} from "@/lib/export"
import { formatCompact, formatDuration, formatPercent } from "@/lib/format"
import { notify } from "@/lib/notify"
import { AreaChart } from "@/components/ui/area-chart"
import { ChartCard } from "@/components/ui/chart-card"
import type { ChartAnnotation, ChartSeries } from "@/components/ui/chart-core"
import type { ExportFormat } from "@/components/ui/export-menu"

import { type ByRange, type MetricKey, type RangeKey, type TrafficPoint, type TrafficView } from "../data"
import { OVERVIEW_CHART_ID, useOverviewView, useRegisterOverviewExport } from "./overview-range"

// Fixed to UTC so the axis reads the same wherever the page is rendered.
const DAY_LABEL = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  timeZone: "UTC",
})

/**
 * How each measure is drawn and read. Chart vocabulary, so it lives with the
 * component that draws it rather than with the rows: every measure gets its own
 * scale and its own formatter, which is what lets one chart hold a count, a rate
 * and a duration without a second axis fighting the first.
 */
const MEASURES: Record<
  MetricKey,
  { label: string; color: ChartSeries["color"]; format: (value: number) => string }
> = {
  visitors: { label: "Visitors", color: "chart-1", format: (value) => formatCompact(value) },
  signups: { label: "Signups", color: "chart-2", format: (value) => formatCompact(value) },
  rate: {
    label: "Signup rate",
    color: "chart-3",
    format: (value) => formatPercent(value, { maximumFractionDigits: 1 }),
  },
  session: {
    label: "Median session",
    color: "chart-5",
    format: (value) => formatDuration(value * 1000),
  },
}

const RANGE_LABELS: Record<RangeKey, string> = {
  "7d": "Last 7 days",
  "30d": "Last 30 days",
  "90d": "Last 90 days",
}

/** Each format, actually written to a file rather than logged as an intention. */
async function exportOverview(format: ExportFormat, range: RangeKey, points: TrafficPoint[], asOf: string) {
  const name = `overview-${range}`

  if (format === "csv") {
    const written = downloadText(exportFilename(name, "csv"), rowsToCsv(points))
    notify[written ? "success" : "error"](
      written ? "Downloaded the range as CSV." : "This browser cannot save the file."
    )
    return
  }

  if (format === "png") {
    const svg = chartSurface(document.getElementById(OVERVIEW_CHART_ID))
    if (!svg) {
      notify.error("The chart is still drawing.")
      return
    }
    // The CSV branch above already reports what happened; the picture has two
    // more ways to fail — drawing it, and saving it — and reported neither.
    try {
      const written = downloadBlob(exportFilename(name, "png"), await svgToPng(svg))
      notify[written ? "success" : "error"](
        written ? "Downloaded the chart as PNG." : "This browser cannot save the file."
      )
    } catch {
      notify.error("The chart could not be drawn as a picture.")
    }
    return
  }

  // The reader's own print dialog, which is also where a PDF comes from.
  printPage({
    brand: "Northwind",
    title: "Overview",
    range: RANGE_LABELS[range],
    asOf,
  })
}

export type TrafficChartProps = {
  /** The rows of every range the toolbar offers, computed on the server: the range is picked here. */
  traffic: ByRange<TrafficView>
  /** When the numbers were collected, for the printed masthead. */
  asOf: string
}

export function TrafficChart({ traffic, asOf }: TrafficChartProps) {
  const { range, metric, compare } = useOverviewView()
  const titleId = React.useId()
  const { days, points, launch } = traffic[range]
  const measure = MEASURES[metric]

  // The rows live here, so exporting them does too: the toolbar in the page
  // header asks this component what its own view means as a file.
  useRegisterOverviewExport(
    React.useCallback((format) => exportOverview(format, range, points, asOf), [range, points, asOf])
  )

  const series: ChartSeries[] = [
    { key: metric, label: measure.label, color: measure.color, compareKey: `${metric}Before` },
  ]

  // The launch post is a fact about the window, not a fifth series: a marker on
  // the day it ran explains the spike beside it, and prints as a row of text.
  const annotations: ChartAnnotation[] = launch
    ? [{ kind: "event", x: launch, label: "Launch post", tone: "neutral" }]
    : []

  return (
    <ChartCard
      data-widget="widget-saas-overview-traffic-chart"
      id={OVERVIEW_CHART_ID}
      // A region under its title, on the page and alone: the tiles above are
      // toggle buttons, and the title names whichever one is pressed.
      role="region"
      aria-labelledby={titleId}
      // The title follows the tile, so the card always names what it is plotting.
      title={<span id={titleId}>{`${measure.label}, last ${days} days`}</span>}
      description={compare ? "Against the period before, drawn dashed" : "One point a day"}
      height={264}
      className="h-full"
    >
      <AreaChart
        data={points}
        index="date"
        series={series}
        compare={compare}
        annotations={annotations}
        height={264}
        valueFormatter={measure.format}
        indexFormatter={(value) => DAY_LABEL.format(new Date(`${value}T00:00:00Z`))}
      />
    </ChartCard>
  )
}
app/saas/components/traffic-sources.tsx
"use client"

import { formatCompact, formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { DonutChart } from "@/components/ui/donut-chart"

import { type ByRange, type SourcesView } from "../data"
import { useOverviewRange } from "./overview-range"

/** Where the picked range's visitors came from, out of every range the server read. */
export function TrafficSources({ sources: byRange }: { sources: ByRange<SourcesView> }) {
  const range = useOverviewRange()
  const { days, sources } = byRange[range]
  const total = sources.reduce((sum, source) => sum + source.value, 0)

  return (
    <ChartCard
      data-widget="widget-saas-overview-traffic-sources"
      title="Where they came from"
      description={`Visitors by source, last ${days} days`}
      height={264}
      className="h-full"
    >
      <DonutChart
        data={sources}
        height={196}
        innerRadius={62}
        centerLabel="Visitors"
        centerValue={formatCompact(total)}
        valueFormatter={(value) => formatNumber(value)}
      />
    </ChartCard>
  )
}