# Analytics Dashboard

> A complete SaaS analytics dashboard: KPI strip, revenue area chart, acquisition, conversion funnel, cohort retention heatmap, filterable customer table, and activity feed — plus the app shell (sidebar, mobile navigation, theme toggle) and typed mock data.

A shadcn/ui block from the [@honestui](https://www.honestui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/honestui/analytics-dashboard).

- Page, with a live preview: https://shadcn.exchange/r/honestui/analytics-dashboard
- Screenshot: https://shadcn.exchange/shot/honestui/analytics-dashboard/light.png
- Registry item: https://www.honestui.com/r/analytics-dashboard.json
- Categories: dashboard
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/honestui/analytics-dashboard.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@tanstack/react-table`, `honestui`
- Registry items: `https://www.honestui.com/r/avatar.json`, `https://www.honestui.com/r/badge.json`, `https://www.honestui.com/r/button.json`, `https://www.honestui.com/r/card.json`, `https://www.honestui.com/r/data-table.json`, `https://www.honestui.com/r/dropdown-menu.json`, `https://www.honestui.com/r/empty.json`, `https://www.honestui.com/r/select.json`, `https://www.honestui.com/r/separator.json`, `https://www.honestui.com/r/sheet.json`, `https://www.honestui.com/r/skeleton.json`, `https://www.honestui.com/r/toggle-group.json`

## Notes from the registry

Import "honestui/charts.css" once in your global stylesheet (before tailwindcss) so the charts are styled. The dashboard installs under app/(dashboard)/ — visit /dashboard. Dark mode: add suppressHydrationWarning to <html> in your root layout (the theme is applied on the client). Mock data lives in lib/dashboard/data.ts; replace it with your own data access. A ready-to-clone template of this dashboard lives at https://github.com/honestui/honestui-dashboard.

## Files

### `registry/default/blocks/analytics-dashboard/app/(dashboard)/analytics/page.tsx`

```tsx
import type { Metadata } from "next";

import { PagePlaceholder } from "@/components/dashboard/page-placeholder";

export const metadata: Metadata = { title: "Analytics" };

export default function AnalyticsPage() {
  return <PagePlaceholder title="Analytics" />;
}
```

### `registry/default/blocks/analytics-dashboard/app/(dashboard)/customers/page.tsx`

```tsx
import type { Metadata } from "next";

import { PagePlaceholder } from "@/components/dashboard/page-placeholder";

export const metadata: Metadata = { title: "Customers" };

export default function CustomersPage() {
  return <PagePlaceholder title="Customers" />;
}
```

### `registry/default/blocks/analytics-dashboard/app/(dashboard)/dashboard/error.tsx`

```tsx
"use client";

import { Button } from "@/components/ui/button";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty";

/** Route-level error boundary for the dashboard. */
export default function DashboardError({
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
      <Empty className="mt-24">
        <EmptyHeader>
          <EmptyTitle>The dashboard couldn&apos;t be loaded</EmptyTitle>
          <EmptyDescription>
            Something went wrong while rendering this page.
          </EmptyDescription>
        </EmptyHeader>
        <Button variant="outline" onClick={reset}>
          Try again
        </Button>
      </Empty>
    </div>
  );
}
```

### `registry/default/blocks/analytics-dashboard/app/(dashboard)/dashboard/loading.tsx`

```tsx
import { Skeleton } from "@/components/ui/skeleton";

/** Route-level loading state mirroring the dashboard's layout. */
export default function DashboardLoading() {
  return (
    <div className="mx-auto flex w-full max-w-7xl flex-col gap-10 px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
      {/* Header */}
      <div className="flex flex-wrap items-start justify-between gap-4">
        <div className="space-y-2">
          <Skeleton className="h-7 w-36" />
          <Skeleton className="h-4 w-72 max-w-full" />
        </div>
        <div className="flex gap-2">
          <Skeleton className="h-8 w-36" />
          <Skeleton className="h-8 w-24" />
        </div>
      </div>

      {/* Metrics */}
      <div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
        {Array.from({ length: 4 }).map((_, i) => (
          <div key={i} className="space-y-2">
            <Skeleton className="h-4 w-32 max-w-full" />
            <Skeleton className="h-8 w-24" />
            <Skeleton className="h-3.5 w-36 max-w-full" />
          </div>
        ))}
      </div>

      {/* Revenue chart */}
      <div className="space-y-5">
        <div className="flex flex-wrap items-end justify-between gap-3">
          <div className="space-y-2">
            <Skeleton className="h-5 w-24" />
            <Skeleton className="h-4 w-64 max-w-full" />
          </div>
          <Skeleton className="h-8 w-72 max-w-full" />
        </div>
        <Skeleton className="h-72 w-full sm:h-80" />
      </div>

      {/* Two-column analytics */}
      <div className="grid gap-10 lg:grid-cols-2 lg:gap-12">
        {Array.from({ length: 2 }).map((_, i) => (
          <div key={i} className="space-y-5">
            <div className="space-y-2">
              <Skeleton className="h-5 w-28" />
              <Skeleton className="h-4 w-56 max-w-full" />
            </div>
            <Skeleton className="h-56 w-full" />
          </div>
        ))}
      </div>

      {/* Customers table */}
      <div className="space-y-5">
        <div className="space-y-2">
          <Skeleton className="h-5 w-28" />
          <Skeleton className="h-4 w-56 max-w-full" />
        </div>
        <div className="overflow-hidden rounded-lg border">
          <div className="border-b p-4">
            <Skeleton className="h-8 w-64 max-w-full" />
          </div>
          <div className="space-y-3 p-4">
            {Array.from({ length: 8 }).map((_, i) => (
              <Skeleton key={i} className="h-6 w-full" />
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
```

### `registry/default/blocks/analytics-dashboard/app/(dashboard)/dashboard/page.tsx`

```tsx
import type { Metadata } from "next";

import { AcquisitionSection } from "@/components/dashboard/acquisition-section";
import { ConversionFunnel } from "@/components/dashboard/conversion-funnel";
import { CustomersSection } from "@/components/dashboard/customers-section";
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
import { DateRangeProvider } from "@/components/dashboard/date-range-context";
import { MetricOverview } from "@/components/dashboard/metric-overview";
import { QuickInsight } from "@/components/dashboard/quick-insight";
import { RecentActivity } from "@/components/dashboard/recent-activity";
import { RetentionSection } from "@/components/dashboard/retention-section";
import { RevenueSection } from "@/components/dashboard/revenue-section";

export const metadata: Metadata = { title: "Overview" };

export default function DashboardPage() {
  return (
    <DateRangeProvider>
      <div className="mx-auto flex w-full max-w-7xl flex-col gap-10 px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
        <DashboardHeader />
        <MetricOverview />
        <RevenueSection />
        <div className="grid gap-10 lg:grid-cols-2 lg:gap-12">
          <AcquisitionSection />
          <ConversionFunnel />
        </div>
        <RetentionSection />
        <CustomersSection />
        <div className="grid gap-10 lg:grid-cols-[1fr_minmax(0,20rem)] lg:gap-12">
          <RecentActivity />
          <QuickInsight />
        </div>
      </div>
    </DateRangeProvider>
  );
}
```

### `registry/default/blocks/analytics-dashboard/app/(dashboard)/layout.tsx`

```tsx
import type { ReactNode } from "react";

import { AppSidebar } from "@/components/dashboard/app-sidebar";
import { MobileHeader } from "@/components/dashboard/mobile-header";

/**
 * Applies the stored (or system) theme before the dashboard paints so there
 * is no flash of the wrong theme. The theme lives in `data-theme` for
 * HonestUI tokens and the `dark` class for Tailwind variants and chart
 * theming. If your root layout already does this, remove the script here —
 * and either way, add `suppressHydrationWarning` to `<html>` in your root
 * layout since the theme is applied on the client.
 */
const themeInitScript = `(function () {
  try {
    var theme = localStorage.getItem("theme");
    if (theme !== "light" && theme !== "dark") {
      theme = window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark"
        : "light";
    }
    var root = document.documentElement;
    root.dataset.theme = theme;
    root.classList.toggle("dark", theme === "dark");
    root.style.colorScheme = theme;
  } catch (_) {}
})();`;

export default function AppLayout({ children }: { children: ReactNode }) {
  return (
    <div className="flex min-h-dvh">
      <script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
      <AppSidebar />
      <div className="flex min-w-0 flex-1 flex-col">
        <MobileHeader />
        <main className="flex-1">{children}</main>
      </div>
    </div>
  );
}
```

### `registry/default/blocks/analytics-dashboard/app/(dashboard)/reports/page.tsx`

```tsx
import type { Metadata } from "next";

import { PagePlaceholder } from "@/components/dashboard/page-placeholder";

export const metadata: Metadata = { title: "Reports" };

export default function ReportsPage() {
  return <PagePlaceholder title="Reports" />;
}
```

### `registry/default/blocks/analytics-dashboard/app/(dashboard)/revenue/page.tsx`

```tsx
import type { Metadata } from "next";

import { PagePlaceholder } from "@/components/dashboard/page-placeholder";

export const metadata: Metadata = { title: "Revenue" };

export default function RevenuePage() {
  return <PagePlaceholder title="Revenue" />;
}
```

### `registry/default/blocks/analytics-dashboard/app/(dashboard)/settings/page.tsx`

```tsx
import type { Metadata } from "next";

import { PagePlaceholder } from "@/components/dashboard/page-placeholder";

export const metadata: Metadata = { title: "Settings" };

export default function SettingsPage() {
  return <PagePlaceholder title="Settings" />;
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/account-menu.tsx`

```tsx
"use client";

import { ChevronsUpDown } from "honestui/icons";

import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

/** Compact account area pinned to the bottom of the sidebar. */
export function AccountMenu() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        render={
          <button
            type="button"
            className="flex w-full items-center gap-2.5 rounded-md p-2 text-left transition-colors hover:bg-sidebar-accent focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring"
          />
        }
      >
        <Avatar size="5" color="indigo">
          <AvatarFallback>CO</AvatarFallback>
        </Avatar>
        <span className="flex min-w-0 flex-1 flex-col">
          <span className="truncate text-sm font-medium text-sidebar-foreground">
            Connor
          </span>
          <span className="truncate text-xs text-muted-foreground">
            Northstar HQ
          </span>
        </span>
        <ChevronsUpDown
          aria-hidden
          className="size-4 shrink-0 text-muted-foreground"
        />
      </DropdownMenuTrigger>
      <DropdownMenuContent side="top" align="start" className="w-56">
        <DropdownMenuGroup>
          <DropdownMenuLabel>
            <span className="flex flex-col">
              Connor
              <span className="text-xs font-normal text-muted-foreground">
                connor@northstar.app
              </span>
            </span>
          </DropdownMenuLabel>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuItem
          render={<a href="https://honestui.com/docs" target="_blank" rel="noreferrer" />}
        >
          HonestUI docs
        </DropdownMenuItem>
        <DropdownMenuItem
          render={
            <a
              href="https://github.com/honestui/honestui"
              target="_blank"
              rel="noreferrer"
            />
          }
        >
          GitHub repository
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/acquisition-section.tsx`

```tsx
"use client";

import { BarChart, type ChartConfig } from "honestui/charts";

import { acquisitionByRange } from "@/lib/dashboard/data";
import {
  formatCompactNumber,
  formatNumber,
  formatPercent,
} from "@/lib/dashboard/format";
import { useDateRange } from "./date-range-context";

const chartConfig = {
  sessions: {
    label: "Sessions",
    colors: {
      light: ["var(--hui-color-viz-blue-9)"],
      dark: ["var(--hui-color-viz-blue-9)"],
    },
  },
} satisfies ChartConfig;

export function AcquisitionSection() {
  const { rangeKey } = useDateRange();
  const channels = acquisitionByRange[rangeKey];

  return (
    <section aria-label="Acquisition" className="flex min-w-0 flex-col">
      <h2 className="text-base font-semibold">Acquisition</h2>
      <p className="mt-0.5 text-sm text-muted-foreground">
        Sessions by channel over the selected period
      </p>

      <div className="mt-5 h-56 w-full">
        <BarChart
          data={channels}
          config={chartConfig}
          xDataKey="channel"
          layout="horizontal"
          ariaLabel="Sessions by acquisition channel"
          className="h-full w-full"
        >
          <BarChart.YAxis dataKey="channel" />
          <BarChart.XAxis
            tickFormatter={(value) => formatCompactNumber(Number(value))}
          />
          <BarChart.Tooltip />
          <BarChart.Bar dataKey="sessions" />
        </BarChart>
      </div>

      <div className="mt-4 border-t">
        <div
          aria-hidden
          className="grid grid-cols-[1fr_auto_auto] gap-x-6 pt-3 pb-1 text-xs text-muted-foreground"
        >
          <span>Channel</span>
          <span className="w-16 text-right">Conv. rate</span>
          <span className="w-20 text-right">Customers</span>
        </div>
        <ul className="text-sm">
          {channels.map((channel) => (
            <li
              key={channel.channel}
              className="grid grid-cols-[1fr_auto_auto] items-baseline gap-x-6 py-1.5"
            >
              <span className="truncate">{channel.channel}</span>
              <span className="w-16 text-right tabular-nums text-muted-foreground">
                {formatPercent(channel.conversionRate)}
              </span>
              <span className="w-20 text-right tabular-nums">
                {formatNumber(channel.customers)}
              </span>
            </li>
          ))}
        </ul>
      </div>
    </section>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/app-sidebar.tsx`

```tsx
"use client";

import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { PanelLeftClose, PanelLeftOpen } from "honestui/icons";

import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { AccountMenu } from "./account-menu";
import { Brand } from "./brand";
import { SidebarNav } from "./sidebar-nav";
import { ThemeToggle } from "./theme-toggle";

const COLLAPSED_STORAGE_KEY = "sidebar-collapsed";
const COLLAPSED_CHANGE_EVENT = "sidebar-collapsed-change";

function readStoredCollapsed(): boolean {
  try {
    return localStorage.getItem(COLLAPSED_STORAGE_KEY) === "true";
  } catch {
    return false;
  }
}

function writeStoredCollapsed(next: boolean) {
  try {
    localStorage.setItem(COLLAPSED_STORAGE_KEY, String(next));
  } catch {
    // Storage can be unavailable; the toggle still works for this visit
    // through the change event below.
  }
  window.dispatchEvent(new Event(COLLAPSED_CHANGE_EVENT));
}

function subscribeToCollapsed(onChange: () => void) {
  window.addEventListener(COLLAPSED_CHANGE_EVENT, onChange);
  window.addEventListener("storage", onChange);
  return () => {
    window.removeEventListener(COLLAPSED_CHANGE_EVENT, onChange);
    window.removeEventListener("storage", onChange);
  };
}

/**
 * The collapsed preference lives in localStorage so it survives visits.
 * useSyncExternalStore keeps server and client first paint consistent
 * (server snapshot: expanded) without setState-in-effect churn.
 */
function useCollapsed() {
  const collapsed = useSyncExternalStore(
    subscribeToCollapsed,
    readStoredCollapsed,
    () => false,
  );
  const setCollapsed = useCallback(
    (next: boolean) => writeStoredCollapsed(next),
    [],
  );
  return [collapsed, setCollapsed] as const;
}

function SidebarContent({
  collapsed,
  onToggleCollapsed,
}: {
  collapsed: boolean;
  onToggleCollapsed: () => void;
}) {
  return (
    <>
      <div className="flex items-center justify-between px-4 pt-5 pb-4">
        <Brand />
        <div className="flex items-center gap-0.5">
          <ThemeToggle />
          <Button
            variant="ghost"
            size="icon-sm"
            aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
            onClick={onToggleCollapsed}
          >
            {collapsed ? (
              <PanelLeftOpen aria-hidden className="size-4" />
            ) : (
              <PanelLeftClose aria-hidden className="size-4" />
            )}
          </Button>
        </div>
      </div>
      <div className="flex flex-1 flex-col overflow-y-auto px-2.5 pb-4">
        <SidebarNav />
      </div>
      <div className="px-2.5 pb-3">
        <Separator variant="secondary" className="mb-3" />
        <AccountMenu />
      </div>
    </>
  );
}

/**
 * Desktop sidebar — hidden below lg, where MobileHeader takes over.
 *
 * It can be collapsed out of the layout. While collapsed, moving the cursor
 * to the left edge of the screen (or tabbing into the sidebar) peeks it in
 * as an overlay; moving away hides it again. Expanding pins it back into
 * the layout. The choice persists across visits.
 */
export function AppSidebar() {
  const [collapsed, setCollapsed] = useCollapsed();
  const [peeking, setPeeking] = useState(false);
  const hideTimer = useRef<number | null>(null);

  useEffect(() => {
    return () => {
      if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
    };
  }, []);

  function persistCollapsed(next: boolean) {
    setCollapsed(next);
    setPeeking(false);
  }

  function showPeek() {
    if (hideTimer.current !== null) {
      window.clearTimeout(hideTimer.current);
      hideTimer.current = null;
    }
    setPeeking(true);
  }

  // A short delay keeps the overlay from flickering when the cursor briefly
  // crosses its edge.
  function hidePeek() {
    if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
    hideTimer.current = window.setTimeout(() => setPeeking(false), 150);
  }

  if (!collapsed) {
    return (
      <aside className="sticky top-0 hidden h-dvh w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar lg:flex">
        <SidebarContent
          collapsed={false}
          onToggleCollapsed={() => persistCollapsed(true)}
        />
      </aside>
    );
  }

  return (
    <div className="hidden lg:block">
      {/* Hover target along the left edge of the screen. */}
      <div
        aria-hidden
        className="fixed inset-y-0 left-0 z-40 w-2"
        onMouseEnter={showPeek}
      />
      <aside
        className={cn(
          "fixed inset-y-0 left-0 z-40 flex w-60 -translate-x-full flex-col border-r border-sidebar-border bg-sidebar transition-transform duration-200 ease-out motion-reduce:transition-none",
          peeking && "translate-x-0 shadow-lg",
        )}
        onMouseEnter={showPeek}
        onMouseLeave={hidePeek}
        onFocusCapture={showPeek}
        onBlurCapture={hidePeek}
      >
        <SidebarContent
          collapsed
          onToggleCollapsed={() => persistCollapsed(false)}
        />
      </aside>
    </div>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/brand.tsx`

```tsx
import Link from "next/link";

import { cn } from "@/lib/utils";

/** Compact Northstar wordmark used in the sidebar and mobile header. */
export function Brand({ className }: { className?: string }) {
  return (
    <Link
      href="/dashboard"
      className={cn(
        "flex items-center gap-2 rounded-md font-medium text-foreground",
        className,
      )}
    >
      <span
        aria-hidden
        className="flex size-6 items-center justify-center rounded-md bg-primary text-sm font-semibold text-primary-foreground"
      >
        N
      </span>
      Northstar
    </Link>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/chart-states.tsx`

```tsx
import { Button } from "@/components/ui/button";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty";
import { cn } from "@/lib/utils";

/** Shown in place of a chart when the selected period has no data. */
export function ChartEmptyState({ className }: { className?: string }) {
  return (
    <Empty className={cn("h-full justify-center", className)}>
      <EmptyHeader>
        <EmptyTitle>No data for this period</EmptyTitle>
        <EmptyDescription>
          Try a different date range to see activity.
        </EmptyDescription>
      </EmptyHeader>
    </Empty>
  );
}

/**
 * Shown in place of a chart when its data fails to load. The starter's mock
 * data never fails; wire `onRetry` to your refetch when real data arrives.
 */
export function ChartErrorState({
  message,
  onRetry,
  className,
}: {
  message: string;
  onRetry?: () => void;
  className?: string;
}) {
  return (
    <Empty className={cn("h-full justify-center", className)}>
      <EmptyHeader>
        <EmptyTitle>{message}</EmptyTitle>
        <EmptyDescription>
          Check your connection, then load the chart again.
        </EmptyDescription>
      </EmptyHeader>
      {onRetry && (
        <Button variant="outline" size="sm" onClick={onRetry}>
          Try again
        </Button>
      )}
    </Empty>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/conversion-funnel.tsx`

```tsx
import { funnelStages } from "@/lib/dashboard/data";
import { formatNumber, formatPercent } from "@/lib/dashboard/format";

/**
 * Funnel composed from primitives — HonestUI has no funnel chart, so bars
 * share one scale (percent of visitors) and each stage shows its conversion
 * from the previous one.
 */
export function ConversionFunnel() {
  const max = funnelStages[0].value;

  return (
    <section aria-label="Conversion" className="flex min-w-0 flex-col">
      <h2 className="text-base font-semibold">Conversion</h2>
      <p className="mt-0.5 text-sm text-muted-foreground">
        Visitor-to-customer funnel across the full journey
      </p>

      <ol className="mt-5 flex flex-1 flex-col justify-between gap-4">
        {funnelStages.map((stage, index) => {
          const previous = index > 0 ? funnelStages[index - 1].value : null;
          const stepRate = previous ? (stage.value / previous) * 100 : null;

          return (
            <li key={stage.stage}>
              <div className="flex items-baseline justify-between gap-4 text-sm">
                <span>{stage.stage}</span>
                <span className="flex items-baseline gap-2 tabular-nums">
                  {stepRate !== null && (
                    <span className="text-xs text-muted-foreground">
                      {formatPercent(stepRate)} of previous
                    </span>
                  )}
                  <span className="w-16 text-right font-medium">
                    {formatNumber(stage.value)}
                  </span>
                </span>
              </div>
              <div
                role="presentation"
                className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-muted"
              >
                <div
                  className="h-full rounded-full bg-[var(--hui-color-background-accent-emphasis)]"
                  style={{ width: `${Math.max((stage.value / max) * 100, 1.5)}%` }}
                />
              </div>
            </li>
          );
        })}
      </ol>

      <p className="mt-4 text-xs text-muted-foreground">
        {formatPercent((funnelStages.at(-1)!.value / max) * 100, 1)} of visitors
        become paying customers end to end.
      </p>
    </section>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/customers-section.tsx`

```tsx
"use client";

import { useEffect, useState } from "react";
import type { ColumnDef, VisibilityState } from "@tanstack/react-table";

import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { DataTable } from "@/components/ui/data-table/data-table";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty";
import { customers, healthStatuses, planNames } from "@/lib/dashboard/data";
import { formatCurrency, formatNumber } from "@/lib/dashboard/format";
import type { Customer, HealthStatus } from "@/lib/dashboard/types";

const healthBadgeVariant: Record<
  HealthStatus,
  "success" | "warning" | "error"
> = {
  Healthy: "success",
  Watch: "warning",
  "At risk": "error",
};

const avatarColors = ["indigo", "sky", "mint", "gold", "iris"] as const;

function avatarColor(name: string) {
  let hash = 0;
  for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 997;
  return avatarColors[hash % avatarColors.length];
}

const columns: ColumnDef<Customer>[] = [
  {
    accessorKey: "name",
    header: "Customer",
    enableSorting: false,
    enableHiding: false,
    cell: ({ row }) => (
      <span className="flex items-center gap-2.5">
        <Avatar size="4" shape="rounded" color={avatarColor(row.original.name)}>
          <AvatarFallback>{row.original.name.charAt(0)}</AvatarFallback>
        </Avatar>
        <span className="font-medium">{row.original.name}</span>
      </span>
    ),
  },
  {
    accessorKey: "plan",
    header: "Plan",
    enableSorting: false,
  },
  {
    accessorKey: "mrr",
    header: "MRR",
    meta: { align: "right" },
    cell: ({ row }) => (
      <span className="tabular-nums">{formatCurrency(row.original.mrr)}</span>
    ),
  },
  {
    accessorKey: "seats",
    header: "Seats",
    enableSorting: false,
    meta: { align: "right" },
    cell: ({ row }) => (
      <span className="tabular-nums">{formatNumber(row.original.seats)}</span>
    ),
  },
  {
    accessorKey: "health",
    header: "Health",
    enableSorting: false,
    cell: ({ row }) => (
      <Badge variant={healthBadgeVariant[row.original.health]} size="sm">
        {row.original.health}
      </Badge>
    ),
  },
  {
    accessorKey: "lastActive",
    header: "Last active",
    enableSorting: false,
    cell: ({ row }) => (
      <span className="text-muted-foreground">{row.original.lastActive}</span>
    ),
  },
];

export function CustomersSection() {
  // Below md, drop the secondary columns instead of cramming six columns
  // into a phone viewport. The toolbar's view menu can bring them back.
  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});

  useEffect(() => {
    const query = window.matchMedia("(max-width: 767px)");
    const apply = () =>
      setColumnVisibility(
        query.matches ? { seats: false, lastActive: false } : {},
      );
    apply();
    query.addEventListener("change", apply);
    return () => query.removeEventListener("change", apply);
  }, []);

  return (
    <section aria-label="Customers">
      <h2 className="text-base font-semibold">Customers</h2>
      <p className="mt-0.5 text-sm text-muted-foreground">
        Accounts ranked by recurring revenue
      </p>

      <div className="mt-5">
        <DataTable
          columns={columns}
          data={customers}
          caption="Customer accounts"
          search={{ placeholder: "Search customers..." }}
          filters={[
            {
              columnId: "plan",
              title: "Plan",
              options: planNames.map((plan) => ({ label: plan, value: plan })),
            },
            {
              columnId: "health",
              title: "Health",
              options: healthStatuses.map((health) => ({
                label: health,
                value: health,
              })),
            },
          ]}
          pagination={{ pageSizeOptions: [10, 20] }}
          columnVisibility={columnVisibility}
          onColumnVisibilityChange={setColumnVisibility}
          getRowId={(customer) => customer.id}
          getRowLabel={(row) => row.original.name}
          noResultsState={
            <Empty className="p-10">
              <EmptyHeader>
                <EmptyTitle>No customers found</EmptyTitle>
                <EmptyDescription>
                  Try changing your filters or search.
                </EmptyDescription>
              </EmptyHeader>
            </Empty>
          }
        />
      </div>
    </section>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/dashboard-header.tsx`

```tsx
"use client";

import { Calendar, Download } from "honestui/icons";

import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  dateRangeOptions,
  getRevenueSeries,
  revenueMetricOptions,
} from "@/lib/dashboard/data";
import type { DateRangeKey } from "@/lib/dashboard/types";
import { useDateRange } from "./date-range-context";

/** Builds a CSV of every revenue series for the selected range. */
function buildRevenueCsv(rangeKey: DateRangeKey): string {
  const header = [
    "date",
    ...revenueMetricOptions.flatMap((option) => [
      `${option.key}_current`,
      `${option.key}_previous`,
    ]),
  ];
  const series = revenueMetricOptions.map((option) =>
    getRevenueSeries(rangeKey, option.key),
  );
  const rows = series[0].map((point, index) => [
    point.date,
    ...series.flatMap((s) => [s[index].current, s[index].previous]),
  ]);
  return [header, ...rows].map((row) => row.join(",")).join("\n");
}

export function DashboardHeader() {
  const { rangeKey, setRangeKey } = useDateRange();

  function exportCsv() {
    const blob = new Blob([buildRevenueCsv(rangeKey)], {
      type: "text/csv;charset=utf-8",
    });
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = url;
    link.download = `northstar-revenue-${rangeKey}.csv`;
    link.click();
    URL.revokeObjectURL(url);
  }

  return (
    <header className="flex flex-wrap items-start justify-between gap-x-6 gap-y-4">
      <div>
        <h1 className="text-2xl font-semibold tracking-tight">Overview</h1>
        <p className="mt-1 text-sm text-muted-foreground">
          Monitor revenue, customers, and product activity.
        </p>
      </div>
      <div className="flex flex-wrap items-center gap-2">
        <Select
          value={rangeKey}
          items={Object.fromEntries(
            dateRangeOptions.map((option) => [option.key, option.label]),
          )}
          onValueChange={(value) => {
            if (value) setRangeKey(value as DateRangeKey);
          }}
        >
          <SelectTrigger size="small" aria-label="Date range">
            <Calendar aria-hidden className="size-4 text-muted-foreground" />
            <SelectValue placeholder="Date range" />
          </SelectTrigger>
          <SelectContent align="end">
            {dateRangeOptions.map((option) => (
              <SelectItem key={option.key} value={option.key}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <Button variant="outline" size="sm" onClick={exportCsv}>
          <Download aria-hidden className="size-4" />
          Export
        </Button>
      </div>
    </header>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/date-range-context.tsx`

```tsx
"use client";

import {
  createContext,
  useContext,
  useMemo,
  useState,
  type ReactNode,
} from "react";

import { dateRangeOptions, defaultDateRangeKey } from "@/lib/dashboard/data";
import type { DateRangeKey, DateRangeOption } from "@/lib/dashboard/types";

interface DateRangeContextValue {
  rangeKey: DateRangeKey;
  setRangeKey: (key: DateRangeKey) => void;
  range: DateRangeOption;
}

const DateRangeContext = createContext<DateRangeContextValue | null>(null);

/** Shares the selected date range across the dashboard sections. */
export function DateRangeProvider({ children }: { children: ReactNode }) {
  const [rangeKey, setRangeKey] = useState<DateRangeKey>(defaultDateRangeKey);

  const value = useMemo<DateRangeContextValue>(
    () => ({
      rangeKey,
      setRangeKey,
      range:
        dateRangeOptions.find((option) => option.key === rangeKey) ??
        dateRangeOptions[1],
    }),
    [rangeKey],
  );

  return (
    <DateRangeContext.Provider value={value}>
      {children}
    </DateRangeContext.Provider>
  );
}

export function useDateRange(): DateRangeContextValue {
  const context = useContext(DateRangeContext);
  if (!context) {
    throw new Error("useDateRange must be used within DateRangeProvider");
  }
  return context;
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/metric-overview.tsx`

```tsx
"use client";

import { TrendingDown, TrendingUp } from "honestui/icons";

import { cn } from "@/lib/utils";
import { metricsByRange } from "@/lib/dashboard/data";
import { useDateRange } from "./date-range-context";

/**
 * The four headline metrics. Sentiment drives color, not the sign — a falling
 * churn rate renders as good news.
 */
export function MetricOverview() {
  const { rangeKey, range } = useDateRange();
  const metrics = metricsByRange[rangeKey];

  return (
    <section aria-label="Key metrics">
      <dl className="grid grid-cols-2 gap-x-6 gap-y-8 lg:grid-cols-4 lg:divide-x lg:divide-border">
        {metrics.map((metric) => {
          const falling = metric.change.startsWith("-");
          const TrendIcon = falling ? TrendingDown : TrendingUp;

          return (
            <div key={metric.id} className="min-w-0 lg:px-6 lg:first:pl-0 lg:last:pr-0">
              <dt className="text-sm text-muted-foreground">{metric.label}</dt>
              <dd className="mt-1.5">
                <span className="text-2xl font-semibold tracking-tight tabular-nums">
                  {metric.value}
                </span>
                <span className="mt-1 flex flex-wrap items-baseline gap-x-1.5 text-xs">
                  <span
                    className={cn(
                      "inline-flex items-center gap-1 font-medium tabular-nums",
                      metric.sentiment === "positive"
                        ? "text-[var(--hui-color-foreground-success-primary)]"
                        : "text-[var(--hui-color-foreground-danger-primary)]",
                    )}
                  >
                    <TrendIcon aria-hidden className="size-3.5" />
                    {metric.change}
                  </span>
                  <span className="text-muted-foreground">
                    vs. {range.comparisonLabel}
                  </span>
                </span>
              </dd>
            </div>
          );
        })}
      </dl>
    </section>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/mobile-header.tsx`

```tsx
"use client";

import { useState } from "react";
import { Menu } from "honestui/icons";

import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet";
import { AccountMenu } from "./account-menu";
import { Brand } from "./brand";
import { SidebarNav } from "./sidebar-nav";
import { ThemeToggle } from "./theme-toggle";

/** Compact top bar with sheet navigation — shown below lg. */
export function MobileHeader() {
  const [open, setOpen] = useState(false);

  return (
    <header className="sticky top-0 z-40 flex items-center gap-2 border-b bg-background px-4 py-2.5 lg:hidden">
      <Sheet open={open} onOpenChange={setOpen}>
        <SheetTrigger
          render={
            <Button variant="ghost" size="icon-sm" aria-label="Open navigation" />
          }
        >
          <Menu aria-hidden className="size-4" />
        </SheetTrigger>
        <SheetContent side="left" className="w-72 p-0">
          <SheetHeader className="px-4 pt-4 pb-2">
            <SheetTitle>
              <Brand />
            </SheetTitle>
            <SheetDescription className="sr-only">
              Main navigation
            </SheetDescription>
          </SheetHeader>
          <div className="flex flex-1 flex-col overflow-y-auto px-2.5 pb-4">
            <SidebarNav onNavigate={() => setOpen(false)} />
          </div>
          <div className="px-2.5 pb-3">
            <Separator variant="secondary" className="mb-3" />
            <AccountMenu />
          </div>
        </SheetContent>
      </Sheet>
      <Brand />
      <div className="flex-1" />
      <ThemeToggle />
    </header>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/nav.ts`

```ts
import type { ComponentType } from "react";
import {
  ChartLine,
  FileText,
  LayoutDashboard,
  LifeBuoy,
  Settings,
  Users,
  Wallet,
} from "honestui/icons";

export interface NavItem {
  href: string;
  label: string;
  icon: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
  external?: boolean;
}

export const primaryNav: NavItem[] = [
  { href: "/dashboard", label: "Overview", icon: LayoutDashboard },
  { href: "/analytics", label: "Analytics", icon: ChartLine },
  { href: "/customers", label: "Customers", icon: Users },
  { href: "/revenue", label: "Revenue", icon: Wallet },
  { href: "/reports", label: "Reports", icon: FileText },
];

export const secondaryNav: NavItem[] = [
  { href: "/settings", label: "Settings", icon: Settings },
  {
    href: "https://honestui.com/docs",
    label: "Help",
    icon: LifeBuoy,
    external: true,
  },
];
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/page-placeholder.tsx`

```tsx
import Link from "next/link";

import { Button } from "@/components/ui/button";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty";

/**
 * Stub for routes the starter intentionally leaves unbuilt. The Overview page
 * is the reference implementation; these keep the navigation honest without
 * shipping half-finished screens.
 */
export function PagePlaceholder({ title }: { title: string }) {
  return (
    <div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
      <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
      <Empty className="mt-24">
        <EmptyHeader>
          <EmptyTitle>This page is yours to build</EmptyTitle>
          <EmptyDescription>
            The starter ships the Overview page as its reference
            implementation. Reuse its sections and data patterns to build out
            {" "}
            {title}.
          </EmptyDescription>
        </EmptyHeader>
        <Button variant="outline" render={<Link href="/dashboard" />}>
          View overview
        </Button>
      </Empty>
    </div>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/quick-insight.tsx`

```tsx
import { TrendingUp } from "honestui/icons";

import { Card, CardHeader, CardPanel, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { quickInsight } from "@/lib/dashboard/data";
import { formatCurrency } from "@/lib/dashboard/format";

export function QuickInsight() {
  return (
    <section aria-label="Insight" className="flex min-w-0">
      <Card variant="soft" className="w-full gap-2.5 py-5">
        <CardHeader className="px-5">
          <CardTitle className="flex items-center gap-2 text-sm font-semibold">
            <TrendingUp
              aria-hidden
              className="size-4 shrink-0 text-[var(--hui-color-foreground-success-primary)]"
            />
            {quickInsight.headline}
          </CardTitle>
        </CardHeader>
        <CardPanel className="flex flex-1 flex-col px-5">
          <p className="text-sm leading-6 text-muted-foreground">
            {quickInsight.detail}
          </p>
          <Separator variant="secondary" className="mt-4 mb-1" />
          <ul className="flex flex-1 flex-col justify-evenly text-sm">
            {quickInsight.breakdown.map((item) => (
              <li
                key={item.label}
                className="flex items-baseline justify-between gap-4 py-1.5"
              >
                <span className="text-muted-foreground">{item.label}</span>
                <span className="font-medium tabular-nums">
                  {formatCurrency(item.mrr)}
                </span>
              </li>
            ))}
          </ul>
        </CardPanel>
      </Card>
    </section>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/recent-activity.tsx`

```tsx
import type { ComponentType } from "react";
import {
  ArrowDownRight,
  ArrowUpRight,
  CirclePlus,
  RefreshCw,
  UserPlus,
} from "honestui/icons";

import { recentActivity } from "@/lib/dashboard/data";
import type { ActivityKind } from "@/lib/dashboard/types";

type IconComponent = ComponentType<{
  className?: string;
  "aria-hidden"?: boolean;
}>;

const kindIcons: Record<ActivityKind, IconComponent> =
  {
    upgrade: ArrowUpRight,
    expansion: CirclePlus,
    renewal: RefreshCw,
    contraction: ArrowDownRight,
    trial: UserPlus,
  };

export function RecentActivity() {
  return (
    <section aria-label="Recent activity" className="min-w-0">
      <h2 className="text-base font-semibold">Recent activity</h2>
      <p className="mt-0.5 text-sm text-muted-foreground">
        Latest account events across the workspace
      </p>

      <ul className="mt-4 divide-y divide-border">
        {recentActivity.map((item) => {
          const Icon = kindIcons[item.kind];
          return (
            <li key={item.id} className="flex items-center gap-3 py-2.5">
              <Icon
                aria-hidden
                className="size-4 shrink-0 text-muted-foreground"
              />
              <p className="min-w-0 flex-1 truncate text-sm">
                <span className="font-medium">{item.customer}</span>{" "}
                <span className="text-muted-foreground">
                  {item.event.charAt(0).toLowerCase() + item.event.slice(1)}
                </span>
              </p>
              <span className="shrink-0 text-xs text-muted-foreground">
                {item.time}
              </span>
            </li>
          );
        })}
      </ul>
    </section>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/retention-section.tsx`

```tsx
"use client";

import { useEffect, useState } from "react";
import { Heatmap, type ChartConfig } from "honestui/charts";

import { retentionCohorts } from "@/lib/dashboard/data";
import { formatPercent } from "@/lib/dashboard/format";

const chartConfig = {
  retention: {
    label: "Retention",
    colors: {
      light: [
        "var(--hui-color-viz-iris-6)",
        "var(--hui-color-viz-iris-8)",
        "var(--hui-color-viz-iris-9)",
        "var(--hui-color-viz-iris-11)",
      ],
      dark: [
        "var(--hui-color-viz-iris-6)",
        "var(--hui-color-viz-iris-8)",
        "var(--hui-color-viz-iris-9)",
        "var(--hui-color-viz-iris-11)",
      ],
    },
  },
} satisfies ChartConfig;

export function RetentionSection() {
  // In-cell percentages get cramped on phones; the tooltip still carries the
  // exact values there.
  const [showValues, setShowValues] = useState(true);

  useEffect(() => {
    const query = window.matchMedia("(min-width: 640px)");
    const apply = () => setShowValues(query.matches);
    apply();
    query.addEventListener("change", apply);
    return () => query.removeEventListener("change", apply);
  }, []);

  return (
    <section aria-label="Retention">
      <h2 className="text-base font-semibold">Retention</h2>
      <p className="mt-0.5 text-sm text-muted-foreground">
        Percentage of each signup cohort still active in subsequent weeks.
      </p>

      <div className="mt-5 h-80 w-full">
        <Heatmap
          data={retentionCohorts}
          config={chartConfig}
          xDataKey="week"
          yDataKey="cohort"
          valueDataKey="retention"
          min={50}
          max={100}
          ariaLabel="Weekly retention by signup cohort, week zero through week eight"
          className="h-full w-full"
        >
          <Heatmap.XAxis />
          <Heatmap.YAxis inverse />
          <Heatmap.Legend
            minLabel="50%"
            maxLabel="100%"
            valueFormatter={(value) => formatPercent(value, 0)}
          />
          <Heatmap.Tooltip
            valueFormatter={(value) => `${formatPercent(value)} retained`}
          />
          <Heatmap.Cells
            showValues={showValues}
            valueFormatter={(value) => formatPercent(value, 0)}
          />
        </Heatmap>
      </div>
    </section>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/revenue-section.tsx`

```tsx
"use client";

import { useState } from "react";
import { AreaChart, type ChartConfig } from "honestui/charts";

import { Toggle, ToggleGroup } from "@/components/ui/toggle-group";
import { getRevenueSeries, revenueMetricOptions } from "@/lib/dashboard/data";
import { formatCompactCurrency } from "@/lib/dashboard/format";
import type { RevenueMetricKey } from "@/lib/dashboard/types";
import { ChartEmptyState } from "./chart-states";
import { useDateRange } from "./date-range-context";

const chartConfig = {
  current: {
    label: "This period",
    colors: {
      light: ["var(--hui-color-viz-iris-9)"],
      dark: ["var(--hui-color-viz-iris-9)"],
    },
  },
  // The comparison series reads as a reference line, so it uses the muted
  // foreground token instead of a second data color.
  previous: {
    label: "Previous period",
    colors: {
      light: ["var(--hui-color-foreground-base-tertiary)"],
      dark: ["var(--hui-color-foreground-base-tertiary)"],
    },
  },
} satisfies ChartConfig;

export function RevenueSection() {
  const { rangeKey, range } = useDateRange();
  const [metric, setMetric] = useState<RevenueMetricKey>("revenue");

  const series = getRevenueSeries(rangeKey, metric);
  const metricLabel =
    revenueMetricOptions.find((option) => option.key === metric)?.label ??
    "Revenue";

  return (
    <section aria-label="Revenue">
      <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
        <div>
          <h2 className="text-base font-semibold">Revenue</h2>
          <p className="mt-0.5 text-sm text-muted-foreground">
            Recurring revenue over the selected period
          </p>
        </div>
        <ToggleGroup
          aria-label="Revenue metric"
          value={[metric]}
          onValueChange={(groupValue: unknown[]) => {
            const next = groupValue[0];
            if (typeof next === "string") {
              setMetric(next as RevenueMetricKey);
            }
          }}
        >
          {revenueMetricOptions.map((option) => (
            <Toggle
              key={option.key}
              value={option.key}
              className="px-1"
              aria-label={option.label}
            >
              <span className="px-2">{option.label}</span>
            </Toggle>
          ))}
        </ToggleGroup>
      </div>

      <div className="mt-5 h-72 w-full sm:h-80">
        {series.length === 0 ? (
          <ChartEmptyState />
        ) : (
          <AreaChart
            data={series}
            config={chartConfig}
            xDataKey="label"
            curveType="monotone"
            ariaLabel={`${metricLabel} for the ${range.label.toLowerCase()} compared with the ${range.comparisonLabel}`}
            className="h-full w-full"
          >
            <AreaChart.Grid />
            <AreaChart.XAxis dataKey="label" />
            <AreaChart.YAxis
              tickFormatter={(value) => formatCompactCurrency(value)}
            />
            <AreaChart.Tooltip />
            <AreaChart.Legend align="right" verticalAlign="top" />
            <AreaChart.Area
              dataKey="previous"
              variant="none"
              strokeVariant="dashed"
            />
            <AreaChart.Area
              dataKey="current"
              variant="gradient"
              strokeVariant="solid"
            />
          </AreaChart>
        )}
      </div>
    </section>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/sidebar-nav.tsx`

```tsx
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";

import { cn } from "@/lib/utils";
import { primaryNav, secondaryNav, type NavItem } from "./nav";

function NavLink({
  item,
  onNavigate,
}: {
  item: NavItem;
  onNavigate?: () => void;
}) {
  const pathname = usePathname();
  const isActive = !item.external && pathname === item.href;
  const Icon = item.icon;

  const className = cn(
    "flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-sidebar-foreground/75 transition-colors",
    "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
    "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring",
    isActive && "bg-sidebar-accent font-medium text-sidebar-accent-foreground",
  );

  if (item.external) {
    return (
      <a
        href={item.href}
        target="_blank"
        rel="noreferrer"
        className={className}
      >
        <Icon aria-hidden className="size-4 shrink-0" />
        {item.label}
      </a>
    );
  }

  return (
    <Link
      href={item.href}
      aria-current={isActive ? "page" : undefined}
      className={className}
      onClick={onNavigate}
    >
      <Icon aria-hidden className="size-4 shrink-0" />
      {item.label}
    </Link>
  );
}

/** Primary + secondary navigation, shared by the sidebar and mobile sheet. */
export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
  return (
    <nav
      aria-label="Main navigation"
      className="flex flex-1 flex-col justify-between gap-8"
    >
      <ul className="flex flex-col gap-0.5">
        {primaryNav.map((item) => (
          <li key={item.href}>
            <NavLink item={item} onNavigate={onNavigate} />
          </li>
        ))}
      </ul>
      <ul className="flex flex-col gap-0.5">
        {secondaryNav.map((item) => (
          <li key={item.href}>
            <NavLink item={item} onNavigate={onNavigate} />
          </li>
        ))}
      </ul>
    </nav>
  );
}
```

### `registry/default/blocks/analytics-dashboard/components/dashboard/theme-toggle.tsx`

```tsx
"use client";

import { Moon, Sun } from "honestui/icons";

import { Button } from "@/components/ui/button";

/**
 * Switches between light and dark by updating `data-theme` (HonestUI tokens)
 * and the `dark` class (Tailwind variants + chart themes) on <html>. The
 * icons swap via CSS so server and client markup always match.
 */
export function ThemeToggle({ className }: { className?: string }) {
  function toggleTheme() {
    const root = document.documentElement;
    const next = root.dataset.theme === "dark" ? "light" : "dark";
    root.dataset.theme = next;
    root.classList.toggle("dark", next === "dark");
    root.style.colorScheme = next;
    try {
      localStorage.setItem("theme", next);
    } catch {
      // Storage can be unavailable (private mode); the toggle still works.
    }
  }

  return (
    <Button
      variant="ghost"
      size="icon-sm"
      aria-label="Toggle theme"
      className={className}
      onClick={toggleTheme}
    >
      <Sun aria-hidden className="size-4 dark:hidden" />
      <Moon aria-hidden className="hidden size-4 dark:block" />
    </Button>
  );
}
```

### `registry/default/blocks/analytics-dashboard/lib/dashboard/data.ts`

```ts
import type {
  AcquisitionChannel,
  ActivityEvent,
  Customer,
  DateRangeKey,
  DateRangeOption,
  FunnelStage,
  Insight,
  Metric,
  RetentionCell,
  RevenueMetricKey,
  RevenueMetricOption,
  RevenuePoint,
} from "./types";

/**
 * All dashboard data is deterministic mock data. Series are generated once at
 * module load from fixed seeds, so values never change between renders.
 * Replace this module with real data access when building on the template.
 */

/** Fixed "today" for the mock data set, so dates are stable. */
const ANCHOR = new Date("2026-08-31T00:00:00");

const DAY = 24 * 60 * 60 * 1000;

/** Small deterministic PRNG (mulberry32). */
function seededRandom(seed: number) {
  let state = seed >>> 0;
  return () => {
    state = (state + 0x6d2b79f5) >>> 0;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function isoDate(date: Date): string {
  return date.toISOString().slice(0, 10);
}

function daysAgo(days: number): Date {
  return new Date(ANCHOR.getTime() - days * DAY);
}

export const dateRangeOptions: DateRangeOption[] = [
  { key: "7d", label: "Last 7 days", comparisonLabel: "previous 7 days" },
  { key: "30d", label: "Last 30 days", comparisonLabel: "previous 30 days" },
  { key: "90d", label: "Last 90 days", comparisonLabel: "previous 90 days" },
  { key: "ytd", label: "This year", comparisonLabel: "last year" },
];

export const defaultDateRangeKey: DateRangeKey = "30d";

export const revenueMetricOptions: RevenueMetricOption[] = [
  { key: "revenue", label: "Revenue" },
  { key: "new-mrr", label: "New MRR" },
  { key: "expansion", label: "Expansion" },
  { key: "churned", label: "Churned" },
];

/* ------------------------------------------------------------------ */
/* Metrics                                                             */
/* ------------------------------------------------------------------ */

function buildMetrics(
  mrrChange: string,
  customersChange: string,
  nrr: string,
  nrrChange: string,
  churn: string,
  churnChange: string,
): Metric[] {
  return [
    {
      id: "mrr",
      label: "Monthly recurring revenue",
      value: "$84,240",
      change: mrrChange,
      sentiment: "positive",
    },
    {
      id: "customers",
      label: "Active customers",
      value: "1,842",
      change: customersChange,
      sentiment: "positive",
    },
    {
      id: "nrr",
      label: "Net revenue retention",
      value: nrr,
      change: nrrChange,
      sentiment: "positive",
    },
    // Churn falling is good news — sentiment is not the sign of the number.
    {
      id: "churn",
      label: "Churn",
      value: churn,
      change: churnChange,
      sentiment: "positive",
    },
  ];
}

export const metricsByRange: Record<DateRangeKey, Metric[]> = {
  "7d": buildMetrics("+1.8%", "+0.9%", "107.2%", "+0.4%", "0.6%", "-0.1%"),
  "30d": buildMetrics("+12.4%", "+8.2%", "108.6%", "+2.1%", "2.4%", "-0.6%"),
  "90d": buildMetrics("+28.6%", "+19.4%", "109.4%", "+3.2%", "6.9%", "-1.1%"),
  ytd: buildMetrics("+64.2%", "+47.8%", "110.2%", "+4.7%", "15.8%", "-2.9%"),
};

/* ------------------------------------------------------------------ */
/* Revenue series                                                      */
/* ------------------------------------------------------------------ */

interface RangeShape {
  points: number;
  /** Distance in days between points. */
  stepDays: number;
}

const rangeShapes: Record<DateRangeKey, RangeShape> = {
  "7d": { points: 7, stepDays: 1 },
  "30d": { points: 30, stepDays: 1 },
  "90d": { points: 13, stepDays: 7 },
  ytd: { points: 8, stepDays: 30 },
};

function seriesDates(
  shape: RangeShape,
): Array<{ date: string; label: string }> {
  const monthly = shape.stepDays >= 28;
  const dates: Array<{ date: string; label: string }> = [];
  for (let i = shape.points - 1; i >= 0; i--) {
    const day = daysAgo(i * shape.stepDays);
    dates.push({
      date: isoDate(day),
      label: day.toLocaleDateString("en-US", {
        month: "short",
        ...(monthly ? {} : { day: "numeric" }),
      }),
    });
  }
  return dates;
}

/**
 * Generates a series that trends from `start` to `end` with seeded noise, so
 * the line rises overall but keeps a few believable dips.
 */
function trendSeries(
  seed: number,
  points: number,
  start: number,
  end: number,
  noise: number,
): number[] {
  const random = seededRandom(seed);
  const values: number[] = [];
  for (let i = 0; i < points; i++) {
    const progress = points === 1 ? 1 : i / (points - 1);
    const wobble = (random() - 0.42) * noise;
    const value = start + (end - start) * progress + wobble;
    values.push(Math.round(Math.max(0, value)));
  }
  return values;
}

function buildRevenueSeries(
  seed: number,
  shape: RangeShape,
  current: { start: number; end: number },
  previousScale: number,
  noise: number,
): RevenuePoint[] {
  const dates = seriesDates(shape);
  const currentValues = trendSeries(
    seed,
    shape.points,
    current.start,
    current.end,
    noise,
  );
  const previousValues = trendSeries(
    seed + 1,
    shape.points,
    current.start * previousScale,
    current.end * previousScale,
    noise,
  );
  return dates.map(({ date, label }, i) => ({
    date,
    label,
    current: currentValues[i],
    previous: previousValues[i],
  }));
}

/** Per-day flow rates (new, expansion, churned MRR) used to scale ranges. */
const flowRates: Record<
  Exclude<RevenueMetricKey, "revenue">,
  { start: number; end: number; noise: number }
> = {
  "new-mrr": { start: 380, end: 640, noise: 260 },
  expansion: { start: 150, end: 320, noise: 150 },
  churned: { start: 210, end: 140, noise: 110 },
};

function buildRangeSeries(
  rangeKey: DateRangeKey,
  seed: number,
): Record<RevenueMetricKey, RevenuePoint[]> {
  const shape = rangeShapes[rangeKey];
  // MRR endpoints per range: always ends at ~$84.2k, starts further back the
  // longer the window is.
  const mrrStart: Record<DateRangeKey, number> = {
    "7d": 82760,
    "30d": 74950,
    "90d": 65510,
    ytd: 51300,
  };

  const series = {
    revenue: buildRevenueSeries(
      seed,
      shape,
      { start: mrrStart[rangeKey], end: 84240 },
      0.87,
      rangeKey === "7d" ? 350 : 1400,
    ),
  } as Record<RevenueMetricKey, RevenuePoint[]>;

  for (const key of ["new-mrr", "expansion", "churned"] as const) {
    const rate = flowRates[key];
    series[key] = buildRevenueSeries(
      seed + revenueMetricOptions.findIndex((o) => o.key === key) * 7,
      shape,
      {
        start: rate.start * shape.stepDays,
        end: rate.end * shape.stepDays,
      },
      key === "churned" ? 1.18 : 0.85,
      rate.noise * shape.stepDays,
    );
  }

  return series;
}

const revenueSeries: Record<
  DateRangeKey,
  Record<RevenueMetricKey, RevenuePoint[]>
> = {
  "7d": buildRangeSeries("7d", 11),
  "30d": buildRangeSeries("30d", 23),
  "90d": buildRangeSeries("90d", 37),
  ytd: buildRangeSeries("ytd", 53),
};

export function getRevenueSeries(
  range: DateRangeKey,
  metric: RevenueMetricKey,
): RevenuePoint[] {
  return revenueSeries[range][metric];
}

/* ------------------------------------------------------------------ */
/* Acquisition                                                         */
/* ------------------------------------------------------------------ */

const acquisitionBase: AcquisitionChannel[] = [
  { channel: "Organic search", sessions: 9840, conversionRate: 3.1, customers: 148 },
  { channel: "Direct", sessions: 6420, conversionRate: 2.4, customers: 87 },
  { channel: "Referral", sessions: 3980, conversionRate: 4.2, customers: 96 },
  { channel: "Social", sessions: 2860, conversionRate: 1.1, customers: 22 },
  { channel: "Paid search", sessions: 1720, conversionRate: 3.8, customers: 41 },
];

function scaleAcquisition(
  factor: number,
  conversionShift: number,
  seed: number,
): AcquisitionChannel[] {
  const random = seededRandom(seed);
  return acquisitionBase.map((channel) => {
    const jitter = 0.9 + random() * 0.2;
    const sessions = Math.round((channel.sessions * factor * jitter) / 10) * 10;
    const conversionRate =
      Math.round((channel.conversionRate + conversionShift) * 10) / 10;
    return {
      channel: channel.channel,
      sessions,
      conversionRate,
      customers: Math.round(sessions * (conversionRate / 100)),
    };
  });
}

export const acquisitionByRange: Record<DateRangeKey, AcquisitionChannel[]> = {
  "7d": scaleAcquisition(0.24, -0.2, 71),
  "30d": acquisitionBase,
  "90d": scaleAcquisition(2.9, -0.3, 73),
  ytd: scaleAcquisition(7.6, -0.5, 79),
};

/* ------------------------------------------------------------------ */
/* Conversion funnel                                                   */
/* ------------------------------------------------------------------ */

export const funnelStages: FunnelStage[] = [
  { stage: "Visitors", value: 24820 },
  { stage: "Signed up", value: 4930 },
  { stage: "Activated", value: 3160 },
  { stage: "Started trial", value: 1420 },
  { stage: "Converted", value: 682 },
];

/* ------------------------------------------------------------------ */
/* Retention cohorts                                                   */
/* ------------------------------------------------------------------ */

/** Baseline weekly retention curve, percent of cohort still active. */
const retentionCurve = [100, 78, 69, 64, 61, 59, 57.5, 56.5, 56];

export const retentionWeeks = retentionCurve.map((_, i) => `W${i}`);

function buildRetentionCohorts(): RetentionCell[] {
  const cells: RetentionCell[] = [];
  const cohortCount = 8;
  const random = seededRandom(97);

  for (let c = 0; c < cohortCount; c++) {
    const cohortStart = daysAgo((cohortCount - c + 1) * 7);
    const cohort = cohortStart.toLocaleDateString("en-US", {
      month: "short",
      day: "numeric",
    });
    // Newer cohorts retain slightly better — and have fewer observed weeks.
    const cohortLift = c * 0.9;
    const observedWeeks = Math.min(retentionCurve.length, cohortCount + 1 - c);

    for (let w = 0; w < observedWeeks; w++) {
      const jitter = (random() - 0.5) * 3.4;
      const retention =
        w === 0 ? 100 : Math.min(99, retentionCurve[w] + cohortLift + jitter);
      cells.push({
        cohort,
        week: retentionWeeks[w],
        retention: Math.round(retention * 10) / 10,
      });
    }
  }

  return cells;
}

export const retentionCohorts: RetentionCell[] = buildRetentionCohorts();

/* ------------------------------------------------------------------ */
/* Customers                                                           */
/* ------------------------------------------------------------------ */

export const customers: Customer[] = [
  { id: "linear-labs", name: "Linear Labs", plan: "Enterprise", mrr: 8900, seats: 164, health: "Healthy", lastActive: "12m ago" },
  { id: "atlas-systems", name: "Atlas Systems", plan: "Business", mrr: 3420, seats: 58, health: "Healthy", lastActive: "1h ago" },
  { id: "mercury-works", name: "Mercury Works", plan: "Business", mrr: 2760, seats: 44, health: "Watch", lastActive: "2d ago" },
  { id: "northwind", name: "Northwind", plan: "Business", mrr: 2510, seats: 39, health: "Healthy", lastActive: "36m ago" },
  { id: "acme-studio", name: "Acme Studio", plan: "Pro", mrr: 890, seats: 18, health: "Healthy", lastActive: "3h ago" },
  { id: "vanta-labs", name: "Vanta Labs", plan: "Enterprise", mrr: 6240, seats: 112, health: "Watch", lastActive: "5d ago" },
  { id: "relay", name: "Relay", plan: "Pro", mrr: 740, seats: 15, health: "Healthy", lastActive: "22m ago" },
  { id: "summit", name: "Summit", plan: "Starter", mrr: 240, seats: 6, health: "At risk", lastActive: "3w ago" },
  { id: "fieldstone", name: "Fieldstone", plan: "Pro", mrr: 620, seats: 12, health: "Healthy", lastActive: "1h ago" },
  { id: "cobalt-systems", name: "Cobalt Systems", plan: "Business", mrr: 1980, seats: 31, health: "Healthy", lastActive: "4h ago" },
  { id: "juniper-health", name: "Juniper Health", plan: "Enterprise", mrr: 5480, seats: 96, health: "Healthy", lastActive: "55m ago" },
  { id: "beacon-analytics", name: "Beacon Analytics", plan: "Pro", mrr: 810, seats: 16, health: "Watch", lastActive: "6d ago" },
  { id: "driftwood", name: "Driftwood", plan: "Starter", mrr: 180, seats: 4, health: "Healthy", lastActive: "2h ago" },
  { id: "halcyon-media", name: "Halcyon Media", plan: "Pro", mrr: 560, seats: 11, health: "At risk", lastActive: "2w ago" },
  { id: "orbital", name: "Orbital", plan: "Business", mrr: 2140, seats: 35, health: "Healthy", lastActive: "18m ago" },
  { id: "pinewheel", name: "Pinewheel", plan: "Starter", mrr: 290, seats: 7, health: "Healthy", lastActive: "1d ago" },
  { id: "quartz-digital", name: "Quartz Digital", plan: "Pro", mrr: 980, seats: 21, health: "Healthy", lastActive: "40m ago" },
  { id: "riverbed-labs", name: "Riverbed Labs", plan: "Business", mrr: 1650, seats: 27, health: "Watch", lastActive: "3d ago" },
  { id: "solstice", name: "Solstice", plan: "Starter", mrr: 120, seats: 3, health: "Watch", lastActive: "1w ago" },
  { id: "tandem-hq", name: "Tandem HQ", plan: "Pro", mrr: 720, seats: 14, health: "Healthy", lastActive: "5h ago" },
  { id: "umbra-security", name: "Umbra Security", plan: "Enterprise", mrr: 4120, seats: 73, health: "Healthy", lastActive: "1h ago" },
  { id: "waypoint", name: "Waypoint", plan: "Starter", mrr: 210, seats: 5, health: "At risk", lastActive: "4w ago" },
];

export const planNames = ["Starter", "Pro", "Business", "Enterprise"] as const;
export const healthStatuses = ["Healthy", "Watch", "At risk"] as const;

/* ------------------------------------------------------------------ */
/* Activity + insight                                                  */
/* ------------------------------------------------------------------ */

export const recentActivity: ActivityEvent[] = [
  { id: "a1", kind: "upgrade", event: "Upgraded to Business", customer: "Northwind", time: "2h ago" },
  { id: "a2", kind: "expansion", event: "Added 14 seats", customer: "Atlas Systems", time: "5h ago" },
  { id: "a3", kind: "renewal", event: "Renewed annual plan", customer: "Relay", time: "Yesterday" },
  { id: "a4", kind: "contraction", event: "Cancelled 2 seats", customer: "Mercury Works", time: "Yesterday" },
  { id: "a5", kind: "trial", event: "Started a Pro trial", customer: "Acme Studio", time: "2d ago" },
  { id: "a6", kind: "expansion", event: "Added 6 seats", customer: "Juniper Health", time: "3d ago" },
];

export const quickInsight: Insight = {
  headline: "Expansion revenue is up 18%",
  detail:
    "Business plan upgrades generated $4,280 in expansion MRR this month, the best upgrade month this year.",
  breakdown: [
    { label: "Business", mrr: 4280 },
    { label: "Enterprise", mrr: 1360 },
    { label: "Pro", mrr: 940 },
    { label: "Starter", mrr: 210 },
  ],
};
```

### `registry/default/blocks/analytics-dashboard/lib/dashboard/format.ts`

```ts
const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

const compactCurrency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  notation: "compact",
  maximumFractionDigits: 1,
});

const number = new Intl.NumberFormat("en-US");

const compactNumber = new Intl.NumberFormat("en-US", {
  notation: "compact",
  maximumFractionDigits: 1,
});

/** "$84,240" */
export function formatCurrency(value: number): string {
  return currency.format(value);
}

/** "$84.2K" — for chart axes and tight spots. */
export function formatCompactCurrency(value: number): string {
  return compactCurrency.format(value);
}

/** "24,820" */
export function formatNumber(value: number): string {
  return number.format(value);
}

/** "24.8K" */
export function formatCompactNumber(value: number): string {
  return compactNumber.format(value);
}

/** "2.4%" */
export function formatPercent(value: number, fractionDigits = 1): string {
  return `${value.toFixed(fractionDigits)}%`;
}

/** "Aug 14" from an ISO date string. */
export function formatShortDate(isoDate: string): string {
  const date = new Date(`${isoDate}T00:00:00`);
  return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
```

### `registry/default/blocks/analytics-dashboard/lib/dashboard/types.ts`

```ts
export type DateRangeKey = "7d" | "30d" | "90d" | "ytd";

export interface DateRangeOption {
  key: DateRangeKey;
  label: string;
  /** Label used in "vs." comparison copy, e.g. "previous 30 days". */
  comparisonLabel: string;
}

export type RevenueMetricKey = "revenue" | "new-mrr" | "expansion" | "churned";

export interface RevenueMetricOption {
  key: RevenueMetricKey;
  label: string;
}

/** Type alias (not interface) so it satisfies the chart data constraint. */
export type RevenuePoint = {
  /** ISO date (day the point represents, or period start for aggregates). */
  date: string;
  /** Display label used on the axis and in tooltips, e.g. "Aug 19". */
  label: string;
  current: number;
  previous: number;
};

export type MetricSentiment = "positive" | "negative";

export interface Metric {
  id: string;
  label: string;
  value: string;
  /** Signed change vs. the previous period, e.g. "+12.4%". */
  change: string;
  /** Whether the change is good news — a falling churn rate is positive. */
  sentiment: MetricSentiment;
}

/** Type alias (not interface) so it satisfies the chart data constraint. */
export type AcquisitionChannel = {
  channel: string;
  sessions: number;
  /** Visitor → customer conversion rate, in percent. */
  conversionRate: number;
  customers: number;
};

export interface FunnelStage {
  stage: string;
  value: number;
}

/** Type alias (not interface) so it satisfies the chart data constraint. */
export type RetentionCell = {
  /** Cohort label, e.g. "Jul 6". */
  cohort: string;
  /** Week offset label, e.g. "W3". */
  week: string;
  /** Percentage of the cohort still active, 0–100. */
  retention: number;
};

export type PlanName = "Starter" | "Pro" | "Business" | "Enterprise";

export type HealthStatus = "Healthy" | "Watch" | "At risk";

export interface Customer {
  id: string;
  name: string;
  plan: PlanName;
  mrr: number;
  seats: number;
  health: HealthStatus;
  lastActive: string;
}

export type ActivityKind =
  | "upgrade"
  | "expansion"
  | "renewal"
  | "contraction"
  | "trial";

export interface ActivityEvent {
  id: string;
  kind: ActivityKind;
  event: string;
  customer: string;
  time: string;
}

export interface Insight {
  headline: string;
  detail: string;
  /** Expansion MRR this month by plan, largest first. */
  breakdown: Array<{ label: string; mrr: number }>;
}
```
