@kairos-innovations/report-builder
v0.2.0
Published
Headless report-builder rules for Kairos consoles: one reading of how a cube rolls up, so two dashboards cannot disagree about a number
Maintainers
Readme
@kairos-innovations/report-builder
The rules a self-service report builder needs, as pure functions. No React, no transport, no geography.
It exists because two consoles reading the same cube must not disagree about a number — and because the two ways a report silently lies are easy to write by accident.
Install
npm install @kairos-innovations/report-builderNo dependencies and no peer dependencies.
The idea
The reporting engine cannot GROUP BY. Every dataset is therefore a cube: a view already aggregated to the
finest grain of its dimensions, with summable measures as columns. This package rolls that cube up to whatever
grain the user asked for — and refuses when the maths would be wrong.
import { pivot } from "@kairos-innovations/report-builder"
const result = pivot(rows, config, dataset)
if (!result.ok) {
// NON_ADDITIVE_ROLLUP · MIXED_CURRENCY · UNKNOWN_DATASET · UNKNOWN_MEASURE
showNotice(result.message)
} else {
render(result.spec) // { type, rows, seriesKeys?, unit, measureLabel, total?, currency? }
}What it refuses, and why
A non-additive measure. A measure declares how it survives a roll-up:
| aggregation | Roll-up | Example |
|---|---|---|
| { kind: "sum", column } | adds | collected_amount |
| { kind: "ratio", numerator, denominator } | sums both, divides once | days_in_state_sum ÷ occupancy_count |
| { kind: "distinct", column } | cannot be combined | vehicle_count |
An average cannot be re-averaged: two cells of "9.5 days" only average to 9.5 when both hold the same number of
observations. A distinct count cannot be combined at all — a vehicle charged in March and April is one
vehicle. pivot returns { ok: false, reason: "NON_ADDITIVE_ROLLUP" } naming the dimension responsible,
instead of a number that looks right.
Two currencies. Money cubes group by currency. If the rows carry more than one and currency is not a
break-down, pivot returns MIXED_CURRENCY. Adding MZN to UGX is a money bug.
API
| Export | What it answers |
|---|---|
| pivot(rows, config, dataset, options?) | cube rows → VisualSpec, or a refusal |
| eligibleCharts(config, dataset, options?) | which chart types this config can render, and what each refusal needs |
| autoChart(config, dataset) | the chart to draw, and the sentence explaining why |
| resolveChart(config, dataset, options?) | the user's override if it still holds, else auto |
| canAggregate(measure, config, dataset, rows) | may this measure be rolled up to this grain? |
| singleCurrency(rows, dataset) | the one currency, null if several, undefined if not money |
| formatValue(value, unit, options?) | one number, one unit, one string |
| visualTitle / visualSubtitle | generated headline and context line |
| encodeConfig / decodeConfig | a configuration that survives a URL |
Geography is the host's: eligibleCharts draws no map unless you pass mapDimensions, because hardcoding one
deployment's administrative vocabulary would make it everyone's.
Release
Bump version in package.json and merge to dev. The workflow publishes only when that version is not
already on npm, so an ordinary feature merge is a no-op. Releases are published from CI by trusted publishing,
so there is no npm token stored anywhere and every release carries a provenance attestation.
Licence
MIT
Drawing
layoutChart(spec, box, options?) returns plain geometry for all eight chart types: numbers, SVG
path strings, ordinal series indices and 0..1 intensities.
It never returns a colour. Not a hex value, not a palette, not a default. Your product's theme is yours; this package tells you which series and how intense, and you map that onto your own tokens. It is why one package can serve products that look nothing like each other, and why a download made from your chart carries your colours rather than ours.
A map resolves by what you supply. Pass options.shapes — region outlines keyed to the dimension
value, with [lon, lat] rings — and you get a choropleth; pass none and you get a cartogram
over the same values. Which one a deployment shows follows its DATA, not a build flag, so a product
renders a cartogram before its boundaries are loaded and a real map afterwards with no code change.
Values that have data but no shape come back in unmatched rather than being dropped.
buildReportDocument(input) returns everything a printed report says other than the chart: title,
subtitle, dataset, break-down, the figures as formatted strings with their shares, and the values a
filter excluded — named, not counted. It takes generatedAt as a parameter and never reads a clock.
WELLS is the field-well vocabulary in plain language, in the order that reads as a sentence: the
number, broken down by, then split by, narrow down.
