Skip to content

RevenueTracker

Revenue by stream, with mix, growth and concentration.
Implemented
Status
Implemented
Family
13 of 13
Registry
@lopifi/revenue-tracker
npx shadcn@latest add @lopifi/theme @lopifi/revenue-tracker

The 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.

components/ledger/revenue-tracker.tsx
"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

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

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.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.