Skip to contentVibraUI
Part of the SaaS dashboardinstalls at /saas/retention

Retention dashboard

A retention page: signups and the share still active a week, a month and two months on, the cohort heatmap behind those numbers, the same grid as curves, which plans the survivors sit on, and every cohort's size against its target.

Open the live page

The page is a server component inside AppShell: it reads db.cohorts once and hands plain rows to the islands that draw them. A cohort row is a week of signups and the share of it still active week by week, and the array stops where the calendar does — so a week a cohort has not lived through yet is absent rather than reported as a loss, the heatmap draws those cells as "no data", and the curves end where the readings do. The headline shares are means across the cohorts that actually reached each week, never across all twelve. The bubbles are the one thing the cohort rows cannot answer — a row records a count and a curve, not who was in it — so the segments are read off the live accounts in db.customers instead, which is the only honest join between the two. The signup target is a rule over the rows rather than a literal: the mean week rounded up to the nearest ten, which is what a goal set from last quarter looks like. Composes AppShell, PageHeader, StatCardGroup, StatCard, DashboardGrid, Widget, HeatmapGrid, ChartCard, LineChart, BubbleChart and BarChart.

Preview

Install

npx shadcn@latest add @vibra/saas-retention

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

Source

app/saas/retention/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 { CohortHeatmap } from "./components/cohort-heatmap"
import { RetentionCurves } from "./components/retention-curves"
import { RetentionStats } from "./components/retention-stats"
import { SegmentBubbles } from "./components/segment-bubbles"
import { WeeklySignups } from "./components/weekly-signups"
import {
  cohortGrid,
  currentUser,
  lastUpdated,
  retentionCurves,
  retentionStats,
  segments,
  shellNotifications,
  signupsByWeek,
  signupTarget,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/saas/nav"

/**
 * Retention. A server component: it reads the cohorts through `db` once and
 * hands plain rows to the client islands that draw them, so the data layer
 * never has to cross into the browser bundle.
 */
export default function RetentionPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.retention}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Retention"
        description="Who came back, week by week, and where each cohort settles."
        meta={lastUpdated()}
      />

      <RetentionStats stats={retentionStats()} />

      <DashboardGrid>
        {/* The grid is 12 cohorts by 9 weeks — a portrait shape — so it takes
            the wider column and the bubbles fill the height beside it. */}
        <DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
          <CohortHeatmap grid={cohortGrid()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <SegmentBubbles segments={segments()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <RetentionCurves curves={retentionCurves()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
          <WeeklySignups weeks={signupsByWeek()} target={signupTarget()} />
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
app/saas/retention/data.ts
/**
 * What /retention reads. The book is `db.cohorts` — one row per week of
 * signups, followed week by week — joined to `db.customers` for the segments
 * those accounts sit in. A week a cohort has not lived through yet is absent
 * from its own row, so nothing here invents a reading: the grid draws those
 * cells as "no data" and the curves stop where the calendar does. Nothing reads
 * a clock: every window ends at REFERENCE_DATE.
 */
import { getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, type Cohort, type Member } from "@/lib/sample-data"

const COHORTS = db.cohorts.all()

/** How many weeks of life the grid and the curves follow. */
export const WEEKS = 9

const WEEK_LABEL = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  timeZone: "UTC",
})

/** A cohort's own name: the Monday it opened. */
export function cohortLabel(cohort: Cohort): string {
  return WEEK_LABEL.format(cohort.week)
}

/** The share of a cohort still active `week` weeks on, as a percentage, or NaN. */
function share(cohort: Cohort, week: number): number {
  const kept = cohort.retention[week]
  return kept === undefined ? Number.NaN : kept * 100
}

export type CohortGrid = { rows: string[]; columns: string[]; values: number[][] }

/**
 * Retention by signup week: each row a cohort, each column a week of life.
 * Newest cohort at the top, because that is the one a reader came to check.
 */
export function cohortGrid(): CohortGrid {
  const newestFirst = [...COHORTS].reverse()
  return {
    rows: newestFirst.map(cohortLabel),
    columns: Array.from({ length: WEEKS }, (_, week) => `W${week}`),
    values: newestFirst.map((cohort) =>
      Array.from({ length: WEEKS }, (_, week) => share(cohort, week))
    ),
  }
}

export type CurvePoint = Record<string, string | number | null>

export type Curves = { data: CurvePoint[]; series: { key: string; label: string }[] }

/** How many cohorts the curve chart draws, oldest first — the ones with a full life behind them. */
const CURVE_COHORTS = 4

/**
 * The same numbers as lines rather than cells: the four oldest cohorts, which
 * are the only ones with enough weeks behind them for a shape, plus the mean
 * across every cohort that reached each week.
 */
export function retentionCurves(): Curves {
  const oldest = COHORTS.slice(0, CURVE_COHORTS)

  const data = Array.from({ length: WEEKS }, (_, week) => {
    const point: CurvePoint = { week: `W${week}` }
    for (const cohort of oldest) {
      const value = share(cohort, week)
      point[cohort.id] = Number.isFinite(value) ? Math.round(value * 10) / 10 : null
    }
    const reached = COHORTS.map((cohort) => share(cohort, week)).filter((value) =>
      Number.isFinite(value)
    )
    point.mean =
      reached.length > 0
        ? Math.round((reached.reduce((sum, value) => sum + value, 0) / reached.length) * 10) / 10
        : null
    return point
  })

  return {
    data,
    series: [
      { key: "mean", label: "All cohorts" },
      ...oldest.map((cohort) => ({ key: cohort.id, label: cohortLabel(cohort) })),
    ],
  }
}

/** The mean share still active `week` weeks on, across the cohorts that got there. */
function meanAt(week: number): number {
  const reached = COHORTS.map((cohort) => share(cohort, week)).filter((value) =>
    Number.isFinite(value)
  )
  if (reached.length === 0) return 0
  return reached.reduce((sum, value) => sum + value, 0) / reached.length
}

export type RetentionStat = {
  key: string
  label: string
  value: number
  description: string
  format: "number" | "percent"
}

/** The four headline numbers: how many arrived, and how many of them stayed. */
export function retentionStats(): RetentionStat[] {
  const signups = COHORTS.reduce((total, cohort) => total + cohort.size, 0)

  return [
    {
      key: "signups",
      label: "Signups",
      value: signups,
      description: `across ${COHORTS.length} weekly cohorts`,
      format: "number",
    },
    {
      key: "w1",
      label: "Week 1",
      value: meanAt(1),
      description: "still active seven days on",
      format: "percent",
    },
    {
      key: "w4",
      label: "Week 4",
      value: meanAt(4),
      description: "still active a month on",
      format: "percent",
    },
    {
      key: "w8",
      label: "Week 8",
      value: meanAt(8),
      description: "the floor the curve settles on",
      format: "percent",
    },
  ]
}

/**
 * What a week of signups is aiming at. The rows record what arrived, not what
 * was hoped for, so the target is a rule over them: the mean week, rounded up
 * to the nearest ten, which is what a goal set from last quarter looks like.
 */
export function signupTarget(): number {
  const mean = COHORTS.reduce((total, cohort) => total + cohort.size, 0) / COHORTS.length
  return Math.ceil(mean / 10) * 10
}

export type SignupWeek = { week: string; signups: number }

/** Signups per week, oldest first, for the bar chart the target line crosses. */
export function signupsByWeek(): SignupWeek[] {
  return COHORTS.map((cohort) => ({ week: cohortLabel(cohort), signups: cohort.size }))
}

export type Segment = { key: string; label: string; value: number }

const PLAN_LABELS: Record<string, string> = {
  free: "Free",
  starter: "Starter",
  team: "Team",
  enterprise: "Enterprise",
}

/**
 * Where the accounts that stayed actually sit. A cohort row records a count and
 * a curve, not who was in it, so the segments are read off the live accounts in
 * `db.customers` instead — which is the only honest join between the two.
 */
export function segments(): Segment[] {
  const counts = new Map<string, number>()
  for (const customer of db.customers.all()) {
    if (customer.status === "churned") continue
    counts.set(customer.plan, (counts.get(customer.plan) ?? 0) + 1)
  }

  return [...counts]
    .map(([plan, value]) => ({ key: plan, label: PLAN_LABELS[plan] ?? plan, value }))
    .sort((a, b) => b.value - a.value)
}

/** 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 }
}

/** The window the cohorts cover, as a line under the title. */
export function lastUpdated(): string {
  const first = COHORTS[0]
  return `${COHORTS.length} weekly cohorts, ${WEEK_LABEL.format(first.week)} to ${WEEK_LABEL.format(
    REFERENCE_DATE
  )}`
}
app/saas/retention/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/retention/components/cohort-heatmap.tsx
"use client"

import { HeatmapGrid } from "@/components/ui/heatmap-grid"
import { Widget } from "@/components/ui/widget"

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

const share = (value: number) => `${Math.round(value)}%`

/** Retention by signup week: each row a cohort, each column a week of its life. */
export function CohortHeatmap({ grid }: { grid: CohortGrid }) {
  return (
    <Widget
      data-widget="widget-saas-retention-cohort-heatmap"
      title="Retention by cohort"
      description="The share of each week's signups still active, week by week"
      className="h-full"
      footer="Empty cells are weeks a cohort has not lived through yet, not weeks it lost."
    >
      <HeatmapGrid
        rows={grid.rows}
        columns={grid.columns}
        values={grid.values}
        min={0}
        max={100}
        color="chart-2"
        cellSize={44}
        showValues
        valueFormatter={share}
      />
    </Widget>
  )
}
app/saas/retention/components/retention-curves.tsx
"use client"

import { ChartCard } from "@/components/ui/chart-card"
import { LineChart } from "@/components/ui/line-chart"

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

const share = (value: number) => `${Math.round(value)}%`

/** The same grid as lines: four cohorts against the average of every one of them. */
export function RetentionCurves({ curves }: { curves: Curves }) {
  return (
    <ChartCard
      data-widget="widget-saas-retention-retention-curves"
      title="Retention curves"
      description="Where each cohort settles, against the average"
      height={280}
      className="h-full"
    >
      <LineChart
        data={curves.data}
        index="week"
        series={curves.series}
        valueFormatter={share}
        height={260}
        legend="bottom"
      />
    </ChartCard>
  )
}
app/saas/retention/components/retention-stats.tsx
import { CalendarDaysIcon, RepeatIcon, UserPlusIcon, UsersIcon } from "lucide-react"

import { formatNumber, formatPercent } from "@/lib/format"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

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

// One icon per headline, keyed by the stat rather than its position, so the row
// can be reordered without the icons following the wrong numbers.
const ICONS: Record<string, React.ReactNode> = {
  signups: <UserPlusIcon />,
  w1: <RepeatIcon />,
  w4: <CalendarDaysIcon />,
  w8: <UsersIcon />,
}

/** How many arrived, and how many of them were still here a week, a month and two months on. */
export function RetentionStats({ stats }: { stats: RetentionStat[] }) {
  return (
    <StatCardGroup data-widget="widget-saas-retention-retention-stats" columns={4}>
      {stats.map((stat) => (
        <StatCard
          key={stat.key}
          label={stat.label}
          value={
            stat.format === "percent"
              ? formatPercent(stat.value / 100, { maximumFractionDigits: 1 })
              : formatNumber(stat.value, { maximumFractionDigits: 0 })
          }
          description={stat.description}
          icon={ICONS[stat.key]}
        />
      ))}
    </StatCardGroup>
  )
}
app/saas/retention/components/segment-bubbles.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { BubbleChart } from "@/components/ui/bubble-chart"
import { ChartCard } from "@/components/ui/chart-card"

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

const accounts = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

/** Which plans the accounts that stayed are actually on, sized by how many. */
export function SegmentBubbles({ segments }: { segments: Segment[] }) {
  return (
    <ChartCard
      data-widget="widget-saas-retention-segment-bubbles"
      title="Who stayed"
      description="Live accounts by plan"
      height={560}
      className="h-full"
    >
      <BubbleChart data={segments} height={520} valueFormatter={accounts} />
    </ChartCard>
  )
}
app/saas/retention/components/weekly-signups.tsx
"use client"

import { formatNumber } from "@/lib/format"
import { BarChart } from "@/components/ui/bar-chart"
import { ChartCard } from "@/components/ui/chart-card"

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

const count = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })

export type WeeklySignupsProps = { weeks: SignupWeek[]; target: number }

/** How big each cohort was when it arrived, against the week it was aiming at. */
export function WeeklySignups({ weeks, target }: WeeklySignupsProps) {
  return (
    <ChartCard
      data-widget="widget-saas-retention-weekly-signups"
      title="Signups a week"
      description="Every cohort at the size it opened"
      height={260}
      className="h-full"
    >
      <BarChart
        data={weeks}
        index="week"
        series={[{ key: "signups", label: "Signups", color: "chart-1" }]}
        annotations={[{ kind: "line", value: target, label: "Target", tone: "brand" }]}
        valueFormatter={count}
        height={240}
        showLegend={false}
      />
    </ChartCard>
  )
}