Skip to contentVibraUI

Retention by weekly cohort

Each week's signups down the side and each week of their life along the top, the share still active in every cell; reads cohortGrid().

Preview

Install

npx shadcn@latest add @vibra/widget-saas-retention-cohort-heatmap

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

Source

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/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
  )}`
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Retention dashboard page