Retention headline
How many signed up, and how many of them were still here a week, four weeks and eight weeks on; reads retentionStats().
Preview
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-saas-retention-retention-statsNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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