GestaltBI la forma all’origine del significato

storybook

@gestaltbi/storybook

An interactive report as data: prose, the figures it quotes, and the claims it rests on.

A story is a JSON document, not a component. It says what to compute, what to say about it, and what would have to be true for the saying to hold. resolveStory runs it against a real frame and returns everything a host needs to render — and nothing about how.

Which means the numbers in the finished report are computed from the data every time it is read, and a claim the data no longer supports says so, next to the paragraph that made it.

npm install @gestaltbi/storybook on npm →

A story is data

import { resolveStory } from '@gestaltbi/storybook';

const resolved = resolveStory(story, rows, {
  columnDirectory: directory,   // from @gestaltbi/stream
  opContext: context,           // needed only by pivot and correlate panels
  locale: 'it',
  currency: 'EUR',
});

resolved.status     // 'pass' | 'warn' | 'fail' | 'none'
resolved.chapters   // prose with the numbers already in it, panels, verdicts

The output carries no colours, no components and no markup. A host renders ResolvedStory however it renders anything else, which is why the same story reads the same in a browser, a PDF job or a test.

Figures, and the {{token}} rule

Prose never contains a number. It contains a token naming a figure the chapter declares, and the resolver substitutes the value it computed from the frame.

{
  "id": "volume",
  "title": "How much moved",
  "figures": [
    { "id": "qty", "label": "Units sold", "measure": "sold_quant", "reduce": "sum", "format": "integer" }
  ],
  "prose": ["Across the period the business shifted {{qty}} units."],
  "takeaway": "Volume is the place to start."
}

This is the whole discipline of the format. A sentence written this way stays true when the filter moves, when next month lands, and when somebody points it at a different period — because the author committed to what to measure, never to what it came to.

reducewhat it takes
sum / avg / min / maxover every row in the frame
first / lastin the order of the story’s date column
deltalast minus first
growthlast over first, minus one
countrows — the one reduce that needs no measure

Panels

What is drawn beside the prose. Five kinds, each resolved to plain data.

kindrenders
figuresthe chapter’s own figures as metric cards
seriesone row per period, one line or bar per measure
pivota cross-tab — dimensions down the side, dimensions across the top
correlateranked associations between columns
tablenamed columns, straight through
{
  "kind": "pivot",
  "format": "percent",
  "options": {
    "rows": ["category"],
    "measure": "calc:fail_rate",
    "type": "ratio", "numerator": "failed", "denominator": "resolved",
    "totals": true
  }
}

format says how the cells should be printed — a rate rendered raw is 0.1881408827463219, which is correct and unreadable. The host formats; the story only says what with, the same division of labour as FigureSpec.format.

A report that can be contradicted

A narrative that its own data cannot argue with is marketing. Chapters carry checks from @gestaltbi/stream; they run every time the story is resolved, and a failure is reported next to the paragraph that made the claim.

"checks": [
  { "id": "covers", "type": "covers", "label": "Revenue covered infrastructure, every month",
    "measure": "recognized_revenue", "by": "aws_cost" }
]

A chapter's status is the worst verdict in it, and the story's is the worst of its chapters — none when it asserts nothing at all. In the published Everpix report two checks fail on purpose: revenue never covered AWS, and the accrual margin was never positive. That is the report proving its own thesis rather than asserting it.

Fitting a story to a dataset

A story is written against one dataset's vocabulary. Pointing it at another does not throw — figures come back absent, checks skip — but the result is a page of em dashes, which reads like a bug rather than a mismatch. Ask first.

import { missingColumns } from '@gestaltbi/storybook';

const missing = missingColumns(story, declaredColumns);
if (missing.length) {
  // "This story was written for a different dataset", and name them.
}

requiredColumns walks figures, every panel kind and every check, so it sees a column named in a pivot's denominator as readily as one in a paragraph.

Where stories come from

This package ships none. A story belongs with the data it reads, so a GestaltBI config repo keeps story.json beside processing.json and owns its narrative outright — see Kickstarter Intelligence for a hand-written one.

@gestaltbi/inference writes them too: composeStory asks a model for this format, grounds it against the dataset profile, and hands back a Story ready to resolve. The model chooses what to compute and never learns what it came to — which is the same {{token}} rule, enforced from the other side.

Reference

Extracted from src/index.ts when this page was built, so it cannot drift from what the package actually exports.

Functions

formatFigure

fn
function formatFigure(value: number | null, format: FigureFormat = 'number', o: FormatOptions = {}): string

Turn a number into the string a sentence can carry.

Deliberately conservative: a story is read, not audited, so figures round to what a person would say out loud. The unrounded value stays on the figure for anything that needs it.

interpolate

fn
function interpolate(text: string, figures: Map<string, string>): string

Replace {{id}} with a figure.

An unknown token is left standing rather than blanked: a story quoting a figure nobody computed is a bug in the story, and it should be visible.

missingColumns

fn
function missingColumns(story: Story, present: string[] | Set<string>): string[]

The subset of {@link requiredColumns} the host cannot supply.

requiredColumns

fn
function requiredColumns(story: Story): string[]

Every column a story reads.

A story is written against one dataset's vocabulary. Pointing it at another one does not throw — figures come back absent and checks skip — but the result is a report full of em dashes, which reads like a bug. A host can ask this first and say "this story was written for a different dataset" instead.

resolveStory

fn
function resolveStory(story: Story, rows: any[], o: ResolveOptions = {}): ResolvedStory

Run a story against a frame.

The output is everything a host needs to render and nothing about how: no colours, no components, no markup. A narrative is a sequence of claims, the figures behind them, and the verdicts on whether the data still supports them — which is the part that survives the data changing underneath.

Interfaces

Chapter

interface
interface Chapter

One idea: what it says, the numbers it quotes, what is drawn beside it, and the claims it rests on.

id: string
title: string
prose: string[]

Paragraphs. {{figureId}} is replaced with the formatted figure.

takeaway?: string

The one line the reader should leave with.

figures?: FigureSpec[]

Numbers this chapter computes; quotable from prose and takeaway.

panel?: Panel
checks?: Check[]

The claims the chapter rests on.

A narrative that cannot be contradicted by its own data is marketing. These run every time the story is resolved, and a failure is reported next to the paragraph that made the claim.

Figure

interface
interface Figure extends FigureSpec

A figure once it has met the data: the value, and the string to print.

value: number | null
formatted: string

Ready to print. Null values render as an em dash, never as zero.

FigureSpec

interface
interface FigureSpec

A number the prose is allowed to quote.

id: string

Referenced from prose as {{id}}.

label: string
measure?: string

Column to reduce. Omit for reduce: 'count'.

reduce: Reduce
format?: FigureFormat
unit?: string

Shown beside the value on a metric card.

note?: string

One line under the card: what the reader should take from it.

FormatOptions

interface
interface FormatOptions

Locale and currency for every number in a story.

locale?: string
currency?: string

ResolvedChapter

interface
interface ResolvedChapter

A chapter with its numbers in its sentences and its claims answered.

id: string
title: string
prose: string[]
takeaway?: string
figures: Figure[]
panel?: ResolvedPanel
verdicts: Verdict[]
status: 'pass' | 'warn' | 'fail' | 'none'

Worst verdict in the chapter. none when it asserts nothing.

ResolvedStory

interface
interface ResolvedStory

The whole report, resolved against a frame and ready to render.

id: string
title: string
subtitle?: string
standfirst: string[]
chapters: ResolvedChapter[]
credits: Array<{ label: string; href?: string }>
status: 'pass' | 'warn' | 'fail' | 'none'

fail when any chapter's claims broke against this data.

n: number

Rows the story was resolved against.

ResolveOptions

interface
interface ResolveOptions extends FormatOptions

What a story needs beyond the rows: who can name its columns, what runs its panels, and how to print a number.

columnDirectory?: ColumnDirectory
opContext?: OpContext

Context handed to the stream ops a panel runs.

date?: string

Overrides the story's own date column.

SeriesSpec

interface
interface SeriesSpec

One line or bar on a series panel.

measure: string
label: string
type?: 'line' | 'bar' | 'area'
axis?: 0 | 1

Put a series on the right-hand axis when it is in different units.

Story

interface
interface Story

A report, as data.

Everything needed to render one and nothing about how: the chapters in order, the process they read, and the column carrying time when the structure does not say.

id: string
title: string
subtitle?: string
standfirst?: string[]

Opening paragraphs, before the first chapter.

source?: string

Process name the chapters read from. The host resolves it.

date?: string

Column carrying time, when the structure does not say.

chapters: Chapter[]
credits?: Array<{ label: string; href?: string }>

Types

FigureFormat

type
type FigureFormat = 'currency' | 'percent' | 'number' | 'compact' | 'integer' | 'date'

How a number should be printed. currency and the story's locale decide the symbol and the rounding.

Panel

type
type Panel = | { kind: 'figures' } | { kind: 'series'; date?: string; series: SeriesSpec[]; stack?: boolean } | { kind: 'pivot'; options: PivotOptions; format?: FigureFormat } | { kind: 'correlate'; options: CorrelateOptions } | { kind: 'table'; columns: Array<{ column: string; label?: string; format?: FigureFormat }> }

What is drawn beside the prose.

Reduce

type
type Reduce = 'last' | 'first' | 'sum' | 'avg' | 'min' | 'max' | 'delta' | 'growth' | 'count'

How a column is reduced to the single number a sentence can carry.

delta and growth compare the last observation to the first, in the order of the story's date column — which is why the resolver needs one.

ResolvedPanel

type
type ResolvedPanel = | { kind: 'figures'; figures: Figure[] } | { kind: 'series'; labels: string[]; series: Array<{ label: string; type: string; axis: number; data: Array<number | null> }>; stack: boolean; } | { kind: 'pivot'; columns: string[]; rows: any[]; omitted: number; rowKey: string; /** How the cells should be printed. A rate is unreadable raw. */ format?: FigureFormat; } | { kind: 'correlate'; associations: any[] } | { kind: 'table'; columns: Array<{ column: string; label: string; format?: FigureFormat }>; rows: any[] }

A panel with its data computed — still no markup, no colours, no components.

Constants

ABSENT

const
const ABSENT

Absence prints as an em dash. A gap in the data is not a zero.