Skip to content

ExpenseTracker

Spend by category on three bases. Polarity derived per row from the sign convention.
Implemented
Status
Implemented
Family
13 of 13
Registry
@lopifi/expense-tracker
npx shadcn@latest add @lopifi/theme @lopifi/expense-tracker

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

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

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

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.