DriverModel
- Status
- Implemented
- Family
- 13 of 13
- Registry
- @lopifi/driver-model
npx shadcn@latest add @lopifi/theme @lopifi/driver-modelThe four-layer spec
The inputs a forecast is built from and the outputs they produce, in one grid with the boundary between them visible. Three separations carry it: actual from projection, input from output, and assumption from arithmetic.
Inputs are typed; outputs carry the ƒ mark and their formula. A model where you cannot tell which is which is a model where nobody can find the assumption that moved the answer.
Only projected inputs accept editing. An actual is what the ledger posted, and a model that lets you type over it will eventually be used to explain a month that never happened.
The named assumptions sit under the grid in words — "new logos hold at 14 a month" — because that is the sentence someone can disagree with, and disagreement is the point of a forecast review. A growth rate buried three rows into a projected column is not.
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/driver-model.tsx
- Lopifi dependencies · 15
- clipboardcncolumn-managerformat-moneyiconsinfo-tipmoneymoney.typesperiod-changeperiod-gridruletooltipuse-column-widthsuse-grid-navuse-scroll-edges
- npm packages
- @base-ui-components/reactclass-variance-authorityclsxtailwind-merge
- Registry entry
- /r/driver-model.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 { InfoTip } from "@/components/ledger/info-tip";
import type { GridUnit, PeriodColumn, PeriodRow } from "@/components/ledger/period-grid";
import { PeriodGrid } from "@/components/ledger/period-grid";
import { Rule } from "@/components/ledger/rule";
export interface ModelDriver {
/** An output of the model rather than an input to it. Marked ƒ, never typed into. */
computed?: boolean;
/** How the figure is arrived at. Rendered under the label, always. */
formula?: string;
id: string;
label: string;
level?: number;
polarity?: "standard" | "inverted" | "neutral";
/** The unit decides the render. A headcount is not a dollar. */
unit?: GridUnit;
/**
* One entry per period, scaled by 100 like every figure in the grid —
* $412,000 is 41_200_000, 38 people is 3800, 12.4% is 1240.
*/
values: (number | null)[];
variant?: "line" | "subtotal" | "total" | "section" | "ratio";
}
export interface DriverModelProps {
/** The index of the last actual. Only periods after it are assumptions. */
actualThrough: number;
/** Named assumptions behind the projection. Stated, never implied. */
assumptions?: { label: string; value: string; rationale?: string }[];
className?: string;
currency: string;
drivers: ModelDriver[];
/**
* A committed edit to a projected input cell, in minor units. The model
* lives with the caller — everything downstream of this cell has to move in
* the same render, and only the caller knows what downstream means.
*/
onDriverEdit?: (edit: { driverId: string; periodIndex: number; value: number }) => void;
periods: string[];
scenario?: { value: string; options: string[]; onChange: (s: string) => void };
storageKey?: string;
}
/**
* The driver model: the inputs a forecast is built from, and the outputs they
* produce, in one grid with the boundary between them visible.
*
* Three separations do all the work here, and a model missing any one of them
* is a spreadsheet with a plausible number at the bottom:
*
* 1. **Actual from projection.** The dashed edge and the shading. Everything
* left of it happened; everything right of it is a belief.
* 2. **Input from output.** An input is typed; an output is computed and
* carries the ƒ mark and its formula. A model where you cannot tell which
* is which is a model where nobody can find the assumption that moved the
* answer.
* 3. **Assumption from arithmetic.** The named assumptions sit under the
* grid in words — "new logos hold at 14 a month" — because that is the
* sentence someone will disagree with, and they cannot disagree with a
* growth rate buried three rows into a projected column.
*
* Only projected inputs accept typing. An actual is what the ledger posted;
* a model that lets you type over it is a model that will eventually be used
* to explain a month that never happened.
*/
export function DriverModel({
periods,
actualThrough,
drivers,
currency,
scenario,
onDriverEdit,
assumptions = [],
storageKey,
className,
}: DriverModelProps) {
const columns: PeriodColumn[] = React.useMemo(
() =>
periods.map((p, i) => ({
id: p,
kind: i > actualThrough ? "forecast" : "actual",
label: p,
locked: i === actualThrough,
})),
[periods, actualThrough],
);
const rows: PeriodRow[] = React.useMemo(
() =>
drivers.map((d) => ({
derived: d.computed,
id: d.id,
label: d.label,
level: d.level ?? 1,
note: d.formula,
polarity: d.polarity ?? "standard",
unit: d.unit ?? "money",
values: d.values,
variant: d.variant ?? (d.computed ? "subtotal" : "line"),
})),
[drivers],
);
const byId = React.useMemo(() => new Map(drivers.map((d) => [d.id, d])), [drivers]);
const periodIndex = React.useMemo(() => new Map(periods.map((p, i) => [p, i])), [periods]);
return (
<div className={cn("space-y-4", className)}>
<PeriodGrid
actualThrough={actualThrough}
caption={`Driver model${scenario ? `, ${scenario.value} scenario` : ""}`}
columns={columns}
currency={currency}
/* Projected inputs only. Never an actual, never an output. */
editable={(row, column) => {
const driver = byId.get(row.id);
const index = periodIndex.get(column.id);
return driver != null && !driver.computed && index != null && index > actualThrough;
}}
footnote={
<>
Columns after <span className="font-medium">{periods[actualThrough]}</span> are assumptions.{" "}
{onDriverEdit ? (
<>
Projected inputs can be typed into — double-click a cell, or press Enter on it. The cell
accepts what a spreadsheet accepts, including <span className="numeric">=1200+340</span> and{" "}
<span className="numeric">1.2k</span>. Actuals and ƒ rows cannot be edited.
</>
) : null}
</>
}
labelHeader="Driver"
labelWidth={260}
onCellEdit={
onDriverEdit
? ({ rowId, columnIndex, value }) =>
onDriverEdit({ driverId: rowId, periodIndex: columnIndex, value })
: undefined
}
rows={rows}
storageKey={storageKey}
toolbar={
scenario ? (
<label className="flex items-center gap-2">
<span className="text-label font-medium text-text-secondary">Scenario</span>
<select
className="h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle"
onChange={(e) => scenario.onChange(e.target.value)}
value={scenario.value}
>
{scenario.options.map((s) => (
<option key={s}>{s}</option>
))}
</select>
</label>
) : null
}
/>
{assumptions.length > 0 ? (
<div className="rounded-xl border border-border bg-surface p-6">
<h3 className="text-title-sm font-medium">
Assumptions
<InfoTip about="Why assumptions are listed in words">
A growth rate sitting in a projected column is arithmetic. The sentence it came from is the
thing a reader can actually disagree with, and disagreement is the whole point of a forecast
review.
</InfoTip>
</h3>
<Rule className="mt-3" variant="subtotal" />
<dl className="mt-3 space-y-3">
{assumptions.map((a) => (
/* One group per assumption, its dt and dds direct children: a
<dl> may hold only those (and the div that groups them). */
<div className="grid grid-cols-[1fr_auto] items-baseline gap-x-4" key={a.label}>
<dt className="text-body">{a.label}</dt>
<dd className="numeric text-body font-medium">{a.value}</dd>
{a.rationale ? (
<dd className="col-span-2 mt-0.5 text-caption text-text-tertiary">{a.rationale}</dd>
) : null}
</div>
))}
</dl>
</div>
) : null}
</div>
);
}
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.
- ExpenseTrackerSpend by category on three bases. Polarity derived per row from the sign convention.
- 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
Elsewhere in the system
Where DriverModel 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.