Feature adoption
The share of live accounts that touched each surface, week by week, as a heatmap, and the surface used most widely this week; reads adoptionGrid().
Preview
"use client"
import { HeatmapGrid } from "@/components/ui/heatmap-grid"
import { Widget } from "@/components/ui/widget"
import { type AdoptionGrid } from "../data"
/** Which surfaces the live accounts reach for, week by week. */
export function FeatureAdoption({ grid }: { grid: AdoptionGrid }) {
const { features, weeks, values } = grid
const latest = values.map((row) => row[row.length - 1])
const best = features[latest.indexOf(Math.max(...latest))]
return (
<Widget
data-widget="widget-saas-usage-feature-adoption"
title="Feature adoption"
description="The share of live accounts that touched each surface, by week"
className="h-full"
contentClassName="overflow-x-auto"
footer={`${best} is the most widely used surface this week.`}
>
<HeatmapGrid
rows={features}
columns={weeks}
values={values}
color="chart-1"
cellSize={32}
valueFormatter={(value) => `${value}% of accounts`}
/>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-saas-usage-feature-adoptionNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import { HeatmapGrid } from "@/components/ui/heatmap-grid"
import { Widget } from "@/components/ui/widget"
import { type AdoptionGrid } from "../data"
/** Which surfaces the live accounts reach for, week by week. */
export function FeatureAdoption({ grid }: { grid: AdoptionGrid }) {
const { features, weeks, values } = grid
const latest = values.map((row) => row[row.length - 1])
const best = features[latest.indexOf(Math.max(...latest))]
return (
<Widget
data-widget="widget-saas-usage-feature-adoption"
title="Feature adoption"
description="The share of live accounts that touched each surface, by week"
className="h-full"
contentClassName="overflow-x-auto"
footer={`${best} is the most widely used surface this week.`}
>
<HeatmapGrid
rows={features}
columns={weeks}
values={values}
color="chart-1"
cellSize={32}
valueFormatter={(value) => `${value}% of accounts`}
/>
</Widget>
)
}/**
* What this page reads. The accounts are `db.customers` rows and the event
* stream is the `db.auditEvents` trail, so both change the moment a repository
* is swapped for a real store.
*
* Product telemetry has no entity of its own, so three things are derived and
* none of them is a literal. An account's monthly active users is a *rule* over
* the row db does record — its seats, times how deeply its plan tends to be
* used, times a jitter fixed by `seeded("dashboard-product-usage")`, capped at
* the seats it pays for. The daily active series is that population shaped day
* by day from the same generator, so it can never drift away from the accounts
* on the page. Retention and feature adoption are curves off the same seed.
* "Now" is `REFERENCE_DATE`; nothing here reads a clock.
*/
import { formatNumber, formatPercent, getInitials } from "@/lib/format"
import {
db,
REFERENCE_DATE,
seeded,
type Customer,
type Member,
} from "@/lib/sample-data"
const DAY_MS = 86_400_000
/** How much of a plan's seats log in during a month, before the per-account jitter. */
const PLAN_ENGAGEMENT: Record<Customer["plan"], number> = {
enterprise: 0.82,
team: 0.71,
starter: 0.58,
free: 0.36,
}
// One series for the whole block: each live account's engagement, the daily
// readings, adoption and retention draw from it in this order, so every number
// on the page is the same on every render.
const SEED = "dashboard-product-usage"
// An account that has churned or been suspended logs nobody in.
const isLive = (customer: Customer) => customer.status === "active" || customer.status === "trial"
type Draws = {
/** How far each account runs from its plan's engagement, by id. */
engagement: Map<string, number>
/** Each day's noise on the daily and on the weekly reading. */
days: { daily: number; weekly: number }[]
adoption: number[][]
retention: number[]
}
let drawn: Draws | undefined
/**
* The figures no row carries, drawn once and in the block's fixed order — when
* a page first asks, never when the module loads. The accounts themselves are
* read per request: one that churns since leaves the page, and one that goes
* live draws its engagement from its own id.
*/
function draws(): Draws {
if (drawn) return drawn
const rand = seeded(SEED)
const engagement = new Map(
db.customers
.all()
.filter(isLive)
.map((customer) => [customer.id, 0.82 + rand() * 0.36])
)
const days = Array.from({ length: SERIES_DAYS }, () => {
const daily = 0.95 + rand() * 0.1
return { daily, weekly: 0.97 + rand() * 0.06 }
})
const adoption = FEATURES.map(() => Array.from({ length: ADOPTION_WEEKS }, () => 0.94 + rand() * 0.12))
const retention = Array.from({ length: 8 }, (_, week) => (week === 0 ? 1 : 0.97 + rand() * 0.06))
drawn = { engagement, days, adoption, retention }
return drawn
}
export type Account = { id: string; company: string; plan: Customer["plan"]; monthlyActive: number }
/** Every live account, the most people in the product first. */
function accounts(): Account[] {
const { engagement } = draws()
return db.customers
.all()
.filter(isLive)
.map((customer) => ({
id: customer.id,
company: customer.company,
plan: customer.plan,
monthlyActive: Math.max(
1,
Math.min(
customer.seats,
Math.round(
customer.seats *
PLAN_ENGAGEMENT[customer.plan] *
(engagement.get(customer.id) ?? 0.82 + seeded(`${SEED}:${customer.id}`)() * 0.36)
)
)
),
}))
.sort((a, b) => b.monthlyActive - a.monthlyActive)
}
/** The accounts with the most people in the product this month. */
export function topAccounts(limit = 6): Account[] {
return accounts().slice(0, limit)
}
/** How many live accounts the product is measured across. */
export function accountCount(): number {
return accounts().length
}
// How a monthly population splits down: a little under two thirds come back in
// a given week, a third on a given day. Weekends run at 0.62 of a weekday.
const WEEKLY_SHARE = 0.61
const DAILY_SHARE = 0.34
const WEEKEND_FACTOR = 0.62
const SERIES_DAYS = 90
/** Midnight UTC on the last complete day before "now". */
const LAST_DAY =
Date.UTC(
REFERENCE_DATE.getUTCFullYear(),
REFERENCE_DATE.getUTCMonth(),
REFERENCE_DATE.getUTCDate()
) - DAY_MS
export type ActivePoint = { date: string; dau: number; wau: number; mau: number }
/**
* Ninety days of active users. The monthly line is the population itself — the
* accounts above, added up — so it is flat by construction and neither the
* chart nor the stat card can drift away from the list beside them. Only the
* daily and weekly readings move: a weekday runs well ahead of a weekend.
*/
function activeSeries(population: Account[]): ActivePoint[] {
// The population the whole page is measured against: everyone who logged in
// this month, across every live account.
const mau = population.reduce((total, account) => total + account.monthlyActive, 0)
return draws().days.map((noise, index) => {
const at = new Date(LAST_DAY - (SERIES_DAYS - 1 - index) * DAY_MS)
const weekend = at.getUTCDay() === 0 || at.getUTCDay() === 6
return {
date: at.toISOString().slice(0, 10),
dau: Math.round(mau * DAILY_SHARE * (weekend ? WEEKEND_FACTOR : 1) * noise.daily),
wau: Math.round(mau * WEEKLY_SHARE * noise.weekly),
mau,
}
})
}
/** The daily, weekly and monthly active users over the window, oldest first. */
export function activeUsers(): ActivePoint[] {
return activeSeries(accounts())
}
/** How many days the active-users chart covers. */
export const SERIES_WINDOW_DAYS = SERIES_DAYS
export type UsageStat = {
key: string
label: string
value: string
/** Left off where there is nothing to compare against — the population itself. */
delta?: number
description: string
}
const ratio = (current: number, previous: number): number =>
previous === 0 ? 0 : current / previous - 1
/** The four headline numbers, each against the same day a week earlier. */
export function usageStats(): UsageStat[] {
const population = accounts()
const series = activeSeries(population)
const latest = series[series.length - 1]
const weekAgo = series[series.length - 8]
const against = "vs the same day last week"
const stickiness = latest.dau / latest.mau
const wasStickiness = weekAgo.dau / weekAgo.mau
return [
{
// The population every other number on the page is a share of, so there
// is no week-on-week change to report: it is the accounts themselves.
key: "mau",
label: "Monthly active",
value: formatNumber(latest.mau, { maximumFractionDigits: 0 }),
description: `across ${population.length} live accounts`,
},
{
key: "wau",
label: "Weekly active",
value: formatNumber(latest.wau, { maximumFractionDigits: 0 }),
delta: ratio(latest.wau, weekAgo.wau),
description: against,
},
{
key: "dau",
label: "Daily active",
value: formatNumber(latest.dau, { maximumFractionDigits: 0 }),
delta: ratio(latest.dau, weekAgo.dau),
description: against,
},
{
key: "stickiness",
label: "Stickiness",
value: formatPercent(stickiness, { maximumFractionDigits: 1 }),
delta: ratio(stickiness, wasStickiness),
description: "DAU over MAU",
},
]
}
/** The eight surfaces adoption is measured across, in the order they shipped. */
export const FEATURES = [
"Dashboards",
"Alerts",
"Reports",
"API keys",
"Integrations",
"Audit log",
"SSO",
"Exports",
]
const ADOPTION_WEEKS = 12
/** The week labels the adoption grid is drawn against, oldest first. */
export const ADOPTION_COLUMNS = Array.from(
{ length: ADOPTION_WEEKS },
(_, index) => `W${index + 1}`
)
/**
* The share of live accounts that touched each surface in each week, as a
* percentage. An older surface starts high and creeps up; a newer one starts
* low and climbs faster.
*/
function adoption(): number[][] {
return draws().adoption.map((weeks, row) => {
const start = 74 - row * 8
const climb = 4 + row * 1.4
return weeks.map((noise, week) => {
const trend = start + (week / (ADOPTION_WEEKS - 1)) * climb
return Math.round(Math.max(2, Math.min(96, trend * noise)))
})
})
}
/** Feature adoption, one row per surface and one column per week. */
export function featureAdoption(): number[][] {
return adoption()
}
/** The adoption grid whole: the surfaces down the side, the weeks along the top, the shares between. */
export type AdoptionGrid = { features: string[]; weeks: string[]; values: number[][] }
/** Everything the adoption heatmap draws, handed to it as one prop. */
export function adoptionGrid(): AdoptionGrid {
return { features: FEATURES, weeks: ADOPTION_COLUMNS, values: adoption() }
}
export type RetentionPoint = { week: string; retained: number }
/**
* A cohort's retention over its first eight weeks: everyone in week 0, a steep
* drop through the first fortnight, then a floor the product settles on.
*/
/** The retention curve, week 0 first. */
export function retention(): RetentionPoint[] {
return draws().retention.map((noise, week) => {
if (week === 0) return { week: "Week 0", retained: 100 }
const floor = 41
const decay = floor + (100 - floor) * Math.exp(-week / 2.1)
return { week: `Week ${week}`, retained: Math.round(decay * noise) }
})
}
/** The newest product events: who did what to which record, newest first. */
export function eventStream(limit = 8) {
const members = new Map(db.members.all().map((member) => [member.id, member]))
return db.auditEvents
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, limit)
.map((event) => ({
id: event.id,
actor: {
name: members.get(event.actor)?.name ?? "A teammate",
src: members.get(event.actor)?.avatarUrl,
},
action: event.action,
target: `${event.resource.replace(/_/g, " ")} ${event.resourceId}`,
time: event.at,
}))
}
/** The moment the feed measures "Today" against. */
export const NOW = REFERENCE_DATE
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
// Fixed to UTC so the line reads the same wherever the page is rendered.
const UPDATED_AT = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
hourCycle: "h23",
timeZone: "UTC",
})
/** When the numbers on this page were last collected. */
export function lastUpdated(): string {
return `Last updated ${UPDATED_AT.format(REFERENCE_DATE)} UTC`
}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 Product usage dashboard page