@llodev/bi-dashboard 0.0.0-stage → 0.1.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.
@@ -0,0 +1,512 @@
1
+ ### 9.1 React + shadcn/ui charts (Recharts) — TypeScript
2
+
3
+ Assumes the shadcn/ui `Card` and `Chart` components are installed (`components/ui/card.tsx`, `components/ui/chart.tsx`). In Tailwind v4 the tokens are used as `var(--chart-1)`; in v3, as `hsl(var(--chart-1))`. `ChartContainer` exposes each `config` key as `--color-<key>`.
4
+
5
+ ```tsx
6
+ "use client";
7
+
8
+ import { Bar, BarChart, Cell, ReferenceDot, ReferenceLine, XAxis, YAxis } from "recharts";
9
+ import {
10
+ Card,
11
+ CardContent,
12
+ CardDescription,
13
+ CardFooter,
14
+ CardHeader,
15
+ CardTitle,
16
+ } from "@/components/ui/card";
17
+ import {
18
+ ChartContainer,
19
+ ChartTooltip,
20
+ ChartTooltipContent,
21
+ type ChartConfig,
22
+ } from "@/components/ui/chart";
23
+ import { makeFormat, peakTitle, peakVsBaseline, times } from "@/lib/chart-story";
24
+
25
+ export type DailyPoint = { date: string; value: number }; // date as ISO "2025-11-28"
26
+
27
+ const chartConfig = {
28
+ value: { label: "Sales", color: "var(--chart-1)" },
29
+ context: { label: "Context", color: "var(--muted-foreground)" },
30
+ highlight: { label: "Highlight", color: "var(--chart-1)" },
31
+ } satisfies ChartConfig;
32
+
33
+ type PeakLabelProps = { viewBox?: { x?: number; y?: number }; value: string; note: string };
34
+
35
+ function PeakLabel({ viewBox, value, note }: PeakLabelProps) {
36
+ const x = (viewBox?.x ?? 0) - 8;
37
+ const y = viewBox?.y ?? 0;
38
+ return (
39
+ <g>
40
+ <text
41
+ x={x}
42
+ y={y + 18}
43
+ textAnchor="end"
44
+ style={{ fill: "var(--color-highlight)", fontSize: 22, fontWeight: 800 }}
45
+ >
46
+ {value}
47
+ </text>
48
+ <text
49
+ x={x}
50
+ y={y + 36}
51
+ textAnchor="end"
52
+ style={{ fill: "var(--color-highlight)", fontSize: 12 }}
53
+ >
54
+ {note}
55
+ </text>
56
+ </g>
57
+ );
58
+ }
59
+
60
+ export function PeakBarsCard({
61
+ data,
62
+ eventName,
63
+ subtitle,
64
+ source,
65
+ locale = "pt-BR",
66
+ currency = "BRL",
67
+ }: {
68
+ data: DailyPoint[];
69
+ eventName?: string;
70
+ subtitle: string;
71
+ source?: string;
72
+ locale?: string;
73
+ currency?: string;
74
+ }) {
75
+ const f = makeFormat(locale, currency);
76
+ const { index, row, peak, baseline, factor } = peakVsBaseline(data, (d) => d.value);
77
+ const monthTicks = data.filter((d) => d.date.endsWith("-01")).map((d) => d.date);
78
+ const toDate = (iso: string) => new Date(`${iso}T00:00:00Z`);
79
+
80
+ return (
81
+ <Card>
82
+ <CardHeader>
83
+ <CardTitle className="text-xl font-extrabold leading-tight">
84
+ {peakTitle(factor, eventName, f)}
85
+ </CardTitle>
86
+ <CardDescription>{subtitle}</CardDescription>
87
+ </CardHeader>
88
+ <CardContent>
89
+ <ChartContainer config={chartConfig} className="aspect-auto h-[320px] w-full">
90
+ <BarChart
91
+ accessibilityLayer
92
+ data={data}
93
+ margin={{ top: 16, right: 8, left: 8, bottom: 0 }}
94
+ barCategoryGap="30%"
95
+ >
96
+ <XAxis
97
+ dataKey="date"
98
+ ticks={monthTicks}
99
+ interval={0}
100
+ tickLine={false}
101
+ axisLine={false}
102
+ tickMargin={8}
103
+ tickFormatter={(iso: string) => f.month(toDate(iso))}
104
+ />
105
+ <YAxis hide domain={[0, "dataMax"]} />
106
+ <ChartTooltip
107
+ cursor={false}
108
+ content={
109
+ <ChartTooltipContent
110
+ labelFormatter={(iso) => f.day(toDate(String(iso)))}
111
+ formatter={(v) => f.moneyFull.format(Number(v))}
112
+ hideIndicator
113
+ />
114
+ }
115
+ />
116
+ <ReferenceLine
117
+ y={baseline}
118
+ stroke="var(--color-context)"
119
+ strokeDasharray="3 3"
120
+ label={{
121
+ value: `normal day (median) · ${f.money.format(baseline)}`,
122
+ position: "insideBottomLeft",
123
+ fill: "var(--color-context)",
124
+ fontSize: 11,
125
+ stroke: "var(--card)",
126
+ strokeWidth: 4,
127
+ paintOrder: "stroke",
128
+ }}
129
+ />
130
+ <Bar dataKey="value" isAnimationActive={false}>
131
+ {data.map((d, i) => (
132
+ <Cell
133
+ key={d.date}
134
+ fill={i === index ? "var(--color-highlight)" : "var(--color-context)"}
135
+ fillOpacity={i === index ? 1 : 0.35}
136
+ />
137
+ ))}
138
+ </Bar>
139
+ <ReferenceDot
140
+ x={row.date}
141
+ y={peak}
142
+ r={0}
143
+ label={
144
+ <PeakLabel
145
+ value={f.money.format(peak).toUpperCase()}
146
+ note={`${f.day(toDate(row.date))} · ${times(factor, f)} a normal day`}
147
+ />
148
+ }
149
+ />
150
+ </BarChart>
151
+ </ChartContainer>
152
+ </CardContent>
153
+ {source && (
154
+ <CardFooter className="text-[10px] uppercase tracking-wider text-muted-foreground">
155
+ {source}
156
+ </CardFooter>
157
+ )}
158
+ </Card>
159
+ );
160
+ }
161
+ ```
162
+
163
+ Line with a protagonist, a direct label at the end, and a mark at the crossing:
164
+
165
+ ```tsx
166
+ "use client";
167
+
168
+ import { CartesianGrid, LabelList, Line, LineChart, ReferenceDot, XAxis, YAxis } from "recharts";
169
+ import {
170
+ Card,
171
+ CardContent,
172
+ CardDescription,
173
+ CardFooter,
174
+ CardHeader,
175
+ CardTitle,
176
+ } from "@/components/ui/card";
177
+ import {
178
+ ChartContainer,
179
+ ChartTooltip,
180
+ ChartTooltipContent,
181
+ type ChartConfig,
182
+ } from "@/components/ui/chart";
183
+ import { firstCrossing, makeFormat, overtakeTitle } from "@/lib/chart-story";
184
+
185
+ type Row = { week: number } & Record<string, number>;
186
+
187
+ export function HighlightLineCard({
188
+ data,
189
+ series,
190
+ highlight,
191
+ rival,
192
+ subtitle,
193
+ source,
194
+ locale = "pt-BR",
195
+ }: {
196
+ data: Row[];
197
+ series: string[];
198
+ highlight: string;
199
+ rival: string;
200
+ subtitle: string;
201
+ source?: string;
202
+ locale?: string;
203
+ }) {
204
+ const f = makeFormat(locale);
205
+ const h = data.map((r) => r[highlight]);
206
+ const crossing = firstCrossing(
207
+ h,
208
+ data.map((r) => r[rival]),
209
+ );
210
+ const factor = h[h.length - 1] / h[0];
211
+ const title = overtakeTitle(
212
+ highlight,
213
+ rival,
214
+ factor,
215
+ crossing >= 0 ? `week ${data[crossing].week}` : null,
216
+ f,
217
+ );
218
+ const last = data.length - 1;
219
+ const config = Object.fromEntries(
220
+ series.map((k) => [
221
+ k,
222
+ { label: k, color: k === highlight ? "var(--chart-1)" : "var(--muted-foreground)" },
223
+ ]),
224
+ ) satisfies ChartConfig;
225
+
226
+ return (
227
+ <Card>
228
+ <CardHeader>
229
+ <CardTitle className="text-xl font-extrabold leading-tight">{title}</CardTitle>
230
+ <CardDescription>{subtitle}</CardDescription>
231
+ </CardHeader>
232
+ <CardContent>
233
+ <ChartContainer config={config} className="aspect-auto h-[300px] w-full">
234
+ <LineChart
235
+ accessibilityLayer
236
+ data={data}
237
+ margin={{ top: 24, right: 96, left: 8, bottom: 0 }}
238
+ >
239
+ <CartesianGrid vertical={false} horizontal={false} />
240
+ <XAxis
241
+ dataKey="week"
242
+ type="number"
243
+ domain={["dataMin", "dataMax"]}
244
+ ticks={[1, 13, 26, 39, 52]}
245
+ tickFormatter={(w: number) => `wk ${w}`}
246
+ tickLine={false}
247
+ axisLine={false}
248
+ />
249
+ <YAxis hide />
250
+ <ChartTooltip
251
+ cursor={false}
252
+ content={
253
+ <ChartTooltipContent labelFormatter={(_, p) => `week ${p?.[0]?.payload?.week}`} />
254
+ }
255
+ />
256
+ {series.map((k) => (
257
+ <Line
258
+ key={k}
259
+ dataKey={k}
260
+ type="monotone"
261
+ dot={false}
262
+ isAnimationActive={false}
263
+ stroke={`var(--color-${k})`}
264
+ strokeWidth={k === highlight ? 3 : 1.5}
265
+ strokeOpacity={k === highlight ? 1 : 0.5}
266
+ >
267
+ <LabelList
268
+ dataKey={k}
269
+ content={(p) =>
270
+ p.index === last ? (
271
+ <text
272
+ x={Number(p.x) + 8}
273
+ y={Number(p.y)}
274
+ dominantBaseline="middle"
275
+ style={{
276
+ fill:
277
+ k === highlight ? "var(--color-" + k + ")" : "var(--muted-foreground)",
278
+ fontSize: 12,
279
+ fontWeight: 700,
280
+ }}
281
+ >
282
+ {`${k} ${f.num.format(Number(p.value))}`}
283
+ </text>
284
+ ) : null
285
+ }
286
+ />
287
+ </Line>
288
+ ))}
289
+ {crossing >= 0 && (
290
+ <ReferenceDot
291
+ x={data[crossing].week}
292
+ y={h[crossing]}
293
+ r={4}
294
+ fill={`var(--color-${highlight})`}
295
+ stroke="none"
296
+ label={{
297
+ value: `week ${data[crossing].week} · ${highlight} passes ${rival}`,
298
+ position: "top",
299
+ offset: 12,
300
+ fill: `var(--color-${highlight})`,
301
+ fontSize: 12,
302
+ fontWeight: 700,
303
+ }}
304
+ />
305
+ )}
306
+ </LineChart>
307
+ </ChartContainer>
308
+ </CardContent>
309
+ {source && (
310
+ <CardFooter className="text-[10px] uppercase tracking-wider text-muted-foreground">
311
+ {source}
312
+ </CardFooter>
313
+ )}
314
+ </Card>
315
+ );
316
+ }
317
+ ```
318
+
319
+ Ranked horizontal bars with a marker for the previous period and the change annotated:
320
+
321
+ ```tsx
322
+ "use client";
323
+
324
+ import { Bar, Cell, ComposedChart, LabelList, Scatter, XAxis, YAxis } from "recharts";
325
+ import {
326
+ Card,
327
+ CardContent,
328
+ CardDescription,
329
+ CardFooter,
330
+ CardHeader,
331
+ CardTitle,
332
+ } from "@/components/ui/card";
333
+ import { ChartContainer, type ChartConfig } from "@/components/ui/chart";
334
+ import { growth, makeFormat } from "@/lib/chart-story";
335
+
336
+ export type RankedItem = { name: string; value: number; previous?: number };
337
+
338
+ const rankedConfig = {
339
+ value: { label: "Current", color: "var(--muted-foreground)" },
340
+ previous: { label: "Previous", color: "var(--foreground)" },
341
+ highlight: { label: "Highlight", color: "var(--chart-1)" },
342
+ } satisfies ChartConfig;
343
+
344
+ export function RankedBarsCard({
345
+ items,
346
+ highlight,
347
+ title,
348
+ subtitle,
349
+ source,
350
+ locale = "pt-BR",
351
+ currency = "BRL",
352
+ }: {
353
+ items: RankedItem[];
354
+ highlight: string;
355
+ title: string;
356
+ subtitle: string;
357
+ source?: string;
358
+ locale?: string;
359
+ currency?: string;
360
+ }) {
361
+ const f = makeFormat(locale, currency);
362
+ const data = [...items]
363
+ .sort((a, b) => b.value - a.value)
364
+ .map((d) => {
365
+ const g = d.previous === undefined ? null : growth(d.value, d.previous);
366
+ return {
367
+ ...d,
368
+ label:
369
+ g === null
370
+ ? f.money.format(d.value)
371
+ : `${f.money.format(d.value)} · ${f.delta.format(g)}`,
372
+ };
373
+ });
374
+
375
+ return (
376
+ <Card>
377
+ <CardHeader>
378
+ <CardTitle className="text-xl font-extrabold leading-tight">{title}</CardTitle>
379
+ <CardDescription>{subtitle}</CardDescription>
380
+ </CardHeader>
381
+ <CardContent>
382
+ <ChartContainer
383
+ config={rankedConfig}
384
+ className="aspect-auto w-full"
385
+ style={{ height: data.length * 44 + 16 }}
386
+ >
387
+ <ComposedChart
388
+ layout="vertical"
389
+ data={data}
390
+ margin={{ top: 0, right: 140, left: 0, bottom: 0 }}
391
+ >
392
+ <XAxis type="number" hide domain={[0, "dataMax"]} />
393
+ <YAxis
394
+ type="category"
395
+ dataKey="name"
396
+ width={96}
397
+ tickLine={false}
398
+ axisLine={false}
399
+ tick={({ x, y, payload }) => (
400
+ <text
401
+ x={Number(x) - 8}
402
+ y={Number(y)}
403
+ textAnchor="end"
404
+ dominantBaseline="middle"
405
+ style={{
406
+ fontSize: 12,
407
+ fontWeight: payload.value === highlight ? 700 : 400,
408
+ fill:
409
+ payload.value === highlight
410
+ ? "var(--color-highlight)"
411
+ : "var(--muted-foreground)",
412
+ }}
413
+ >
414
+ {payload.value}
415
+ </text>
416
+ )}
417
+ />
418
+ <Bar dataKey="value" barSize={14} radius={3} isAnimationActive={false}>
419
+ {data.map((d) => (
420
+ <Cell
421
+ key={d.name}
422
+ fill={d.name === highlight ? "var(--color-highlight)" : "var(--color-value)"}
423
+ fillOpacity={d.name === highlight ? 1 : 0.35}
424
+ />
425
+ ))}
426
+ <LabelList
427
+ dataKey="label"
428
+ content={(p) => {
429
+ const d = data[Number(p.index)];
430
+ // the label starts after whichever is farther right: the bar or the previous-period marker
431
+ const reach = d && d.value > 0 ? Math.max(1, (d.previous ?? 0) / d.value) : 1;
432
+ return (
433
+ <text
434
+ x={Number(p.x) + Number(p.width) * reach + 8}
435
+ y={Number(p.y) + Number(p.height) / 2}
436
+ dominantBaseline="middle"
437
+ style={{
438
+ fontSize: 12,
439
+ fontWeight: data[Number(p.index)]?.name === highlight ? 700 : 400,
440
+ fill:
441
+ data[Number(p.index)]?.name === highlight
442
+ ? "var(--color-highlight)"
443
+ : "var(--muted-foreground)",
444
+ }}
445
+ >
446
+ {p.value}
447
+ </text>
448
+ );
449
+ }}
450
+ />
451
+ </Bar>
452
+ <Scatter
453
+ dataKey="previous"
454
+ isAnimationActive={false}
455
+ shape={(p: { cx?: number; cy?: number }) => (
456
+ <rect
457
+ x={(p.cx ?? 0) - 1}
458
+ y={(p.cy ?? 0) - 10}
459
+ width={2}
460
+ height={20}
461
+ fill="var(--color-previous)"
462
+ />
463
+ )}
464
+ />
465
+ </ComposedChart>
466
+ </ChartContainer>
467
+ </CardContent>
468
+ {source && (
469
+ <CardFooter className="text-[10px] uppercase tracking-wider text-muted-foreground">
470
+ {source}
471
+ </CardFooter>
472
+ )}
473
+ </Card>
474
+ );
475
+ }
476
+ ```
477
+
478
+ The subtitle explains the marker: "Revenue by channel in 2025 · stroke = 2024". A shaded band on any cartesian chart: `<ReferenceArea x1={start} x2={end} fill="var(--color-highlight)" fillOpacity={0.12} />`.
479
+
480
+ KPI card with shadcn:
481
+
482
+ ```tsx
483
+ import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
484
+
485
+ export function KpiCard({
486
+ label,
487
+ value,
488
+ delta,
489
+ base,
490
+ }: {
491
+ label: string;
492
+ value: string;
493
+ delta?: string;
494
+ base?: string;
495
+ }) {
496
+ return (
497
+ <Card className="gap-1 py-4 shadow-none">
498
+ <CardHeader className="px-5">
499
+ <CardDescription className="text-xs uppercase tracking-wide">{label}</CardDescription>
500
+ <CardTitle className="text-3xl font-bold tabular-nums">{value}</CardTitle>
501
+ </CardHeader>
502
+ {delta && (
503
+ <CardFooter className="px-5 text-sm text-muted-foreground">
504
+ <span className="font-medium text-foreground tabular-nums">{delta}</span>&nbsp;{base}
505
+ </CardFooter>
506
+ )}
507
+ </Card>
508
+ );
509
+ }
510
+ ```
511
+
512
+ To group related KPIs on a single background (common region), put them inside **one** `Card` with an internal grid, instead of one `Card` per number.