Skip to content

DriverModel

The inputs a forecast is built from, and what they produce.
Implemented
Status
Implemented
Family
13 of 13
Registry
@lopifi/driver-model
npx shadcn@latest add @lopifi/theme @lopifi/driver-model

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

components/ledger/driver-model.tsx
"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

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

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.