@llodev/bi-dashboard 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/LICENSE +21 -0
- package/README.md +60 -2
- package/SKILL.md +201 -0
- package/anti-patterns/charts.md +14 -0
- package/docs/i18n/README.es-ES.md +61 -0
- package/docs/i18n/README.pt-BR.md +61 -0
- package/package.json +32 -4
- package/references/bi-tools.md +34 -0
- package/references/capability-map.md +17 -0
- package/references/echarts.md +124 -0
- package/references/explore.md +71 -0
- package/references/form.md +32 -0
- package/references/ggplot2.md +37 -0
- package/references/layout.md +44 -0
- package/references/python.md +103 -0
- package/references/react-recharts.md +512 -0
- package/references/story.md +211 -0
- package/references/vega-lite.md +85 -0
- package/references/verify.md +29 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
## 3. Phase 2 — Compute the story
|
|
2
|
+
|
|
3
|
+
Every title sentence and every annotation comes from a number computed in code or SQL. Never estimate by eye.
|
|
4
|
+
|
|
5
|
+
### 3.1 The ChartStory contract
|
|
6
|
+
|
|
7
|
+
Each chart is specified by a library-agnostic contract. Fill the contract with the numbers first; then any adapter ([`SKILL.md`](../SKILL.md)) draws it.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
export type ChartStory = {
|
|
11
|
+
title: string; // the conclusion, with the number that matters
|
|
12
|
+
subtitle: string; // what is measured, the unit, the period, and how to read it
|
|
13
|
+
source?: string; // "SOURCE: …"
|
|
14
|
+
form:
|
|
15
|
+
| "time-bars"
|
|
16
|
+
| "line"
|
|
17
|
+
| "category-bars"
|
|
18
|
+
| "units"
|
|
19
|
+
| "slope"
|
|
20
|
+
| "distribution"
|
|
21
|
+
| "scatter"
|
|
22
|
+
| "heatmap"
|
|
23
|
+
| "funnel"
|
|
24
|
+
| "map"
|
|
25
|
+
| "table"
|
|
26
|
+
| "kpi";
|
|
27
|
+
highlight: { key: string | number; label: string; value: string }; // the protagonist
|
|
28
|
+
reference?: { value: number; label: string }; // median, target, previous year
|
|
29
|
+
annotations?: Array<{ key: string | number; text: string }>; // crossings, events
|
|
30
|
+
};
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### 3.2 Story functions (TypeScript)
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
export const median = (xs: number[]): number => {
|
|
37
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
38
|
+
const m = Math.floor(s.length / 2);
|
|
39
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Peak value versus the baseline (median). */
|
|
43
|
+
export function peakVsBaseline<T>(rows: T[], value: (r: T) => number) {
|
|
44
|
+
const vals = rows.map(value);
|
|
45
|
+
const index = vals.reduce((best, v, i) => (v > vals[best] ? i : best), 0);
|
|
46
|
+
const baseline = median(vals);
|
|
47
|
+
return { index, row: rows[index], peak: vals[index], baseline, factor: vals[index] / baseline };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Each item's share of the total, sorted from largest to smallest. */
|
|
51
|
+
export function shares<T>(rows: T[], value: (r: T) => number) {
|
|
52
|
+
const total = rows.reduce((acc, r) => acc + value(r), 0);
|
|
53
|
+
return rows
|
|
54
|
+
.map((row) => ({ row, value: value(row), share: total ? value(row) / total : 0 }))
|
|
55
|
+
.sort((a, b) => b.value - a.value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Relative change; null when the baseline is zero. */
|
|
59
|
+
export const growth = (current: number, previous: number) =>
|
|
60
|
+
previous === 0 ? null : (current - previous) / previous;
|
|
61
|
+
|
|
62
|
+
/** First index where `a` moves above `b` (after being equal or below). */
|
|
63
|
+
export function firstCrossing(a: number[], b: number[]): number {
|
|
64
|
+
for (let i = 1; i < Math.min(a.length, b.length); i++) {
|
|
65
|
+
if (a[i - 1] <= b[i - 1] && a[i] > b[i]) return i;
|
|
66
|
+
}
|
|
67
|
+
return -1;
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 3.3 Formatting and language (project locale)
|
|
72
|
+
|
|
73
|
+
Use `Intl` (or the project's i18n) — never concatenate separators by hand in JS/TS.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
export function makeFormat(locale = "pt-BR", currency = "BRL") {
|
|
77
|
+
const money = new Intl.NumberFormat(locale, {
|
|
78
|
+
style: "currency",
|
|
79
|
+
currency,
|
|
80
|
+
notation: "compact",
|
|
81
|
+
minimumFractionDigits: 0,
|
|
82
|
+
maximumFractionDigits: 1,
|
|
83
|
+
});
|
|
84
|
+
const moneyFull = new Intl.NumberFormat(locale, {
|
|
85
|
+
style: "currency",
|
|
86
|
+
currency,
|
|
87
|
+
maximumFractionDigits: 0,
|
|
88
|
+
});
|
|
89
|
+
const num = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });
|
|
90
|
+
const pct = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 });
|
|
91
|
+
const deltaFmt = new Intl.NumberFormat(locale, {
|
|
92
|
+
style: "percent",
|
|
93
|
+
maximumFractionDigits: 0,
|
|
94
|
+
signDisplay: "exceptZero",
|
|
95
|
+
});
|
|
96
|
+
const delta = { format: (v: number) => deltaFmt.format(v).replace("-", "−") }; // "−15%"
|
|
97
|
+
const monthFmt = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
|
|
98
|
+
const month = (d: Date) => monthFmt.format(d).replace(".", "").toUpperCase(); // "MAI"
|
|
99
|
+
const day = (d: Date) => `${d.getUTCDate()} ${month(d).toLowerCase()}`; // "28 nov"
|
|
100
|
+
return { money, moneyFull, num, pct, delta, month, day };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** "5 times", "5,7 times": rounds to an integer only when it is within 0.15 of one. */
|
|
104
|
+
export const times = (factor: number, f = makeFormat()) =>
|
|
105
|
+
Math.abs(factor - Math.round(factor)) < 0.15
|
|
106
|
+
? `${Math.round(factor)} times`
|
|
107
|
+
: `${f.num.format(factor)} times`;
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Writing conventions (adapt them to the project's language):
|
|
111
|
+
|
|
112
|
+
- Compact values in annotations (`R$ 78,4 mil`, `R$ 1,3 mi`), the full value in the tooltip.
|
|
113
|
+
- Deltas with a sign (`+203%`, `−8%`) and **always with the baseline** ("vs. 2024", "vs. a normal month").
|
|
114
|
+
- Short dates (`28 nov`, `week 26`); axis months abbreviated in uppercase (`JAN FEV MAR`).
|
|
115
|
+
- 2–3 significant figures in text; do not show `R$ 78.412,37` in an annotation.
|
|
116
|
+
|
|
117
|
+
### 3.4 Conclusion titles computed from the data
|
|
118
|
+
|
|
119
|
+
Titles on dashboards with a filter **must be functions of the data**, with an honest fallback:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
export function peakTitle(factor: number, eventName: string | undefined, f = makeFormat()) {
|
|
123
|
+
if (factor >= 1.5)
|
|
124
|
+
return `${eventName ?? "The peak"} sold ${times(factor, f)} more than a normal day`;
|
|
125
|
+
return "Daily sales stayed flat over the period";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function overtakeTitle(
|
|
129
|
+
name: string,
|
|
130
|
+
rival: string,
|
|
131
|
+
factor: number,
|
|
132
|
+
crossingLabel: string | null,
|
|
133
|
+
f = makeFormat(),
|
|
134
|
+
) {
|
|
135
|
+
const grew = `${name} grew ${times(factor, f)}`;
|
|
136
|
+
return crossingLabel ? `${grew} and passed ${rival} in ${crossingLabel}` : grew;
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Rules: the title contains the subject and the number; it is true for the current slice; it rounds honestly (5.09 → "5 times"; 5.6 → "5,6 times"); if there is no strong pattern, it states what is there (stability, a typical range) instead of falling back to a descriptive label. Also check **qualitative** claims ("the largest channel", "tripled", "driven by the app"): each one needs a calculation that supports it, the same as the numbers.
|
|
141
|
+
|
|
142
|
+
### Values the functions return that you must not print
|
|
143
|
+
|
|
144
|
+
`median([])` is `NaN`. On no rows, `peakVsBaseline` still returns index `0` and a `factor` of `NaN` (`reduce` keeps the initial `0`, and `vals[0]` is missing). A zero baseline makes `factor` `Infinity` when the peak is not zero, and `NaN` when the peak is also zero. `growth` already returns `null` when the previous value is zero. Do not pass `NaN` or `Infinity` to `times` or `peakTitle` — `Infinity >= 1.5` is true, so the title would say the peak sold infinity times more.
|
|
145
|
+
|
|
146
|
+
- **Empty slice**: do not call the chart. No highlight and no median line. The title says why this slice is empty, not the previous conclusion.
|
|
147
|
+
- **Zero baseline**: do not write "times" and do not claim the series "stayed flat." State the absolute peak and that the baseline was zero.
|
|
148
|
+
- **Tie**: the `>` reduction keeps the earliest row. Name one day only when its value is strictly greater than every other row. When several rows share the max, the title states the tie count, not a single date.
|
|
149
|
+
|
|
150
|
+
### 3.5 SQL for the story (compute in the database when the volume is large)
|
|
151
|
+
|
|
152
|
+
PostgreSQL — complete daily series (days with no sales become zero), median, factor, and peak flag:
|
|
153
|
+
|
|
154
|
+
```sql
|
|
155
|
+
WITH days AS (
|
|
156
|
+
SELECT d::date AS day
|
|
157
|
+
FROM generate_series(DATE '2025-01-01', DATE '2025-12-31', INTERVAL '1 day') AS d
|
|
158
|
+
), daily AS (
|
|
159
|
+
SELECT days.day, COALESCE(SUM(o.amount), 0) AS revenue
|
|
160
|
+
FROM days
|
|
161
|
+
LEFT JOIN orders o
|
|
162
|
+
ON o.created_at >= days.day AND o.created_at < days.day + 1
|
|
163
|
+
GROUP BY days.day
|
|
164
|
+
), base AS (
|
|
165
|
+
SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY revenue) AS median_revenue FROM daily
|
|
166
|
+
)
|
|
167
|
+
SELECT daily.day,
|
|
168
|
+
daily.revenue,
|
|
169
|
+
base.median_revenue,
|
|
170
|
+
daily.revenue / NULLIF(base.median_revenue, 0) AS factor_vs_median,
|
|
171
|
+
daily.revenue = MAX(daily.revenue) OVER () AS is_peak
|
|
172
|
+
FROM daily CROSS JOIN base
|
|
173
|
+
ORDER BY daily.day;
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Share and growth by category (current year vs. previous):
|
|
177
|
+
|
|
178
|
+
```sql
|
|
179
|
+
SELECT channel,
|
|
180
|
+
SUM(amount) FILTER (WHERE created_at >= DATE '2025-01-01') AS revenue_2025,
|
|
181
|
+
SUM(amount) FILTER (WHERE created_at < DATE '2025-01-01') AS revenue_2024,
|
|
182
|
+
SUM(amount) FILTER (WHERE created_at >= DATE '2025-01-01')
|
|
183
|
+
/ NULLIF(SUM(SUM(amount) FILTER (WHERE created_at >= DATE '2025-01-01')) OVER (), 0) AS share_2025,
|
|
184
|
+
SUM(amount) FILTER (WHERE created_at >= DATE '2025-01-01')
|
|
185
|
+
/ NULLIF(SUM(amount) FILTER (WHERE created_at < DATE '2025-01-01'), 0) - 1 AS growth
|
|
186
|
+
FROM orders
|
|
187
|
+
WHERE created_at >= DATE '2024-01-01' AND created_at < DATE '2026-01-01'
|
|
188
|
+
GROUP BY channel
|
|
189
|
+
ORDER BY revenue_2025 DESC;
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
First week in which one series passes the other:
|
|
193
|
+
|
|
194
|
+
```sql
|
|
195
|
+
WITH w AS (
|
|
196
|
+
SELECT week,
|
|
197
|
+
SUM(users) FILTER (WHERE plan = 'pro') AS pro,
|
|
198
|
+
SUM(users) FILTER (WHERE plan = 'basic') AS basic
|
|
199
|
+
FROM weekly_active_users
|
|
200
|
+
GROUP BY week
|
|
201
|
+
), x AS (
|
|
202
|
+
SELECT week, pro, basic,
|
|
203
|
+
LAG(pro) OVER (ORDER BY week) AS prev_pro, LAG(basic) OVER (ORDER BY week) AS prev_basic
|
|
204
|
+
FROM w
|
|
205
|
+
)
|
|
206
|
+
SELECT MIN(week) AS crossing_week FROM x WHERE prev_pro <= prev_basic AND pro > basic;
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Median equivalents by dialect: ClickHouse `median(x)` / `quantileExact(0.5)(x)`; SQL Server `PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x) OVER ()`; Db2, Oracle, and Snowflake `MEDIAN(x)`; BigQuery `PERCENTILE_CONT(x, 0.5) OVER ()`; MySQL 8 has no native median (use `ROW_NUMBER`/`COUNT` or compute in the application). For complete series: ClickHouse `WITH FILL`, SQL Server/Db2 a calendar table or a recursive CTE.
|
|
210
|
+
|
|
211
|
+
---
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
### 9.3 Vega-Lite (portable JSON; Altair in Python emits the same spec)
|
|
2
|
+
|
|
3
|
+
Line with a protagonist, direct labels, and a mark at the crossing. Colors come in as parameters resolved from the project tokens:
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{
|
|
7
|
+
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
|
|
8
|
+
"title": {
|
|
9
|
+
"text": "Pro grew 5.7 times and passed Basic in week 26",
|
|
10
|
+
"anchor": "start",
|
|
11
|
+
"fontSize": 20,
|
|
12
|
+
"fontWeight": 800,
|
|
13
|
+
"subtitle": "Weekly active users in 2025, by plan",
|
|
14
|
+
"subtitleColor": "#6B6B6B",
|
|
15
|
+
"subtitleFontSize": 12
|
|
16
|
+
},
|
|
17
|
+
"width": 720,
|
|
18
|
+
"height": 300,
|
|
19
|
+
"config": {
|
|
20
|
+
"view": { "stroke": null },
|
|
21
|
+
"axis": { "grid": false, "domain": false, "ticks": false, "labelColor": "#6B6B6B" }
|
|
22
|
+
},
|
|
23
|
+
"data": { "name": "series" },
|
|
24
|
+
"layer": [
|
|
25
|
+
{
|
|
26
|
+
"mark": { "type": "line", "interpolate": "monotone" },
|
|
27
|
+
"encoding": {
|
|
28
|
+
"x": {
|
|
29
|
+
"field": "week",
|
|
30
|
+
"type": "quantitative",
|
|
31
|
+
"title": null,
|
|
32
|
+
"axis": { "values": [1, 13, 26, 39, 52], "labelExpr": "'wk ' + datum.value" }
|
|
33
|
+
},
|
|
34
|
+
"y": { "field": "users", "type": "quantitative", "axis": null },
|
|
35
|
+
"detail": { "field": "plan" },
|
|
36
|
+
"color": {
|
|
37
|
+
"condition": { "test": "datum.plan === 'Pro'", "value": "#E8590C" },
|
|
38
|
+
"value": "#C4C4C4"
|
|
39
|
+
},
|
|
40
|
+
"strokeWidth": { "condition": { "test": "datum.plan === 'Pro'", "value": 3 }, "value": 1.5 }
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"transform": [{ "filter": "datum.week === 52" }],
|
|
45
|
+
"mark": { "type": "text", "align": "left", "dx": 6, "fontSize": 12, "fontWeight": 700 },
|
|
46
|
+
"encoding": {
|
|
47
|
+
"x": { "field": "week", "type": "quantitative" },
|
|
48
|
+
"y": { "field": "users", "type": "quantitative" },
|
|
49
|
+
"text": { "field": "endLabel" },
|
|
50
|
+
"color": {
|
|
51
|
+
"condition": { "test": "datum.plan === 'Pro'", "value": "#C2410C" },
|
|
52
|
+
"value": "#6B6B6B"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"transform": [{ "filter": "datum.plan === 'Pro' && datum.week === 26" }],
|
|
58
|
+
"mark": { "type": "point", "filled": true, "size": 70, "color": "#E8590C" },
|
|
59
|
+
"encoding": {
|
|
60
|
+
"x": { "field": "week", "type": "quantitative" },
|
|
61
|
+
"y": { "field": "users", "type": "quantitative" }
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"transform": [{ "filter": "datum.plan === 'Pro' && datum.week === 26" }],
|
|
66
|
+
"mark": {
|
|
67
|
+
"type": "text",
|
|
68
|
+
"align": "right",
|
|
69
|
+
"dx": -8,
|
|
70
|
+
"dy": -12,
|
|
71
|
+
"fontSize": 12,
|
|
72
|
+
"color": "#C2410C",
|
|
73
|
+
"fontWeight": 700
|
|
74
|
+
},
|
|
75
|
+
"encoding": {
|
|
76
|
+
"x": { "field": "week", "type": "quantitative" },
|
|
77
|
+
"y": { "field": "users", "type": "quantitative" },
|
|
78
|
+
"text": { "value": "week 26 · Pro passes Basic" }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The data arrives in `series` with `{ week, plan, users, endLabel }` — `endLabel` ("Pro 8.550") and the crossing week come from the [`references/story.md`](story.md) functions, and are not hardcoded in the spec in production. In Altair, use `alt.condition(alt.datum.plan == "Pro", alt.value(HIGHLIGHT), alt.value(CONTEXT))` and `+` layers.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
## 10. Phase 5 — Verify
|
|
2
|
+
|
|
3
|
+
1. **Run the code** and fix the project's type and lint errors (`tsc --noEmit`, `eslint`, `ruff`, tests).
|
|
4
|
+
2. **Render and look**: open the page or figure (dev server + screenshot, Playwright, `fig.write_image`, `savefig`) in the light and dark themes and at a narrow width. Look for overlapping labels, a clipped annotation, weak contrast, the wrong highlight.
|
|
5
|
+
3. **Check the numbers**: every number in a title or an annotation matches the chart data and an independent query.
|
|
6
|
+
4. **Test the filters**: change the slice and see whether the title, highlight, and annotations recompute and stay true; test an empty slice.
|
|
7
|
+
5. **Run the checklist** below.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 11. Final checklist
|
|
12
|
+
|
|
13
|
+
**5-second test**: looking for 5 seconds, can someone state the conclusion of each chart and of the dashboard?
|
|
14
|
+
|
|
15
|
+
- [ ] Exploration done; the project's stack, tokens, and components reused (no loose hex in a component).
|
|
16
|
+
- [ ] Dashboard with a conclusion title; each chart with a conclusion title, a subtitle (unit, period, how to read), and a source.
|
|
17
|
+
- [ ] Every cited number was computed; dynamic titles have an honest fallback.
|
|
18
|
+
- [ ] One protagonist per chart in `highlight`; the rest in `context`; same entity, same color everywhere.
|
|
19
|
+
- [ ] No grid; Y axis hidden when the key values are annotated; minimal ticks.
|
|
20
|
+
- [ ] No legend when you can label directly.
|
|
21
|
+
- [ ] Key value annotated beside the key point; reference (median/target/previous year) labeled.
|
|
22
|
+
- [ ] Chart form suited to the question and the data type.
|
|
23
|
+
- [ ] Related groups in the same container; more space between groups than inside them.
|
|
24
|
+
- [ ] Numbers formatted in the project locale, `tabular-nums`, deltas with an explicit baseline.
|
|
25
|
+
- [ ] Contrast ok in the supported themes; meaning does not depend on color alone; a text or table alternative is available.
|
|
26
|
+
- [ ] Loading, empty, and error states handled.
|
|
27
|
+
- [ ] Rendered and inspected visually; story functions have tests.
|
|
28
|
+
|
|
29
|
+
---
|