ExpenseTracker
- Status
- Implemented
- Family
- 13 of 13
- Registry
- @lopifi/expense-tracker
npx shadcn@latest add @lopifi/theme @lopifi/expense-trackerThe four-layer spec
Spend by category on three bases — actual, budget, variance — because they are the same categories asked three questions, and a nine-column grid of all of them is one nobody reads past the third column.
Every row carries a polarity, and the component derives it. An increase in spend is not good news; under budget is favourable and over budget is not. A grid that paints a rising cost green has told the reader the opposite of the truth.
Polarity describes the figure as stored, not the concept behind it, and that is the easiest
thing here to get backwards: this system holds costs as negatives, so a heavier month is a figure
moving down and the row wants standard. A model holding the same costs as positive
magnitudes wants inverted. ExpenseTracker takes a sign and works it out itself rather than
asking its caller to reason about a storage convention to get the colours the right way round.
Committed-but-unbilled is shown beside the total, never folded into it. A purchase order is not an expense — accruing it silently is a cutoff error, and showing it nowhere is how a category with six figures of open POs looks under budget right up until the invoices land.
The movers list ranks by amount, not by percentage. A line that doubled from 400 to 800 is not the story of a month in which another went from 180,000 to 240,000.
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/expense-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/expense-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 type { Polarity } from "@/lib/ledger/money.types";
import { magnitudeShare, periodChange } 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 ExpenseCategory {
/** The approved budget for the same periods, if there is one. */
budget?: (number | null)[];
/** Committed but unbilled — an open PO the period has not seen yet. */
committed?: (number | null)[];
id: string;
label: string;
level?: number;
/** The owner who will be asked about the variance. */
owner?: string;
/** Actual spend per period. `null` is "not posted", never 0. */
values: (number | null)[];
}
export type ExpenseBasis = "actual" | "budget" | "variance";
export interface ExpenseTrackerProps {
actualThrough?: number;
categories: ExpenseCategory[];
className?: string;
currency: string;
materiality?: { absolute: number; relative: number };
periods: string[];
/** Revenue for the same periods, so spend can be read as a share of it. */
revenue?: (number | null)[];
/**
* How spend is held. `negative` is this system's convention — a cost renders
* as `($220,000.00)` — and it is the default.
*
* It exists because polarity describes the figure as stored, not the concept
* behind it, and that is the easiest thing in this file to get backwards: a
* heavier month held as a negative is a figure moving *down*. The component
* derives the polarity from this rather than asking the caller to reason it
* out per row, because every row here is an expense and the answer is the
* same for all of them.
*/
sign?: "negative" | "positive";
storageKey?: string;
}
const BASES: { id: ExpenseBasis; label: string; hint: string }[] = [
{ hint: "What was posted", id: "actual", label: "Actual" },
{ hint: "What was approved", id: "budget", label: "Budget" },
{ hint: "Actual less budget, by period", id: "variance", label: "Variance" },
];
/**
* Spend by category, period by period, on whichever basis is being asked
* about.
*
* Expense rows are `polarity="inverted"` throughout, and that single line is
* the reason this component exists as something other than a table: an
* increase in spend is not good news, and a grid that paints it green is a
* grid that has told the reader the opposite of the truth. Under-budget is
* favourable; over-budget is not; the colours follow, and the sign is always
* present so the colour is never carrying it alone.
*
* Three bases rather than three tables. Actual, budget and variance are the
* same categories asked three questions, and putting all of them on screen at
* once produces a nine-column grid nobody reads past the third column.
*
* Committed-but-unbilled is shown as a note under the total rather than
* folded into it. A purchase order is not an expense — accruing it silently
* into the month's spend is a real cutoff error, and showing it nowhere is
* how a category with a booked but unbilled six figures looks under budget
* right up until the invoice lands.
*/
export function ExpenseTracker({
periods,
categories,
currency,
sign = "negative",
revenue,
actualThrough,
materiality,
storageKey,
className,
}: ExpenseTrackerProps) {
const [basis, setBasis] = React.useState<ExpenseBasis>("actual");
/* Costs held as negatives: a heavier month is a smaller figure, so down is
bad and the row is `standard`. Held as positive magnitudes, up is bad and
the row is `inverted`. Either way, more spend reads as unfavourable —
which is the only outcome that matters. */
const polarity: Polarity = sign === "negative" ? "standard" : "inverted";
const lastActual = actualThrough ?? periods.length - 1;
const hasBudget = categories.some((c) => c.budget != null);
const effectiveBasis: ExpenseBasis = hasBudget ? basis : "actual";
const valuesFor = React.useCallback(
(category: ExpenseCategory): (number | null)[] =>
periods.map((_, i) => {
const actual = category.values[i] ?? null;
const budget = category.budget?.[i] ?? null;
if (effectiveBasis === "actual") return actual;
if (effectiveBasis === "budget") return budget;
/* A variance needs both sides. Missing either one is not a variance
of the amount that is present — it is an unanswerable question, and
`null` is how this system says so. */
return actual == null || budget == null ? null : actual - budget;
}),
[periods, effectiveBasis],
);
const columns: PeriodColumn[] = React.useMemo(
() =>
periods.map((p, i) => ({
id: p,
kind: i > lastActual ? "forecast" : "actual",
label: p,
locked: i === lastActual,
})),
[periods, lastActual],
);
const totals = React.useMemo(
() =>
periods.map((_, i) => {
const present = categories.map((c) => valuesFor(c)[i]).filter((v): v is number => v != null);
return present.length ? present.reduce((a, b) => a + b, 0) : null;
}),
[periods, categories, valuesFor],
);
const rows: PeriodRow[] = React.useMemo(() => {
const out: PeriodRow[] = categories.map((c) => ({
id: c.id,
label: c.label,
level: c.level ?? 1,
note: c.owner,
/* Every expense row carries it, without exception. */
polarity,
values: valuesFor(c),
}));
out.push({
derived: true,
id: "__total",
label: effectiveBasis === "variance" ? "Total variance" : "Total operating expense",
polarity,
values: totals,
variant: "total",
});
if (revenue && effectiveBasis !== "variance") {
out.push({
derived: true,
id: "__of-revenue",
label: "As a share of revenue",
/* The share is a magnitude, so a rising share is always the bad
direction regardless of how the underlying spend is signed. */
polarity: "inverted",
unit: "percent",
values: totals.map((v, i) => {
/* A magnitude: operating expense of −220,000 against revenue of
412,000 is 53.4% of revenue. Nobody says a cost is negative
fifty-three percent of revenue. */
const share = magnitudeShare(v, revenue[i] ?? null);
return share == null ? null : Math.round(share * 100);
}),
variant: "ratio",
});
}
return out;
}, [categories, valuesFor, totals, revenue, effectiveBasis, polarity]);
/* ── The movers ───────────────────────────────────────────────────
The three categories that moved most between the last two posted
columns, by absolute amount rather than by percentage. A category that
doubled from 400 to 800 is not the story of a month in which one went
from 180,000 to 240,000, and ranking by percentage puts it at the top. */
const movers = React.useMemo(() => {
if (lastActual < 1) return [];
return categories
.map((c) => {
const values = valuesFor(c);
const change = periodChange(values[lastActual] ?? null, values[lastActual - 1] ?? null, polarity);
return { change, label: c.label, owner: c.owner };
})
.filter((m) => m.change.absolute != null && m.change.absolute !== 0)
.sort((a, b) => Math.abs(b.change.absolute ?? 0) - Math.abs(a.change.absolute ?? 0))
.slice(0, 3);
}, [categories, valuesFor, lastActual, polarity]);
const committedTotal = React.useMemo(() => {
const present = categories
.map((c) => c.committed?.[lastActual] ?? null)
.filter((v): v is number => v != null);
return present.length ? present.reduce((a, b) => a + b, 0) : null;
}, [categories, lastActual]);
return (
<div className={cn("space-y-4", className)}>
<PeriodGrid
actualThrough={actualThrough}
caption={`Operating expense by category, ${effectiveBasis} basis`}
columns={columns}
currency={currency}
footnote={
<span className="flex flex-wrap items-center gap-x-4 gap-y-1">
<span>
Every row carries its polarity, derived from how spend is signed here — heavier spend reads as
unfavourable and a variance under budget reads as favourable, whichever convention the figures
arrive in.
</span>
{committedTotal != null ? (
<span className="flex items-center gap-1">
Committed but unbilled in {periods[lastActual]}:{" "}
<Money size="xs" tone="inherit" value={{ amount: committedTotal, currency }} />
<InfoTip about="Committed but unbilled">
Open purchase orders. Not an expense until the goods or service arrive, so it is not in the
total above — but a category that looks under budget with this outstanding is not under
budget.
</InfoTip>
</span>
) : null}
</span>
}
labelHeader="Category"
labelWidth={240}
materiality={materiality}
rows={rows}
shareBase="__total"
storageKey={storageKey}
toolbar={
hasBudget ? (
<div
aria-label="Basis"
className="inline-flex overflow-hidden rounded-sm border border-border"
role="group"
>
{BASES.map((b) => (
<button
aria-pressed={effectiveBasis === b.id}
className={cn(
"h-8 border-r border-border px-2.5 text-label transition-colors duration-1 ease-standard last:border-r-0",
effectiveBasis === b.id
? "bg-accent text-accent-fg"
: "bg-surface text-text-secondary hover:bg-surface-hover",
)}
key={b.id}
onClick={() => setBasis(b.id)}
title={b.hint}
type="button"
>
{b.label}
</button>
))}
</div>
) : null
}
/>
{movers.length > 0 && effectiveBasis === "actual" ? (
<div className="rounded-xl border border-border bg-surface p-4">
<h3 className="text-label font-medium text-text-secondary">
Largest movements into {periods[lastActual]}
<InfoTip about="How movers are ranked">
Ranked by amount, not by percentage. A line that doubled from 400 to 800 is not the story of a
month in which another went from 180,000 to 240,000.
</InfoTip>
</h3>
<ul className="mt-2 space-y-1">
{movers.map((m) => (
<li className="flex items-baseline justify-between gap-3 text-body" key={m.label}>
<span>
{m.label}
{m.owner ? <span className="ml-2 text-caption text-text-tertiary">{m.owner}</span> : null}
</span>
<span
className={cn(
"flex items-baseline gap-2",
m.change.direction === "unfavourable" ? "text-negative" : "text-positive",
)}
>
<Money accounting tone="inherit" value={{ amount: m.change.absolute ?? 0, currency }} />
<Percent
className="text-inherit"
value={m.change.relative == null ? null : { kind: "relative", value: m.change.relative }}
/>
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
);
}
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
- RevenueTrackerRevenue by stream, with mix, growth and concentration.
- 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
- ExpenseOutflows or using up of assets, or incurrences of liabilities, from delivering goods or services — recognized when incurred, not when paidTerm
Elsewhere in the system
Where ExpenseTracker 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.