Skip to content

ForecastTable

Projections dashed and labeled everywhere.
Implemented
Status
Implemented
Family
13 of 13
Registry
@lopifi/forecast-table
npx shadcn@latest add @lopifi/theme @lopifi/forecast-table

The component, rendering

Real data, the shipped component. Switch the theme to check the claims the spec makes below.

Preview

The four-layer spec

Driver-based projections with scenarios. Projections render dashed on every chart and carry a "forecast" label on every figure. Actuals-to-date and forecast-remaining are visually distinct within the same period.

The boundary between what happened and what is believed is drawn in three places, not one.

Between periods. Everything after the last closed period is shaded, ruled with a dashed edge, set in the secondary tone, and headed by a column that says forecast under its label — so every projected figure on screen sits beneath the word rather than merely near it.

Within a period. The month that has started and not finished is not one column; it is three — what has posted, what is expected for the rest, and the landing they sum to. A part-elapsed month reported as a single figure sits beside eight closed ones and inherits their authority, which is the difference between "we are at 41.2 with nine days to go" and "we did 58.2" — the second of which nobody said and everybody quotes. The landing is computed from the other two, never supplied: a caller who provides all three will eventually provide three figures that do not add up, and the grid would render the contradiction without noticing it.

Between scenarios. The scenario in force is named in the caption and in the toolbar. It will not rank them and will not style one as the good case — an unlabelled forecast is indistinguishable from a claim, and a ranked one is a recommendation wearing a forecast's clothes.

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/forecast-table.tsx

Lopifi dependencies · 16
clipboardcncolumn-managerfiscal-periodforecastformat-moneyiconsinfo-tipmoneymoney.typesperiod-changeperiod-gridtooltipuse-column-widthsuse-grid-navuse-scroll-edges
npm packages
@base-ui-components/reactclass-variance-authorityclsxtailwind-merge
Registry entry
/r/forecast-table.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/forecast-table.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import { formatDate } from "@/lib/ledger/fiscal-period";
import { splitInProgress } from "@/lib/ledger/forecast";
import type { Polarity } from "@/lib/ledger/money.types";
import { InfoTip } from "@/components/ledger/info-tip";
import type { GridUnit, PeriodColumn, PeriodRow } from "@/components/ledger/period-grid";
import { PeriodGrid } from "@/components/ledger/period-grid";

export type ForecastRow = {
  id: string;
  label: string;
  /**
   * One entry per period, in minor units. For the in-progress period this is
   * the **landing** — the whole period as currently expected, not the part of
   * it that has posted.
   */
  values: (number | null)[];
  polarity?: Polarity;
  unit?: GridUnit;
  level?: number;
  variant?: "line" | "subtotal" | "total" | "section";
};

/**
 * The period that has started and not finished.
 *
 * The single most misread column in any forecast pack. Half of September is a
 * fact and half of it is a belief, and a grid that renders the month as one
 * figure has quietly picked one of those words for both halves — usually the
 * wrong one, because the figure sits next to eight closed months and inherits
 * their authority.
 */
export type InProgressPeriod = {
  /** Index into `periods` of the period part-way through. */
  index: number;
  /**
   * What has actually posted so far, per row id.
   *
   * **Flow rows only.** A period splits into what happened and what is left to
   * happen for revenue, cost and headcount added — figures that accumulate.
   * It does not split that way for a balance: cash at the 21st and cash at
   * month end are two readings of a stock, not two parts of one, and adding
   * them produces a number that means nothing. Leave a balance row out of this
   * map and it renders its landing alone, which is the whole truth about it.
   */
  toDate: Record<string, number | null>;
  /** The date the actuals run to. Stated, never implied. */
  asOf: Date;
};

export interface ForecastTableProps {
  /**
   * Index of the last **closed** period. Everything after it is a projection.
   * Where a period is part-way through, it is named in `inProgress` rather
   * than folded into either side.
   */
  actualThrough: number;
  className?: string;
  currency: string;
  /** The period that has started but not closed, split three ways. */
  inProgress?: InProgressPeriod;
  onScenarioChange: (s: string) => void;
  periods: string[];
  rows: ForecastRow[];
  scenario: string;
  scenarios: string[];
  /** The row every "% of" figure is taken against — usually revenue. */
  shareBase?: string;
  storageKey?: string;
}

/**
 * Driver-based projections, with the boundary between what happened and what
 * is believed drawn in three places rather than one.
 *
 * 1. **Between periods.** Everything after the last closed period is shaded,
 *    ruled with a dashed edge, set in the secondary tone, and headed by a
 *    column that says `forecast` under its label — so every projected figure
 *    on screen sits beneath the word, not merely near it.
 * 2. **Within a period.** The month that has started and not finished is not
 *    one column. It is three: what has posted, what is expected for the rest,
 *    and the landing they sum to. Reporting a part-elapsed month as a single
 *    figure beside eight closed ones is how a forecast gets quoted as an
 *    actual, and it is the specific error this component exists to prevent.
 * 3. **Between scenarios.** The scenario in force is named in the caption and
 *    in the toolbar. An unlabelled forecast is indistinguishable from a claim.
 *
 * It will not rank the scenarios, and it will not style one as the good case.
 * A scenario is a set of assumptions; deciding which of them to hope for is
 * the reader's job and stating it in the chrome is how a forecast review turns
 * into a negotiation about which column to show the board.
 */
export function ForecastTable({
  periods,
  actualThrough,
  rows,
  currency,
  scenario,
  scenarios,
  onScenarioChange,
  inProgress,
  shareBase,
  storageKey,
  className,
}: ForecastTableProps) {
  /* The in-progress period is expanded in place into three columns. Doing it
     here rather than asking the caller for a pre-split shape keeps `values`
     meaning one thing — the whole period — in every row. */
  const columns: PeriodColumn[] = React.useMemo(() => {
    const out: PeriodColumn[] = [];
    periods.forEach((p, i) => {
      if (inProgress && i === inProgress.index) {
        out.push(
          {
            hint: `Posted through ${formatDate(inProgress.asOf)}`,
            id: `${p}--to-date`,
            kind: "actual",
            label: p,
          },
          { hint: "Expected for the rest of the period", id: `${p}--remaining`, kind: "forecast", label: p },
          { hint: "To date plus remaining", id: `${p}--landing`, kind: "derived", label: p, locked: true },
        );
        return;
      }
      out.push({
        id: p,
        kind: i > actualThrough ? "forecast" : "actual",
        label: p,
        /* The last closed period is the anchor every projection is read
           against, so it is the one column the reader cannot hide out from
           under themselves. */
        locked: i === actualThrough,
      });
    });
    return out;
  }, [periods, actualThrough, inProgress]);

  /* Where the split sits in the expanded column list — the grid's own
     `actualThrough` counts columns, not periods, and the two stop agreeing the
     moment one period becomes three. */
  const actualThroughColumn = React.useMemo(() => {
    if (!inProgress) return actualThrough;
    /* The to-date column is the last thing on screen that has posted. */
    return inProgress.index <= actualThrough ? actualThrough : actualThrough + 1;
  }, [actualThrough, inProgress]);

  const gridRows: PeriodRow[] = React.useMemo(
    () =>
      rows.map((r) => {
        const values: (number | null)[] = [];
        periods.forEach((_, i) => {
          const whole = r.values[i] ?? null;
          if (inProgress && i === inProgress.index) {
            /* A row absent from the map is a row the split does not apply to
               — a balance, or anything else read rather than accumulated. It
               shows its landing and nothing else. `remaining` is derived
               inside `splitInProgress`, never supplied. */
            const split = splitInProgress(whole, inProgress.toDate[r.id]);
            values.push(split.toDate, split.remaining, split.landing);
            return;
          }
          values.push(whole);
        });
        return {
          id: r.id,
          label: r.label,
          level: r.level,
          polarity: r.polarity ?? "standard",
          unit: r.unit,
          values,
          variant: r.variant,
        };
      }),
    [rows, periods, inProgress],
  );

  const inProgressLabel = inProgress ? periods[inProgress.index] : null;

  return (
    <div className={cn("space-y-4", className)}>
      <PeriodGrid
        actualThrough={actualThroughColumn}
        caption={`Forecast, ${scenario} scenario`}
        columns={columns}
        currency={currency}
        footnote={
          <>
            Columns after <span className="font-medium">{periods[actualThrough]}</span> are projections, not
            actuals — they are shaded, ruled with a dashed edge, set in the secondary tone, and headed{" "}
            <span className="font-medium">forecast</span>, so every projected figure sits under the word.
            Nothing after that edge has been posted.
            {inProgress ? (
              <>
                {" "}
                <span className="font-medium">{inProgressLabel}</span> has started and not closed, so it is
                three columns rather than one: posted through {formatDate(inProgress.asOf)}, expected for the
                rest, and the landing they sum to. Only the first of those is a fact.
              </>
            ) : null}
          </>
        }
        labelHeader="Driver"
        rows={gridRows}
        shareBase={shareBase}
        storageKey={storageKey}
        toolbar={
          <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) => onScenarioChange(e.target.value)}
              value={scenario}
            >
              {scenarios.map((s) => (
                <option key={s}>{s}</option>
              ))}
            </select>
          </label>
        }
      />

      {inProgress ? (
        <p className="text-caption text-text-tertiary">
          <span className="font-medium text-text-secondary">
            Why {inProgressLabel} is split
            <InfoTip about="Actual-to-date and forecast-remaining">
              A part-elapsed period reported as one figure sits beside closed months and inherits their
              authority. Splitting it is the difference between &ldquo;we are at 41.2 with nine days to
              go&rdquo; and &ldquo;we did 58.2&rdquo; — the second of which nobody said, and everybody quotes.
            </InfoTip>
          </span>{" "}
          The landing column is to-date plus remaining, computed here rather than supplied, so the three
          figures cannot disagree.
        </p>
      ) : 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 ForecastTable 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.