Skip to contentVibraUI

Movement

New, churned and net new MRR and the growth rate over a month, a quarter or a year, each figure rolling to its new reading when the window changes; reads revenueWindows().

Preview

Install

npx shadcn@latest add @vibra/widget-saas-revenue-revenue-rollups

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

Source

app/saas/revenue/components/revenue-rollups.tsx
"use client"

import * as React from "react"

import { formatCurrency, formatPercent } from "@/lib/format"
import { SectionHeader } from "@/components/ui/section-header"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

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

const OPTIONS = [
  { value: "1m", label: "Month" },
  { value: "3m", label: "Quarter" },
  { value: "12m", label: "Year" },
]

const money = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })
const share = (value: number) => formatPercent(value, { maximumFractionDigits: 1 })

/**
 * The movement roll-up, at whichever length the reader asks for.
 *
 * A client island because the window is a choice, and the four figures are
 * rolls rather than swaps: changing the window moves each number to its new
 * reading, so it is visible which way it went without re-reading the label.
 * Every window is computed on the server off the same book, so switching costs
 * nothing and shows no spinner.
 */
export function RevenueRollups({ windows }: { windows: RevenueWindow[] }) {
  const [key, setKey] = React.useState(windows[0]?.key)
  const span = windows.find((window) => window.key === key) ?? windows[0]
  const against = `on the ${span.label} before`

  return (
    <section data-widget="widget-saas-revenue-revenue-rollups" aria-label="Movement" className="flex flex-col gap-3">
      <SectionHeader
        as="h2"
        size="sm"
        title="Movement"
        description={`What the book won and lost over the ${span.label}.`}
        actions={
          <SegmentedControl
            size="sm"
            aria-label="Roll-up window"
            options={OPTIONS}
            value={span.key}
            onValueChange={(value) => setKey(value as RevenueWindow["key"])}
          />
        }
      />

      <StatCardGroup columns={4}>
        <StatCard
          label="New"
          roll={{ value: span.won, format: money }}
          delta={span.wonDelta}
          description={against}
        />
        <StatCard
          label="Churn"
          roll={{ value: span.lost, format: money }}
          delta={span.lostDelta}
          positiveIsGood={false}
          description={against}
        />
        <StatCard
          label="Net new"
          roll={{ value: span.net, format: money }}
          delta={span.netDelta}
          description={against}
        />
        <StatCard
          label="Growth"
          roll={{ value: span.growth, format: share }}
          delta={span.growthDelta}
          description={against}
        />
      </StatCardGroup>
    </section>
  )
}
app/saas/revenue/book.ts
/**
 * The book: how a subscription becomes revenue, and what the revenue was on any
 * given date. `db.subscriptions` records one rate per account and the two dates
 * it ran between, so a month's book is every subscription that had started by
 * the close and had not been cancelled yet.
 *
 * `data.ts` holds the selectors the page reads; this is the model underneath
 * them, kept apart because it is the part a consumer swaps when the rows come
 * from a real billing system.
 */
import { db, REFERENCE_DATE, type Customer, type Subscription } from "@/lib/sample-data"

/** The plan tiers that carry revenue, largest first — Free never enters the book. */
export type PaidTier = Exclude<Customer["plan"], "free">
export const PAID_TIERS = ["enterprise", "team", "starter"] as const satisfies readonly PaidTier[]

/**
 * Every subscription, read per call and never held at module scope: an account
 * cancelled or started since the server started is in the next book.
 */
export function subscriptions(): Subscription[] {
  return db.subscriptions.all()
}

export const PLAN_NAME_BY_ID = new Map(db.plans.all().map((plan) => [plan.id, plan.name]))
const PLAN_BY_TIER = new Map(db.plans.all().map((plan) => [plan.name.toLowerCase(), plan]))

/** The plan a tier names. Customer.plan and Plan.name are the same four tiers. */
export function planFor(tier: PaidTier) {
  return PLAN_BY_TIER.get(tier)!
}

// What a plan charges a seat a month, read off the accounts paying it today:
// every live account on a tier bills the same rate, so one of them states it
// for the accounts that have since stopped paying.
export function seatPrices(): Map<string, number> {
  const prices = new Map<string, number>()
  for (const customer of db.customers.all()) {
    if (customer.mrrCents > 0 && !prices.has(customer.plan)) {
      prices.set(customer.plan, customer.mrrCents / customer.seats)
    }
  }
  return prices
}

/** What a subscription bills a month, in cents. A trial bills nothing. */
export function billedCents(subscription: Subscription, prices = seatPrices()): number {
  if (subscription.status === "trialing") return 0
  const tier = (PLAN_NAME_BY_ID.get(subscription.planId) ?? "").toLowerCase()
  return subscription.seats * (prices.get(tier) ?? 0)
}

/** Midnight UTC on the first of the month `k` months before the current one. */
export function monthStart(k: number): Date {
  return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - k, 1))
}

/** The month that ends at `monthStart(k)`, as "2026-08". */
export function closedMonthKey(k: number): string {
  return monthStart(k + 1).toISOString().slice(0, 7)
}

/** Whether a subscription was billing at `at`. */
export const live = (subscription: Subscription, at: Date): boolean =>
  subscription.startedAt <= at && (!subscription.cancelledAt || subscription.cancelledAt > at)

/** The book at `at`, in dollars, for one plan or for all of them. */
export function bookAt(at: Date, planId?: string): number {
  const prices = seatPrices()
  return (
    subscriptions()
      .filter((row) => live(row, at) && (!planId || row.planId === planId))
      .reduce((total, row) => total + billedCents(row, prices), 0) / 100
  )
}

/** What started billing, and what stopped, between two instants — in dollars. */
export function movement(from: Date, to: Date): { won: number; lost: number } {
  let won = 0
  let lost = 0
  const prices = seatPrices()
  for (const subscription of subscriptions()) {
    if (subscription.startedAt > from && subscription.startedAt <= to) won += billedCents(subscription, prices)
    const { cancelledAt } = subscription
    if (cancelledAt && cancelledAt > from && cancelledAt <= to) lost += billedCents(subscription, prices)
  }
  return { won: won / 100, lost: lost / 100 }
}
app/saas/revenue/data.ts
/**
 * What this page reads. The book is `db.subscriptions` walked month by month by
 * `./book`, so the twelve-month chart, the closing MRR, the movements, the
 * ratios and the quarter tracker all come off the same rows. The page closes
 * each month rather than reading a part-finished one; "now" is `REFERENCE_DATE`.
 */
import { formatCurrency, formatPercent, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, seeded, type Member, type Subscription } from "@/lib/sample-data"

import {
  billedCents,
  bookAt,
  closedMonthKey,
  live,
  monthStart,
  movement,
  PAID_TIERS,
  planFor,
  PLAN_NAME_BY_ID,
  seatPrices,
  subscriptions,
  type PaidTier,
} from "./book"

const MONTHS = 12

/** One month of the book. The keys are the paid tiers, so a typo will not compile. */
export type MonthlyRevenue = { month: string } & Record<PaidTier, number>

/** Twelve closed months of MRR split by plan, oldest first. Each row is that month's book. */
export function revenueByPlan(): MonthlyRevenue[] {
  return Array.from({ length: MONTHS }, (_, index) => {
    const k = MONTHS - 1 - index
    const at = monthStart(k)
    return {
      month: closedMonthKey(k),
      enterprise: Math.round(bookAt(at, planFor("enterprise").id)),
      team: Math.round(bookAt(at, planFor("team").id)),
      starter: Math.round(bookAt(at, planFor("starter").id)),
    }
  })
}

/** A tier a revenue chart stacks: the key its rows carry, and the plan's name. */
export type PlanKey = { key: PaidTier; label: string }

/** The tiers a revenue chart stacks, largest first. */
export function planKeys(): PlanKey[] {
  return PAID_TIERS.map((tier) => ({ key: tier, label: planFor(tier).name }))
}

const CLOSE_AT = monthStart(0)
const OPENING_AT = monthStart(1)
const YEAR_AGO_AT = monthStart(MONTHS)

/** Monthly recurring revenue at the close of the last complete month, read off the book as it stands. */
const closingMrr = () => Math.round(bookAt(CLOSE_AT))

/** The closing split by plan, in the shape PercentageBar takes. */
export function revenueMix(): { label: string; value: number }[] {
  return PAID_TIERS.map((tier) => ({
    label: planFor(tier).name,
    value: Math.round(bookAt(CLOSE_AT, planFor(tier).id)),
  }))
}

/** Enterprise's share of the book now, and twelve months ago. */
export function enterpriseShare(): { now: number; yearAgo: number } {
  const enterprise = planFor("enterprise").id
  return {
    now: bookAt(CLOSE_AT, enterprise) / bookAt(CLOSE_AT),
    yearAgo: bookAt(YEAR_AGO_AT, enterprise) / bookAt(YEAR_AGO_AT),
  }
}

/**
 * What the book won and lost between two month closes, over the same twelve
 * months the chart above draws — a single month of this data is mostly trials
 * that have not started paying. Two movements rather than the usual four: an
 * account moving up or down a plan leaves no trace in the rows, and a made-up
 * expansion would need a made-up contraction to cancel it.
 */
function movements(fromK: number, toK: number) {
  const opening = bookAt(monthStart(fromK))
  const { won, lost } = movement(monthStart(fromK), monthStart(toK))
  return { won, lost, opening, net: bookAt(monthStart(toK)) - opening }
}

/** The book the twelve months of movement below opened against. */
export function mrrYearOpening(): number {
  return Math.round(movements(MONTHS, 0).opening)
}

/** One step of the year's MRR walk, as the waterfall draws it. */
export type MrrMovement = { label: string; value: number; isTotal?: boolean }

/** How the closing book got to where it is over the twelve months behind it. */
export function mrrMovements(): MrrMovement[] {
  const YEAR = movements(MONTHS, 0)
  return [
    { label: "New", value: Math.round(YEAR.won) },
    { label: "Churn", value: -Math.round(YEAR.lost) },
    { label: "Net new", value: 0, isTotal: true },
  ]
}

const money = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })

const paying = (at: Date): number => {
  const prices = seatPrices()
  return subscriptions().filter((subscription) => live(subscription, at) && billedCents(subscription, prices) > 0)
    .length
}

const retained = (span: ReturnType<typeof movements>): number => (span.opening - span.lost) / span.opening

/** One of the four ratios beside the headline number: printed, and moved against the year before. */
export type HeadlineStat = { label: string; value: string; delta: number; positiveIsGood?: boolean }

/** The four ratios beside the headline number, each against the year before it. */
export function headlineStats(): HeadlineStat[] {
  const YEAR = movements(MONTHS, 0)
  const YEAR_BEFORE = movements(MONTHS * 2, MONTHS)
  const arpu = closingMrr() / paying(CLOSE_AT)
  const wasArpu = bookAt(YEAR_AGO_AT) / paying(YEAR_AGO_AT)
  const churn = YEAR.lost / YEAR.opening
  const wasChurn = YEAR_BEFORE.lost / YEAR_BEFORE.opening
  const kept = retained(YEAR)
  // What an account is worth at a churn rate: the months it lasts, times what
  // it pays for each of them.
  const months = (rate: number) => (rate > 0 ? 12 / rate : 36)
  const ltv = arpu * months(churn)
  const wasLtv = wasArpu * months(wasChurn)

  return [
    { label: "ARPU", value: money(arpu), delta: arpu / wasArpu - 1 },
    {
      label: "Gross churn",
      // A fall is the win, so the delta is read the other way up.
      value: formatPercent(churn, { maximumFractionDigits: 1 }),
      delta: wasChurn > 0 ? churn / wasChurn - 1 : 0,
      positiveIsGood: false,
    },
    { label: "Lifetime value", value: money(ltv), delta: ltv / wasLtv - 1 },
    {
      label: "Gross retention",
      value: formatPercent(kept, { maximumFractionDigits: 0 }),
      delta: kept / retained(YEAR_BEFORE) - 1,
    },
  ]
}

export type RevenueWindow = {
  key: "1m" | "3m" | "12m"
  /** How the window reads in a sentence, e.g. "last quarter". */
  label: string
  won: number
  lost: number
  net: number
  /** Net new as a share of what the book opened the window at. */
  growth: number
  /** Each against the same length of book immediately before it. */
  wonDelta: number
  lostDelta: number
  netDelta: number
  growthDelta: number
}

// The three windows a revenue conversation actually compares. Month counts,
// not dates: the book is walked month by month, and a part-finished month is
// mostly trials that have not started paying.
const WINDOWS = [
  { key: "1m", label: "last month", months: 1 },
  { key: "3m", label: "last quarter", months: 3 },
  { key: "12m", label: "last year", months: MONTHS },
] as const

/**
 * What the book won and lost over each window, and how that compares with the
 * same length of book immediately before it — the same two movements the
 * waterfall draws, read at three lengths so the page can be asked a different
 * question without a round trip.
 */
export function revenueWindows(): RevenueWindow[] {
  const ratio = (now: number, before: number) => (before > 0 ? now / before - 1 : 0)

  return WINDOWS.map(({ key, label, months }) => {
    const span = movements(months, 0)
    const before = movements(months * 2, months)
    const growth = span.opening > 0 ? span.net / span.opening : 0
    const wasGrowth = before.opening > 0 ? before.net / before.opening : 0

    return {
      key,
      label,
      won: Math.round(span.won),
      lost: Math.round(span.lost),
      net: Math.round(span.net),
      growth,
      wonDelta: ratio(span.won, before.won),
      lostDelta: ratio(span.lost, before.lost),
      netDelta: ratio(span.net, before.net),
      growthDelta: ratio(growth, wasGrowth),
    }
  })
}

const DAY_MS = 86_400_000
const MONTH_NAME = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })

// The quarter runs to the end of its third month, not to the start of it.
const QUARTER_END = new Date(
  Date.UTC(REFERENCE_DATE.getUTCFullYear(), Math.floor(REFERENCE_DATE.getUTCMonth() / 3) * 3 + 3, 1) - 1
)
/** Everything the headline card prints: the closing book, how it moved, who pays it, and the four ratios. */
export type RevenueHeadline = {
  mrr: number
  /** On the month before. */
  delta: number
  /** On the same close a year earlier. */
  yoy: number
  customers: number
  stats: HeadlineStat[]
}

/** The headline card's figures, read here and handed to the card: the card is an island, and never reads the book. */
export function revenueHeadline(): RevenueHeadline {
  const mrr = closingMrr()
  return {
    mrr,
    delta: mrr / Math.round(bookAt(OPENING_AT)) - 1,
    yoy: mrr / bookAt(YEAR_AGO_AT) - 1,
    customers: db.customers.all().length,
    stats: headlineStats(),
  }
}

const QUARTER_LABEL = `Q${Math.floor(REFERENCE_DATE.getUTCMonth() / 3) + 1}`
// Months into the quarter so far, so the opening is the quarter's own start.
const quarterOpening = () => bookAt(monthStart(REFERENCE_DATE.getUTCMonth() % 3))

/** Whole days left in the quarter, counting the rest of today. */
export const QUARTER_DAYS_LEFT = Math.floor((QUARTER_END.getTime() - REFERENCE_DATE.getTime()) / DAY_MS)

/** Net new MRR booked this quarter, against the number the board was given. */
export function quarterGoal() {
  const opening = quarterOpening()
  const current = Math.round(closingMrr() - opening)
  const months = REFERENCE_DATE.getUTCMonth() % 3
  return {
    label: `${QUARTER_LABEL} net new MRR`,
    current,
    // A round number a little beyond the run rate: a target, not a forecast.
    goal: Math.ceil((current * 1.55) / 1_000) * 1_000,
    milestones: Array.from({ length: Math.max(0, months - 1) }, (_, index) => ({
      value: Math.round(bookAt(monthStart(months - 1 - index)) - opening),
      label: MONTH_NAME.format(monthStart(months - index)).slice(0, 3),
    })),
    deadline: `Quarter closes ${QUARTER_END.getUTCDate()} ${MONTH_NAME.format(QUARTER_END)}`,
  }
}

/**
 * The book the quarter has to close on: what it opened at plus the number the
 * board was given. The goal itself is net new, so a chart of the whole book
 * plots this rather than the difference — the same target, read on the scale
 * the chart is already on.
 */
export function quarterTarget(): { value: number; label: string } {
  return { value: Math.round(quarterOpening() + quarterGoal().goal), label: `${QUARTER_LABEL} target` }
}

/** The month the book is read at, e.g. "August 2026". */
export function bookAsOf(): string {
  return `${MONTH_NAME.format(monthStart(1))} ${monthStart(1).getUTCFullYear()}`
}


export type TopCustomer = {
  company: string
  plan: string
  owner: string
  mrr: number
  /** Change in that account's MRR on the month, as a ratio. */
  delta: number
}

const TOP_COUNT = 8
const YEAR_MS = 365 * 86_400_000

// Growth decays with an account's age, and the floor is what it decays past:
// the two meet at about fifteen months, which is where an account stops adding
// seats faster than it loses them.
const GROWTH_AT_NEW = 0.26
const SETTLED_DRIFT = 0.078

/**
 * What an account did on the month. A subscription carries one rate rather than
 * a history of them, so this is a rule over its age: an account still in its
 * first year is adding seats fast, and one past its second is settled and
 * drifting slightly down. A book this size holds both, so the column does too.
 */
function monthOnMonth(subscription: Subscription): number {
  const years = (CLOSE_AT.getTime() - subscription.startedAt.getTime()) / YEAR_MS
  return Math.round((GROWTH_AT_NEW * Math.exp(-years) - SETTLED_DRIFT) * 1000) / 1000
}

/** The largest accounts by MRR, with what each did on the month. */
export function topCustomers(): TopCustomer[] {
  // Read per call, never held at module scope: an account or an owner written
  // since the server started is what the next render shows.
  const members = new Map(db.members.all().map((member) => [member.id, member]))
  const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
  const prices = seatPrices()
  return subscriptions()
    .filter((subscription) => live(subscription, CLOSE_AT))
    .sort((a, b) => billedCents(b, prices) - billedCents(a, prices))
    .slice(0, TOP_COUNT)
    .map((subscription) => {
      const customer = customers.get(subscription.customerId)
      return {
        company: customer?.company ?? subscription.customerId,
        plan: PLAN_NAME_BY_ID.get(subscription.planId) ?? "—",
        owner: members.get(customer?.owner ?? "")?.name ?? "Unassigned",
        mrr: billedCents(subscription, prices) / 100,
        delta: monthOnMonth(subscription),
      }
    })
}

/** What those accounts add up to, as a share of the whole book. */
export function topCustomersShare(rows: TopCustomer[]): number {
  return rows.reduce((sum, customer) => sum + customer.mrr, 0) / closingMrr()
}

/**
 * How much of what customers pay for they actually use — the expansion signal.
 * Seats are counted off the subscriptions against what their plans include;
 * events and storage have no entity, so they come off `seeded("dashboard-02-usage")`.
 */
export function committedUsage() {
  const rand = seeded("dashboard-02-usage")
  // Seats bought is what the accounts pay for; how many of them are in use is
  // not something a subscription records, and nor are events or storage.
  const prices = seatPrices()
  const seats = subscriptions()
    .filter((subscription) => live(subscription, CLOSE_AT) && billedCents(subscription, prices) > 0)
    .reduce((sum, subscription) => sum + subscription.seats, 0)
  const used = (limit: number, floor: number, spread: number) => limit * (floor + rand() * spread)

  return [
    { label: "Seats", used: Math.round(used(seats, 0.82, 0.1)), limit: seats, unit: "seats", compact: false },
    { label: "Events", used: Math.round(used(12e6, 0.62, 0.12)), limit: 12e6, unit: "events", compact: true },
    { label: "Storage", used: Math.round(used(10, 0.58, 0.14) * 10) / 10, limit: 10, unit: "TB", compact: false },
  ]
}

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

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 Revenue dashboard page