Sextant
← Catalog
Block · intelligent

Annotated Time Series

A time series that annotates its own peaks, dips, trend shifts and plan crossings, with an auto-generated headline. The self-explaining chart.

State
Size
Mode

Acme · Monthly recurring revenue

$5680k

latest · Revenue

  • Revenue
  • Plan

Revenue peaked at $5.84M in Dec 2025.

Acme · Monthly recurring revenue — Revenue by month with auto-detected annotations
MonthRevenuePlanNote
Jan 20253,100,0003,150,000Trough
Feb 20253,260,0003,280,000
Mar 20253,460,0003,420,000Beats plan
Apr 20253,680,0003,560,000
May 20253,850,0003,710,000
Jun 20254,020,0003,860,000
Jul 20254,180,0004,010,000
Aug 20254,340,0004,160,000
Sep 20254,480,0004,310,000
Oct 20254,620,0004,460,000
Nov 20255,280,0004,610,000
Dec 20255,840,0004,760,000Peak
Jan 20265,420,0004,910,000
Feb 20265,680,0005,060,000

Acme analytics · updated hourly · anomalies auto-detected

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

When to reach for it

Use the Annotated Time Series when a line would make a reader hunt for the story — the month growth crossed plan, the spike, the moment the trend turned. Instead of a bare series, the chart marks its own salient points and writes the headline for you. Reach for it in board decks and exec dashboards where the chart has to explain itself in a glance.

How it thinks

deriveAnnotations() runs entirely deterministically (no LLM) over your series and ranks the features it finds:

  • Peak / trough — the global extremes, scored by how many σ they sit from the mean.
  • Anomaly — points past a z-score threshold, labelled with their σ.
  • Trend shift — the sharpest change in slope (largest second difference).
  • Crosses plan — the first time actual moves above (beats plan) or below (misses plan) the comparison line.
  • Last seen here — the most recent earlier point at today's level.

The top features become collision-avoided callouts on the plot, and the rank-0 feature is handed to summarize() for the one-sentence narrative above the chart — e.g. "Revenue peaked at $225k in Sep 2025."

Good to know

  • Pass a plan value on each point to unlock the crosses-plan annotation; omit it and the engine simply works from the series alone.
  • Annotations are decorative SVG (aria-hidden); the accessible representation is the data table, where each annotated month carries its note in the Note column.
  • Same data in → same annotations out. Nothing is sampled or random, so snapshots and screenshots are stable.

Use it with your data

Data contract

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

FieldTypeRequired
datestringyes
valuenumberyes
plannumberno

Static props

import { ChartAnnotatedTimeseries } from "@/components/blocks/chart-annotated-timeseries/chart-annotated-timeseries";

<ChartAnnotatedTimeseries series={myData} />

Client fetch (SWR)

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

Server component (RSC)

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

<ChartAnnotatedTimeseries series={series} />

Map arbitrary rows

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

const out = mapRows(rows, { /* contractKey: "yourColumn" */ }, dataSchema);
if (out.ok) <ChartAnnotatedTimeseries series={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 (value, plan, annotation)

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

Format numbers (currency / locale)

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

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

Replace or hide the narrative

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

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

Restyle any region

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

Drop the card chrome (headless plot)

import { ChartAnnotatedTimeseriesPlot } from "@/components/blocks/chart-annotated-timeseries/chart-annotated-timeseries";

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