Sextant
← Catalog
Block · finance

Rolling Forecast vs Plan

Plan vs actual vs forecast by period with a Holt re-forecast of open periods and an attainment/bias narrative.

State
Size
Mode

Acme MRR rolling forecast · 2026

99%

of plan attained · -0.1% forecast bias

  • Plan
  • Actual
  • Re-forecast

MRR is tracking 99% of plan in 2026, on plan.

Acme MRR rolling forecast — 2026
PeriodPlanForecastActual
Q1 2026$4.2M$4.28M$4.32M
Q2 2026$4.58M$4.41M$4.38M
Q3 2026$4.95M$4.72M
Q4 2026$5.38M$5.31M

Acme analytics · updated daily via FP&A model

Demo data is illustrative. Replace with your own typed data prop.

How it computes

Forecast attainment and bias, with a Holt(α=0.4, β=0.3) re-forecast of the open periods.

attainment = latest(actual||forecast)/plan;  bias = mean((forecast − actual)/actual) over closed periods;  reforecast = holtLinear(actuals).forecast

Assumptions

  • A period is "closed" only when both a forecast and a non-zero actual exist.
  • Holt's additive trend fits the series (no seasonality or multiplicative effects).
  • Only actuals feed the re-forecast; original forecasts are used only to measure bias.

Honest about

  • The re-forecast cone widens by the ETS(A,A,N) SE σ·√(1 + Σ(α+jβ)²), faster than √h — honest about a trend model's compounding uncertainty.
  • With a single closed period, bias is that one ratio with no smoothing.
  • It's pure time-series extrapolation: no macro or product drivers enter the model.

Reference: Hyndman & Athanasopoulos, Forecasting: Principles and Practice

Use it with your data

Data contract

Passed via the data prop — an array of objects: validated at runtime, so a bad shape degrades to the error state rather than crashing.

FieldTypeRequired
periodstringyes
plannumberyes
forecastnumberno
actualnumberno

Static props

import { FpaRollingForecast } from "@/components/blocks/fpa-rolling-forecast/fpa-rolling-forecast";

<FpaRollingForecast data={myData} />

Client fetch (SWR)

const { data, isLoading, error } = useSWR("/api/metric", fetcher);
// route isLoading/error through <ChartStates>, then:
<FpaRollingForecast data={data} />

Server component (RSC)

const data = await fetch(url, {
  next: { revalidate: 3600 },
}).then((r) => r.json());

<FpaRollingForecast data={data} />

Map arbitrary rows

import { mapRows } from "@/lib/sextant/column-map";

const out = mapRows(rows, { /* contractKey: "yourColumn" */ }, dataSchema);
if (out.ok) <FpaRollingForecast data={out.data} />;

Make it yours

Every customization below is an optional prop — omit them all and you get the defaults shown above. Recolor, reformat, restyle, or drop the card chrome without forking the component.

Recolor or rename a series (plan, actual, forecast, reforecast)

<FpaRollingForecast
  series={{ plan: { color: "var(--chart-3)", label: "…" } }}
/>

Format numbers (currency / locale)

const eur = new Intl.NumberFormat("de-DE", {
  style: "currency",
  currency: "EUR",
  notation: "compact",
});

<FpaRollingForecast valueFormatter={(n) => eur.format(n)} />

Replace or hide the narrative

<FpaRollingForecast narrative="Your own one-liner." />

// …or hide it entirely:
<FpaRollingForecast narrative={false} />

Restyle any region

<FpaRollingForecast
  className="max-w-xl"
  classNames={{ body: "bg-muted/20" }}
/>

Drop the card chrome (headless plot)

import { FpaRollingForecastPlot } from "@/components/blocks/fpa-rolling-forecast/fpa-rolling-forecast";

// Just the chart body — bring your own card/layout:
<FpaRollingForecastPlot data={data} config={config} />