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

Product usage dashboard

A product analytics page: daily, weekly and monthly active users, stickiness, a feature adoption heatmap, a cohort retention curve, the accounts with the most people in the product, and the event stream behind them.

Open the live page

The page is a server component inside AppShell; only the three charts cross into the client, because a chart formats its own axis labels. The accounts are db.customers rows and the event stream is the db.auditEvents trail. Product telemetry has no entity, so 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("saas-usage"), capped at the seats it pays for. The daily series is that same population shaped day by day from the same generator, so the chart can never drift away from the accounts beside it; retention and feature adoption are curves off the same seed. Composes AppShell, PageHeader, StatCardGroup, StatCard, DashboardGrid, ChartCard, LineChart, AreaChart, Widget, HeatmapGrid, RankList and ActivityFeed.

Preview

Install

npx shadcn@latest add @vibra/saas-usage

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

Source

app/saas/usage/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 { ActiveUsersChart } from "./components/active-users-chart"
import { EventStream } from "./components/event-stream"
import { FeatureAdoption } from "./components/feature-adoption"
import { RetentionCurve } from "./components/retention-curve"
import { TopAccounts } from "./components/top-accounts"
import { UsageStats } from "./components/usage-stats"
import {
  activeUsers,
  adoptionGrid,
  currentUser,
  lastUpdated,
  retention,
  SERIES_WINDOW_DAYS,
  shellNotifications,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/saas/nav"

/**
 * The product usage page. It is a server component: the accounts and the event
 * trail are read from db on the server, and only the three charts cross into
 * the client, because a chart formats its own axis labels. They cross with
 * their rows as props; none of them imports data.ts.
 */
export default function ProductUsagePage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.usage}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Product usage"
        description="Who is in the product, what they reach for, and how long they stay."
        meta={lastUpdated()}
      />

      <UsageStats />

      <DashboardGrid>
        <DashboardGridItem colSpan={{ base: 12, lg: 8 }}>
          <ActiveUsersChart points={activeUsers()} days={SERIES_WINDOW_DAYS} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 4 }}>
          <TopAccounts />
        </DashboardGridItem>

        <DashboardGridItem colSpan={{ base: 12, lg: 7 }}>
          <FeatureAdoption grid={adoptionGrid()} />
        </DashboardGridItem>
        <DashboardGridItem colSpan={{ base: 12, lg: 5 }}>
          <RetentionCurve points={retention()} />
        </DashboardGridItem>

        <DashboardGridItem colSpan={12}>
          <EventStream />
        </DashboardGridItem>
      </DashboardGrid>
    </AppShell>
  )
}
app/saas/usage/data.ts
/**
 * 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`
}
app/saas/usage/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/usage/components/active-users-chart.tsx
"use client"

import { formatCompact, formatNumber } from "@/lib/format"
import { ChartCard } from "@/components/ui/chart-card"
import { LineChart } from "@/components/ui/line-chart"

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

// Three nested populations, drawn coolest to warmest so the widest band reads
// as the backdrop and the daily line as the thing being watched. The monthly
// line is the account population itself, so it sits flat as the ceiling the
// other two are measured against.
const SERIES = [
  { key: "mau", label: "Monthly active", color: "chart-3" as const },
  { key: "wau", label: "Weekly active", color: "chart-2" as const },
  { key: "dau", label: "Daily active", color: "chart-1" as const },
]

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

export type ActiveUsersChartProps = {
  /** One point a day, oldest first. */
  points: ActivePoint[]
  /** How many days the series covers. */
  days: number
}

/** Daily and weekly actives drawn against the monthly population they come from. */
export function ActiveUsersChart({ points, days }: ActiveUsersChartProps) {
  const latest = points[points.length - 1]

  return (
    <ChartCard
      data-widget="widget-saas-usage-active-users-chart"
      title="Active users"
      description={`Daily and weekly active users against the monthly population, over ${days} days`}
      height={300}
      className="h-full"
      footer={`${formatNumber(latest.dau, { maximumFractionDigits: 0 })} people used the product on the last full day.`}
    >
      <LineChart
        data={points}
        index="date"
        series={SERIES}
        height={300}
        showYAxis
        strokeWidth={1.75}
        indexFormatter={(value) => DAY_LABEL.format(new Date(`${value}T00:00:00Z`))}
        valueFormatter={(value) => formatCompact(value)}
      />
    </ChartCard>
  )
}
app/saas/usage/components/event-stream.tsx
import { ActivityFeed } from "@/components/ui/activity-feed"
import { Widget } from "@/components/ui/widget"

import { eventStream, NOW } from "../data"

export function EventStream() {
  return (
    <Widget
      data-widget="widget-saas-usage-event-stream"
      title="Event stream"
      description="What accounts changed in the product, newest first"
      className="h-full"
      footer="Every event is written to the audit trail and kept for a year."
    >
      <ActivityFeed items={eventStream()} groupByDay now={NOW} />
    </Widget>
  )
}
app/saas/usage/components/feature-adoption.tsx
"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>
  )
}
app/saas/usage/components/retention-curve.tsx
"use client"

import { AreaChart } from "@/components/ui/area-chart"
import { ChartCard } from "@/components/ui/chart-card"

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

const SERIES = [{ key: "retained", label: "Retained", color: "chart-2" as const }]

/** How much of a signup cohort is left, week by week, down to the floor it settles on. */
export function RetentionCurve({ points }: { points: RetentionPoint[] }) {
  const floor = points[points.length - 1]

  return (
    <ChartCard
      data-widget="widget-saas-usage-retention-curve"
      title="Retention"
      description="How much of a signup cohort is still here, week by week"
      height={320}
      className="h-full"
      footer={`The curve flattens at ${floor.retained}% by ${floor.week.toLowerCase()}.`}
    >
      <AreaChart
        data={points}
        index="week"
        series={SERIES}
        height={320}
        showYAxis
        showLegend={false}
        gradient
        valueFormatter={(value) => `${Math.round(value)}%`}
      />
    </ChartCard>
  )
}
app/saas/usage/components/top-accounts.tsx
import { formatNumber } from "@/lib/format"
import { RankList } from "@/components/ui/rank-list"
import { Widget } from "@/components/ui/widget"

import { accountCount, topAccounts } from "../data"

export function TopAccounts() {
  const accounts = topAccounts()

  return (
    <Widget
      data-widget="widget-saas-usage-top-accounts"
      title="Top accounts"
      description="Where this month's active users are, by account"
      className="h-full"
      footer={`Measured across ${formatNumber(accountCount(), { maximumFractionDigits: 0 })} accounts that are still live.`}
    >
      <RankList
        items={accounts.map((account) => ({ label: account.company, value: account.monthlyActive }))}
        color="chart-1"
      />
    </Widget>
  )
}
app/saas/usage/components/usage-stats.tsx
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { usageStats } from "../data"

export function UsageStats() {
  return (
    <StatCardGroup data-widget="widget-saas-usage-usage-stats" columns={4}>
      {usageStats().map((stat) => (
        <StatCard
          key={stat.key}
          label={stat.label}
          value={stat.value}
          delta={stat.delta}
          description={stat.description}
        />
      ))}
    </StatCardGroup>
  )
}