RevenueTracker
- Status
- Implemented
- Family
- 13 of 13
- Registry
- @lopifi/revenue-tracker
npx shadcn@latest add @lopifi/theme @lopifi/revenue-trackerThe four-layer spec
Revenue by stream, period by period, with the four questions an operator actually asks: how much this period, versus what, from where, and how concentrated.
The summary tiles are computed from the same array the grid cells are drawn from — there is no second data path, because a header KPI that disagrees with the table beneath it is the fastest way to lose a reader. Total revenue is summed here rather than accepted as a prop for the same reason.
Month-on-month and year-on-year, together: a month-on-month read of a seasonal business is noise, and a year-on-year read of a fast-growing one is stale.
The run-rate tile is labelled a projection from one period. Multiplying a month by twelve is not ARR — it takes no view of churn, seasonality or contract length — and the tile says so rather than letting the figure travel into a deck as though it did.
One stream over a third of revenue is flagged in words. Concentration is a fact about the risk in the business, not about the month.
Lifted verbatim from Metrics & analysis. The doc and this page are the same paragraphs, so they cannot disagree.
What lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
- Files
components/ledger/revenue-tracker.tsx
- Lopifi dependencies · 15
- clipboardcncolumn-managerformat-moneyiconsinfo-tipmoneymoney.typespercentperiod-changeperiod-gridtooltipuse-column-widthsuse-grid-navuse-scroll-edges
- npm packages
- @base-ui-components/reactclass-variance-authorityclsxtailwind-merge
- Registry entry
- /r/revenue-tracker.json
The whole component
No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.
"use client";
import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import { compoundGrowth, periodChange, runRate, shareOfTotal, trailingAverage } from "@/lib/ledger/period-change";
import { InfoTip } from "@/components/ledger/info-tip";
import { Money } from "@/components/ledger/money";
import { Percent } from "@/components/ledger/percent";
import type { PeriodColumn, PeriodRow } from "@/components/ledger/period-grid";
import { PeriodGrid } from "@/components/ledger/period-grid";
export interface RevenueStream {
/** "Subscription", "Services", "Usage" — grouped in the mix breakdown. */
category?: string;
id: string;
label: string;
/** 1–3. A stream, a product inside it, a plan inside that. */
level?: number;
/** One entry per period. `null` is "not posted", never 0. */
values: (number | null)[];
}
export interface RevenueTrackerProps {
/** Index of the last posted period. Everything after it is a projection. */
actualThrough?: number;
/** "Recognised revenue, accrual basis" — stated, never assumed. */
basis?: string;
className?: string;
currency: string;
/** Resolved period labels, oldest first. */
periods: string[];
/** Prior-year figures for the same periods, if the entity is old enough. */
priorYear?: (number | null)[];
storageKey?: string;
streams: RevenueStream[];
}
/**
* Revenue by stream, period by period, with the four questions an operator
* actually asks about it.
*
* The grid is the component; the strip above it is a summary of the grid's
* last posted column, and every figure in it is computed from the same array
* the cells are drawn from. There is no second data path — a header KPI that
* disagrees with the table beneath it is the fastest way to lose a reader.
*
* The four questions, in the order they get asked:
*
* 1. **How much, this period.** The total of the last posted column.
* 2. **Versus what.** Month on month, and against the same period last year
* when there is a prior year to compare to. Both, because a month-on-month
* read of a seasonal business is noise, and a year-on-year read of a fast
* one is stale.
* 3. **From where.** The mix. `% of` in the grid's own view switch turns
* every cell into its share of total revenue for that column.
* 4. **How concentrated.** One stream over a third of revenue is a fact about
* the risk in the business, and it is flagged rather than left to be
* noticed.
*
* What it will not do: annualise a month and call it ARR without saying so.
* The run-rate tile is labelled a projection from one period, because that is
* what multiplying a month by twelve produces.
*/
export function RevenueTracker({
periods,
streams,
currency,
actualThrough,
priorYear,
storageKey,
basis = "Recognised revenue",
className,
}: RevenueTrackerProps) {
const lastActual = actualThrough ?? periods.length - 1;
/* The total is computed here rather than accepted as a prop. A "total
revenue" that is passed in is a number that can disagree with the rows
above it, and the first time it does, every figure on the page is in
question. Nulls do not contribute and do not zero the total; a column
with nothing posted has no total. */
const totals = React.useMemo(
() =>
periods.map((_, i) => {
const present = streams.map((s) => s.values[i]).filter((v): v is number => v != null);
return present.length ? present.reduce((a, b) => a + b, 0) : null;
}),
[periods, streams],
);
const columns: PeriodColumn[] = React.useMemo(
() =>
periods.map((p, i) => ({
id: p,
kind: i > lastActual ? "forecast" : "actual",
label: p,
locked: i === lastActual,
})),
[periods, lastActual],
);
const rows: PeriodRow[] = React.useMemo(() => {
const streamRows: PeriodRow[] = streams.map((s) => ({
id: s.id,
label: s.label,
level: s.level ?? 1,
note: s.category,
polarity: "standard",
values: s.values,
}));
const out: PeriodRow[] = [
...streamRows,
{
derived: true,
id: "__total",
label: "Total revenue",
polarity: "standard",
values: totals,
variant: "total",
},
{
derived: true,
id: "__growth",
label: "Growth on prior period",
polarity: "standard",
unit: "percent",
values: totals.map((v, i) => {
const change = periodChange(v, i > 0 ? totals[i - 1] : null);
/* Percent rows are held scaled by 100, like every other figure in
the grid — `null` where the ratio does not exist rather than a
zero that would read as "flat". */
return change.relative == null ? null : Math.round(change.relative * 100);
}),
variant: "ratio",
},
];
if (priorYear) {
out.push(
{
derived: true,
id: "__prior-year",
label: "Same period, prior year",
polarity: "standard",
values: priorYear,
variant: "ratio",
},
{
derived: true,
id: "__yoy",
label: "Growth on prior year",
polarity: "standard",
unit: "percent",
values: totals.map((v, i) => {
const change = periodChange(v, priorYear[i] ?? null);
return change.relative == null ? null : Math.round(change.relative * 100);
}),
variant: "ratio",
},
);
}
return out;
}, [streams, totals, priorYear]);
/* ── The strip ───────────────────────────────────────────────────── */
const current = totals[lastActual] ?? null;
const mom = periodChange(current, lastActual > 0 ? totals[lastActual - 1] : null);
const yoy = periodChange(current, priorYear?.[lastActual] ?? null);
const _ttm = trailingAverage(totals.slice(0, lastActual + 1), 12);
const projected = runRate(current);
/* Compound growth across the posted columns — the honest version of "we are
growing X% a month", which is not the last month's jump. */
const cagr = compoundGrowth(totals[0] ?? null, current, Math.max(1, lastActual));
const concentration = React.useMemo(() => {
if (current == null) return null;
const ranked = streams
.map((s) => ({ label: s.label, value: s.values[lastActual] ?? null }))
.filter((s): s is { label: string; value: number } => s.value != null)
.sort((a, b) => b.value - a.value);
const top = ranked[0];
if (!top) return null;
const share = shareOfTotal(top.value, current);
return share == null ? null : { label: top.label, share };
}, [streams, current, lastActual]);
return (
<div className={cn("space-y-4", className)}>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Tile
definition={`${basis}. The total of the column, not a separate figure.`}
label={`Revenue · ${periods[lastActual]}`}
>
<Money size="md" value={current == null ? null : { amount: current, currency }} />
</Tile>
<Tile
definition={
mom.relative == null
? "No percentage — the prior period was zero, negative or unposted."
: `Against ${periods[lastActual - 1] ?? "the prior period"}.`
}
label="On prior period"
>
<span className="flex items-baseline gap-2">
<Percent
size="md"
value={mom.relative == null ? null : { kind: "relative", value: mom.relative }}
/>
{mom.absolute != null ? (
<Money accounting size="xs" tone="secondary" value={{ amount: mom.absolute, currency }} />
) : null}
</span>
</Tile>
<Tile
definition={
priorYear
? "The same period twelve months back — the read that survives seasonality."
: "No prior year supplied. A year-on-year figure is not inferred from a shorter history."
}
label="On prior year"
>
<Percent
size="md"
value={yoy.relative == null ? null : { kind: "relative", value: yoy.relative }}
/>
</Tile>
<Tile
definition="One posted period multiplied by twelve. A projection, not a booking, and not ARR — it takes no view of churn, seasonality or contract length."
label="Annualised run-rate"
>
<Money
size="md"
tone="secondary"
value={projected == null ? null : { amount: projected, currency }}
/>
</Tile>
</div>
<PeriodGrid
actualThrough={actualThrough}
caption={`Revenue by stream, ${periods[0]} to ${periods[periods.length - 1]}`}
columns={columns}
currency={currency}
footnote={
<span className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span>
{basis} · {periods.length} periods · rows marked ƒ are computed here, not read from the ledger.
</span>
{cagr != null ? (
<span>
Compound growth across the posted columns:{" "}
<span className="numeric font-medium text-text-secondary">{cagr.toFixed(1)}%</span> per
period.
</span>
) : null}
</span>
}
labelHeader="Stream"
labelWidth={240}
rows={rows}
shareBase="__total"
storageKey={storageKey}
/>
{concentration && concentration.share >= 30 ? (
<p className="flex items-start gap-2 rounded-sm bg-warning-subtle px-3 py-2 text-body">
<span aria-hidden="true">▲</span>
<span>
<span className="font-medium">{concentration.label}</span> is{" "}
<span className="numeric">{concentration.share.toFixed(1)}%</span> of revenue in{" "}
{periods[lastActual]}. Concentration above a third is a fact about the risk in the business, not a
fact about the month — it belongs in the commentary either way.
</span>
</p>
) : null}
</div>
);
}
function Tile({
label,
definition,
children,
}: {
label: string;
definition: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-md border border-border bg-surface p-4">
<span className="flex items-center gap-1 text-label font-medium text-text-secondary">
{label}
<InfoTip about={label}>{definition}</InfoTip>
</span>
<span className="mt-1 block">{children}</span>
</div>
);
}
/* Re-exported so a caller can build rows for a bespoke grid without reaching
past this module into the grid's own types. */
export type { GridUnit } from "@/components/ledger/period-grid";
The accounting it encodes
Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.
Also in Metrics & analysis
- MetricDefinitionEvery metric requires one. Formula with values substituted.
- NonGaapReconciliationEBITDA below net income, every adjustment named.Spec
- MrrBridgeThe movement, not a single number with a percentage.Spec
- BudgetVsActualPolarity-aware, materiality-filtered, versions tracked.
- ForecastTableProjections dashed and labeled everywhere.
- CovenantMonitorThe contractual definition, not the generic one.Spec
- KpiHealthStripProvisional marking for pre-close numbers.Spec
- ExpenseTrackerSpend by category on three bases. Polarity derived per row from the sign convention.
- DriverModelThe inputs a forecast is built from, and what they produce.
- ScenarioCompareScenarios side by side, all measured against the base.
- PlByDimensionThe P&L cut by department, entity or product.
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Metrics & analysis — the family specThe four-layer specification this component was built against, before it was written.Doc
- Financial metric components: definitions and varianceThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
- ARRARR is a recurring-revenue metric, calculated as MRR × 12.Term
Elsewhere in the system
Where RevenueTracker sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.
Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.
Told when Metrics & analysis changes
Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.