@cosmicdrift/kumiko-renderer-web 0.350.0 → 0.351.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/dist/app/dashboard-body.js +131 -43
- package/dist/primitives/date-field.js +1 -1
- package/dist/primitives/date-parse.d.ts +1 -1
- package/dist/primitives/date-parse.js +1 -1
- package/dist/primitives/index.js +4 -1
- package/dist/widgets/charts.d.ts +18 -3
- package/dist/widgets/charts.js +77 -22
- package/dist/widgets/progress-list.d.ts +2 -0
- package/dist/widgets/progress-list.js +1 -1
- package/dist/widgets/stat.d.ts +4 -1
- package/dist/widgets/stat.js +11 -8
- package/package.json +7 -7
- package/src/changes.json +11 -0
|
@@ -6,7 +6,7 @@ import { EmbeddedFormProvider } from "../primitives/index.js";
|
|
|
6
6
|
import { PageSection } from "../primitives/layout.js";
|
|
7
7
|
import { formatMoney } from "../primitives/money-input.js";
|
|
8
8
|
import { Skeleton } from "../ui/skeleton.js";
|
|
9
|
-
import { SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
|
|
9
|
+
import { chartToneColor, SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
|
|
10
10
|
import { DashboardListTable } from "../widgets/dashboard-list.js";
|
|
11
11
|
import { FeedList } from "../widgets/feed-list.js";
|
|
12
12
|
import { ModeSwitch } from "../widgets/mode-switch.js";
|
|
@@ -116,8 +116,8 @@ function resolveDashboardText(value, translate, formats) {
|
|
|
116
116
|
return resolveI18nText(value, translate, formats, 1);
|
|
117
117
|
return undefined;
|
|
118
118
|
}
|
|
119
|
-
function PanelShell({ testId, title, subtitle, children, }) {
|
|
120
|
-
return (_jsxs("section", { "data-testid": testId, className: "flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground", children: [_jsxs("header", { className: "flex h-11 shrink-0 items-center gap-2 border-b border-border-row px-4", children: [_jsx("h2", { className: "truncate text-sm font-semibold", children: title }), subtitle !== undefined && (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: subtitle }))] }), _jsx("div", { className: "grow p-4", children: children })] }));
|
|
119
|
+
function PanelShell({ testId, title, subtitle, action, children, }) {
|
|
120
|
+
return (_jsxs("section", { "data-testid": testId, className: "flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground", children: [_jsxs("header", { className: "flex h-11 shrink-0 items-center gap-2 border-b border-border-row px-4", children: [_jsx("h2", { className: "truncate text-sm font-semibold", children: title }), subtitle !== undefined && (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: subtitle })), action !== undefined && _jsx("div", { className: "ml-auto shrink-0", children: action })] }), _jsx("div", { className: "grow p-4", children: children })] }));
|
|
121
121
|
}
|
|
122
122
|
function PanelError({ label, error, onRetry, testId, }) {
|
|
123
123
|
const { Button } = usePrimitives();
|
|
@@ -133,7 +133,7 @@ function PanelSkeleton({ shape }) {
|
|
|
133
133
|
}
|
|
134
134
|
return (_jsx("output", { "aria-label": t("kumiko.widget.loading"), className: "flex h-40 items-end gap-1", children: SKELETON_BAR_HEIGHTS.map((height) => (_jsx(Skeleton, { className: "flex-1", style: { height } }, height))) }));
|
|
135
135
|
}
|
|
136
|
-
function QueryPanel({ panel, label, subtitle, screenParams, translate, skeleton, isEmpty, children, }) {
|
|
136
|
+
function QueryPanel({ panel, label, subtitle, action, screenParams, translate, skeleton, isEmpty, children, }) {
|
|
137
137
|
const { data, error, loading, refetch } = useQuery(panel.query, panelPayload(panel, screenParams), { live: true });
|
|
138
138
|
let body;
|
|
139
139
|
if (loading && data === null) {
|
|
@@ -148,7 +148,7 @@ function QueryPanel({ panel, label, subtitle, screenParams, translate, skeleton,
|
|
|
148
148
|
else {
|
|
149
149
|
body = children(data);
|
|
150
150
|
}
|
|
151
|
-
return (_jsx(PanelShell, { testId: `dashboard-panel-${panel.id}`, title: label, subtitle: subtitle, children: body }));
|
|
151
|
+
return (_jsx(PanelShell, { testId: `dashboard-panel-${panel.id}`, title: label, subtitle: subtitle, action: action, children: body }));
|
|
152
152
|
}
|
|
153
153
|
function isSparkPoint(value) {
|
|
154
154
|
return (typeof value === "object" &&
|
|
@@ -207,10 +207,11 @@ function StatPanelBody({ panel, label, screenId, screenParams, translate, varian
|
|
|
207
207
|
const spark = readSparkline(panel, record);
|
|
208
208
|
const testId = `dashboard-panel-${panel.id}`;
|
|
209
209
|
const subText = resolveDashboardText(sub, translate, formats) ?? fallbackText(sub, undefined);
|
|
210
|
+
const icon = Icon !== undefined ? (_jsx(Icon, { entityName: screenId, entityId: null, screenId: screenId, filterParams: screenParams.filterParams })) : undefined;
|
|
210
211
|
if (variant === "strip") {
|
|
211
|
-
return (_jsx(StatStripCell, { label: label, value: value, tone: tone ?? "default", ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
212
|
+
return (_jsx(StatStripCell, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
212
213
|
}
|
|
213
|
-
return (_jsx(StatCard, { icon:
|
|
214
|
+
return (_jsx(StatCard, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
|
|
214
215
|
}
|
|
215
216
|
function readDelta(panel, record) {
|
|
216
217
|
if (panel.deltaField === undefined || panel.deltaDirectionField === undefined)
|
|
@@ -229,13 +230,41 @@ function StatGroupPanelBody({ panel, label, screenId, screenParams, translate, }
|
|
|
229
230
|
if (label === undefined) {
|
|
230
231
|
return (_jsx("div", { "data-testid": testId, className: "grid grid-cols-2 gap-y-2 rounded-lg border border-border bg-card py-1 lg:auto-cols-fr lg:grid-flow-col lg:divide-x lg:divide-border-row", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "strip" }, stat.id))) }));
|
|
231
232
|
}
|
|
232
|
-
return (_jsx(SectionCard, { title: label, testId: testId, children: _jsx("section", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
|
|
233
|
+
return (_jsx(SectionCard, { title: label, ...(panel.subtitle !== undefined && { subtitle: translate(panel.subtitle) }), testId: testId, children: _jsx("section", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
|
|
233
234
|
}
|
|
234
|
-
function
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
235
|
+
function markerKindColor(kind) {
|
|
236
|
+
if (kind === undefined)
|
|
237
|
+
return undefined;
|
|
238
|
+
if (kind.color !== undefined)
|
|
239
|
+
return kind.color;
|
|
240
|
+
return kind.tone !== undefined ? chartToneColor(kind.tone) : undefined;
|
|
241
|
+
}
|
|
242
|
+
function resolveMarkers(markers, translate, formats, markerKinds) {
|
|
243
|
+
return markers?.map((marker) => {
|
|
244
|
+
const color = typeof marker.kind === "string" ? markerKindColor(markerKinds?.[marker.kind]) : undefined;
|
|
245
|
+
return {
|
|
246
|
+
atMs: marker.atMs,
|
|
247
|
+
label: resolveDashboardText(marker.label, translate, formats) ?? "",
|
|
248
|
+
...(color !== undefined && { color }),
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function addUtcMonths(atMs, months) {
|
|
253
|
+
const date = new Date(atMs);
|
|
254
|
+
date.setUTCMonth(date.getUTCMonth() + months);
|
|
255
|
+
return date.getTime();
|
|
256
|
+
}
|
|
257
|
+
// Same window as a "1/3/5 years" switch over a plan: `months` ahead of today,
|
|
258
|
+
// pulled back when the data ends earlier, never before the first bucket.
|
|
259
|
+
function rangeWindow(months, anchorMs, firstMs, lastMs) {
|
|
260
|
+
const endMs = Math.min(lastMs, addUtcMonths(anchorMs, months));
|
|
261
|
+
return { startMs: Math.max(firstMs, addUtcMonths(endMs, -months)), endMs };
|
|
262
|
+
}
|
|
263
|
+
function pointsWithin(points, window) {
|
|
264
|
+
return points.filter((p) => p.atMs >= window.startMs && p.atMs <= window.endMs);
|
|
265
|
+
}
|
|
266
|
+
function selectedRangeMonths(ranges, selected) {
|
|
267
|
+
return ranges?.options.find((option) => option.value === selected)?.months;
|
|
239
268
|
}
|
|
240
269
|
// A plain bar chart ("changes per day") ships `points` without `series`.
|
|
241
270
|
const SINGLE_SERIES_KEY = "value";
|
|
@@ -272,18 +301,25 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
272
301
|
const t = useTranslation();
|
|
273
302
|
const todayLabel = t("kumiko.dashboard.today");
|
|
274
303
|
const tones = panel.seriesTones;
|
|
304
|
+
const colors = panel.seriesColors;
|
|
275
305
|
const testId = `dashboard-chart-${panel.id}`;
|
|
276
|
-
|
|
306
|
+
const ranges = panel.chart === "stacked-area" ? panel.ranges : undefined;
|
|
307
|
+
const [selectedRange, setSelectedRange] = useState(ranges?.default);
|
|
308
|
+
const rangeSwitch = ranges !== undefined && selectedRange !== undefined ? (_jsx(ModeSwitch, { value: selectedRange, options: ranges.options.map((option) => ({
|
|
309
|
+
value: option.value,
|
|
310
|
+
label: translate(option.label),
|
|
311
|
+
})), onChange: setSelectedRange, ariaLabel: label, variant: "pill", testId: `dashboard-chart-range-${panel.id}` })) : undefined;
|
|
312
|
+
return (_jsx(QueryPanel, { panel: panel, label: label, subtitle: panel.subtitle !== undefined ? translate(panel.subtitle) : undefined, action: rangeSwitch, screenParams: screenParams, translate: translate, skeleton: panel.chart === "segment-bars" ? "segments" : "bars", isEmpty: (data) => isChartEmpty(panel.chart, data, chartSeries(data, label, translate)), children: (data) => {
|
|
277
313
|
const series = chartSeries(data, label, translate);
|
|
278
314
|
const { startMs, endMs } = windowOf(data, series);
|
|
279
|
-
const markers = resolveMarkers(data.markers, translate, formats);
|
|
315
|
+
const markers = resolveMarkers(data.markers, translate, formats, panel.markerKinds);
|
|
280
316
|
const formatValue = formats.formatPanelValue(panel.valueFormat);
|
|
281
317
|
if (panel.chart === "segment-bars") {
|
|
282
318
|
return (_jsx(SegmentBarChart, { rows: (data.rows ?? []).map((row) => ({
|
|
283
319
|
...row,
|
|
284
320
|
label: translate(row.label),
|
|
285
321
|
segments: row.segments.map((seg) => ({ ...seg, label: translate(seg.label) })),
|
|
286
|
-
})), tones: tones, ariaLabel: label, formatValue: formatValue, testId: testId }));
|
|
322
|
+
})), tones: tones, colors: colors, ariaLabel: label, formatValue: formatValue, testId: testId }));
|
|
287
323
|
}
|
|
288
324
|
if (panel.chart === "stacked-bars") {
|
|
289
325
|
const times = series.flatMap((s) => s.points.map((p) => p.atMs));
|
|
@@ -293,11 +329,25 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
293
329
|
const prev = all[i - 1];
|
|
294
330
|
return prev === undefined ? [] : [atMs - prev];
|
|
295
331
|
}), DAY_MS);
|
|
296
|
-
return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue: formatValue, testId: testId }));
|
|
332
|
+
return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, colors: colors, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue: formatValue, testId: testId }));
|
|
297
333
|
}
|
|
298
334
|
if (panel.chart === "stacked-area") {
|
|
299
|
-
|
|
300
|
-
|
|
335
|
+
const lines = (data.lines ?? []).map((line) => ({
|
|
336
|
+
...line,
|
|
337
|
+
label: translate(line.label),
|
|
338
|
+
}));
|
|
339
|
+
const todayMs = data.todayMs ?? undefined;
|
|
340
|
+
const months = selectedRangeMonths(ranges, selectedRange);
|
|
341
|
+
const bucketTimes = series.flatMap((s) => s.points.map((p) => p.atMs));
|
|
342
|
+
const window = months === undefined || bucketTimes.length === 0
|
|
343
|
+
? { startMs, endMs }
|
|
344
|
+
: rangeWindow(months, todayMs ?? Math.max(...bucketTimes), Math.min(...bucketTimes), Math.max(...bucketTimes));
|
|
345
|
+
const isWindowed = months !== undefined;
|
|
346
|
+
return (_jsx(StackedAreaChart, { series: isWindowed
|
|
347
|
+
? series.map((s) => ({ ...s, points: pointsWithin(s.points, window) }))
|
|
348
|
+
: series, lines: isWindowed
|
|
349
|
+
? lines.map((line) => ({ ...line, points: pointsWithin(line.points, window) }))
|
|
350
|
+
: lines, windowStartMs: window.startMs, windowEndMs: window.endMs, ...(todayMs !== undefined && { todayMs }), tones: tones, colors: colors, markers: isWindowed && markers !== undefined ? pointsWithin(markers, window) : markers, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: formats.formatDay, formatValue: formatValue, formatMarkerTime: formats.formatDay, scrollable: panel.scrollable === true, showLegendTotals: panel.legendTotals !== false, emptyContent: _jsx(EmptyState, { title: translate(panel.emptyLabel ?? "kumiko.list.no-entries") }), testId: testId }));
|
|
301
351
|
}
|
|
302
352
|
return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: markers, formatMarkerTime: formats.formatDay, ...(panel.valueFormat !== undefined && {
|
|
303
353
|
yAxis: { ticks: 3, format: formatValue },
|
|
@@ -336,12 +386,16 @@ function FeedPanelBody({ panel, label, screenParams, translate, }) {
|
|
|
336
386
|
function ProgressListPanelBody({ panel, label, screenParams, translate, }) {
|
|
337
387
|
const formats = useDashboardFormats();
|
|
338
388
|
return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
|
|
339
|
-
const rows = (data.rows ?? []).map((row, i) =>
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
389
|
+
const rows = (data.rows ?? []).map((row, i) => {
|
|
390
|
+
const sub = resolveDashboardText(row.sub, translate, formats);
|
|
391
|
+
return {
|
|
392
|
+
id: String(i),
|
|
393
|
+
label: resolveDashboardText(row.label, translate, formats) ?? "—",
|
|
394
|
+
value: resolveDashboardText(row.value, translate, formats) ?? "—",
|
|
395
|
+
fraction: row.fraction,
|
|
396
|
+
...(sub !== undefined && { sub }),
|
|
397
|
+
};
|
|
398
|
+
});
|
|
345
399
|
return _jsx(ProgressList, { rows: rows });
|
|
346
400
|
} }));
|
|
347
401
|
}
|
|
@@ -439,33 +493,67 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
|
|
|
439
493
|
return (_jsx(ProgressListPanelBody, { panel: panel, label: text, screenParams: screenParams, translate: translate }));
|
|
440
494
|
}
|
|
441
495
|
const VisibilityQueryContext = createContext(new Map());
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
496
|
+
const NO_GATE_PAYLOAD = {};
|
|
497
|
+
function visibilityGate(query, payload) {
|
|
498
|
+
return { key: `${query}|${JSON.stringify(payload)}`, query, payload };
|
|
499
|
+
}
|
|
500
|
+
// Embedded screens carry no screen filter; in-grid panels pass the filter
|
|
501
|
+
// (unless ignoreScreenFilter, never for a stat-group) and the time range.
|
|
502
|
+
function panelVisibilityGate(panel, screenParams) {
|
|
503
|
+
if (panel.kind === "custom" || panel.visibleWhen === undefined)
|
|
504
|
+
return undefined;
|
|
505
|
+
if (panel.kind === "screen")
|
|
506
|
+
return visibilityGate(panel.visibleWhen.query, NO_GATE_PAYLOAD);
|
|
507
|
+
const dropsFilter = panel.kind !== "stat-group" && panel.ignoreScreenFilter === true;
|
|
508
|
+
return visibilityGate(panel.visibleWhen.query, {
|
|
509
|
+
...(dropsFilter ? {} : screenParams.filterParams),
|
|
510
|
+
...screenParams.rangeParams,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
// One live query per distinct gate (query + payload), shared by every panel
|
|
514
|
+
// that gates on it — N panels on the same status query would otherwise each
|
|
515
|
+
// open their own request, SSE subscription and refetch per event.
|
|
516
|
+
function VisibilityQueryScope({ gate, children, }) {
|
|
446
517
|
const parent = useContext(VisibilityQueryContext);
|
|
447
|
-
const result = useQuery(query,
|
|
448
|
-
|
|
518
|
+
const result = useQuery(gate.query, gate.payload, {
|
|
519
|
+
live: true,
|
|
520
|
+
});
|
|
521
|
+
const value = useMemo(() => new Map(parent).set(gate.key, result), [parent, gate.key, result]);
|
|
449
522
|
return (_jsx(VisibilityQueryContext.Provider, { value: value, children: children }));
|
|
450
523
|
}
|
|
451
|
-
function VisibilityQueryScopes({
|
|
452
|
-
const [first, ...rest] =
|
|
524
|
+
function VisibilityQueryScopes({ gates, children, }) {
|
|
525
|
+
const [first, ...rest] = gates;
|
|
453
526
|
if (first === undefined)
|
|
454
527
|
return children;
|
|
455
|
-
return (_jsx(VisibilityQueryScope, {
|
|
528
|
+
return (_jsx(VisibilityQueryScope, { gate: first, children: _jsx(VisibilityQueryScopes, { gates: rest, children: children }) }));
|
|
456
529
|
}
|
|
457
|
-
function
|
|
458
|
-
const
|
|
530
|
+
function distinctVisibilityGates(panels, screenParams) {
|
|
531
|
+
const gates = new Map();
|
|
459
532
|
for (const panel of panels) {
|
|
460
|
-
|
|
461
|
-
|
|
533
|
+
const gate = panelVisibilityGate(panel, screenParams);
|
|
534
|
+
if (gate !== undefined)
|
|
535
|
+
gates.set(gate.key, gate);
|
|
536
|
+
}
|
|
537
|
+
return [...gates.values()];
|
|
538
|
+
}
|
|
539
|
+
function GatedGridCell({ panel, screenParams, label, className, children, }) {
|
|
540
|
+
const gate = panelVisibilityGate(panel, screenParams);
|
|
541
|
+
const visibility = useContext(VisibilityQueryContext).get(gate?.key ?? "");
|
|
542
|
+
const visibleWhen = panel.kind === "custom" ? undefined : panel.visibleWhen;
|
|
543
|
+
if (visibleWhen !== undefined) {
|
|
544
|
+
const verdict = evalVisibleWhen(visibleWhen, visibility);
|
|
545
|
+
if (verdict === "error" && visibility?.error) {
|
|
546
|
+
return (_jsx("div", { className: className, "data-testid": `dashboard-panel-${panel.id}`, children: _jsx(PanelError, { label: label, error: visibility.error, onRetry: () => void visibility.refetch() }) }));
|
|
462
547
|
}
|
|
548
|
+
// Loading renders nothing too: a skeleton that then vanishes flickers.
|
|
549
|
+
if (verdict !== "visible")
|
|
550
|
+
return null;
|
|
463
551
|
}
|
|
464
|
-
return
|
|
552
|
+
return _jsx("div", { className: className, children: children });
|
|
465
553
|
}
|
|
466
554
|
function ScreenPanelTile({ panel, featureName, translate, }) {
|
|
467
555
|
const visibleWhen = panel.visibleWhen;
|
|
468
|
-
const visibility = useContext(VisibilityQueryContext).get(visibleWhen
|
|
556
|
+
const visibility = useContext(VisibilityQueryContext).get(visibleWhen !== undefined ? visibilityGate(visibleWhen.query, NO_GATE_PAYLOAD).key : "");
|
|
469
557
|
const target = useEmbeddedScreen(featureName, panel.screen);
|
|
470
558
|
if (target === undefined)
|
|
471
559
|
return null;
|
|
@@ -502,7 +590,7 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
|
|
|
502
590
|
const { params: rangeParams, control: rangeControl } = useTimeRange(screen, effectiveTranslate);
|
|
503
591
|
const screenParams = { filterParams, rangeParams };
|
|
504
592
|
const loadedAtMs = useLoadedAt(screenParams);
|
|
505
|
-
const
|
|
593
|
+
const visibilityGates = distinctVisibilityGates(screen.panels, screenParams);
|
|
506
594
|
const scope = screen.scope;
|
|
507
595
|
// A raw description is agent-facing prose; only an i18n key is user-facing copy.
|
|
508
596
|
const translatedDescription = screen.description !== undefined ? effectiveTranslate(screen.description) : undefined;
|
|
@@ -510,13 +598,13 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
|
|
|
510
598
|
const scopeBadge = scope !== undefined ? (_jsx(StatusBadge, { tone: "accent", testId: `dashboard-${screen.id}-scope`, children: effectiveTranslate(scope.badge) })) : null;
|
|
511
599
|
// The shell header owns the title; the badge sits right after it there.
|
|
512
600
|
const badgeInHeader = scopeBadge !== null && headerSlotAvailable && PageHeader !== undefined;
|
|
513
|
-
return (_jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [badgeInHeader && _jsx(PageHeader, { status: scopeBadge }), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [!badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsx(VisibilityQueryScopes, {
|
|
601
|
+
return (_jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [badgeInHeader && _jsx(PageHeader, { status: scopeBadge }), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [!badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsx(VisibilityQueryScopes, { gates: visibilityGates, children: _jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
|
|
514
602
|
if (panel.kind === "screen") {
|
|
515
603
|
return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
|
|
516
604
|
}
|
|
517
605
|
const label = panel.kind === "custom" || panel.label === undefined
|
|
518
606
|
? undefined
|
|
519
607
|
: effectiveTranslate(panel.label);
|
|
520
|
-
return (_jsx(
|
|
608
|
+
return (_jsx(GatedGridCell, { panel: panel, screenParams: screenParams, label: label ?? panel.id, className: panelSpanClassName(panel), children: _jsx(PanelBody, { panel: panel, label: label, screenId: screen.id, screenParams: screenParams, translate: effectiveTranslate }) }, panel.id));
|
|
521
609
|
}) }) })] }));
|
|
522
610
|
}
|
|
@@ -8,8 +8,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// date part next to the time input. So `date` and `timestamp` share the
|
|
9
9
|
// same typing/navigation UX instead of two diverging primitives (#369).
|
|
10
10
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
11
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
11
12
|
import { useState } from "react";
|
|
12
|
-
import { Temporal } from "temporal-polyfill";
|
|
13
13
|
import { cn } from "../lib/cn.js";
|
|
14
14
|
import { CalendarPopover } from "./calendar-popover.js";
|
|
15
15
|
import { formatDateForInput, formatDatePlaceholder, guessLocale, parseIso, parseTypedDate, toIso, } from "./date-parse.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Temporal } from "temporal
|
|
1
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
2
|
export declare function guessLocale(): string;
|
|
3
3
|
export declare function parseIso(v: string): Temporal.PlainDate | undefined;
|
|
4
4
|
export declare function toIso(d: Temporal.PlainDate): string;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// zone. TZ/wall-clock conversion for timestamp fields deliberately stays
|
|
5
5
|
// in timestamp-input.tsx (wire boundary, own test); this file is pure
|
|
6
6
|
// calendar date.
|
|
7
|
-
import { Temporal } from "temporal
|
|
7
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
8
8
|
// Prefer the native Temporal (Chromium 144+/Firefox 139+) over the bundled
|
|
9
9
|
// polyfill so `instanceof` checks match values crossing package boundaries;
|
|
10
10
|
// falls back to the polyfill where native support is absent.
|
package/dist/primitives/index.js
CHANGED
|
@@ -1828,7 +1828,10 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
|
|
|
1828
1828
|
s.headerActions !== undefined ? (_jsxs("div", { className: cn("flex flex-col gap-3 px-[var(--card-padding)] pt-6 pb-4", fillHeight && "shrink-0"), children: [(s.title !== undefined || s.subtitle !== undefined || s.headerActions !== undefined) && (_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [s.title !== undefined && (_jsx("h3", { className: "text-base font-semibold leading-none tracking-tight", children: s.title })), s.subtitle !== undefined && (_jsx("p", { className: "text-sm text-muted-foreground", children: s.subtitle }))] }), s.headerActions] })), s.headerContent] })) : null;
|
|
1829
1829
|
const header = s.header ?? defaultHeader;
|
|
1830
1830
|
const hasHeader = header !== null && header !== undefined;
|
|
1831
|
-
const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col",
|
|
1831
|
+
const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col",
|
|
1832
|
+
// An unframed card has no radius to clip; overflow-hidden would also
|
|
1833
|
+
// zero its flex-item min-height and cut off content inside a scroller.
|
|
1834
|
+
(framed || fillHeight) && "overflow-hidden",
|
|
1832
1835
|
// Same "no flex-1" reasoning as DefaultForm's own fillHeight card
|
|
1833
1836
|
// (fw#2722/#2778): sizes to content and only shrinks (min-h-0) once
|
|
1834
1837
|
// an ancestor is itself height-constrained.
|
package/dist/widgets/charts.d.ts
CHANGED
|
@@ -87,6 +87,7 @@ export declare function TimeseriesChart({ points, windowStartMs, windowEndMs, to
|
|
|
87
87
|
readonly testId?: string;
|
|
88
88
|
}): ReactNode;
|
|
89
89
|
export type ChartTone = "positive" | "negative" | "active" | "neutral";
|
|
90
|
+
export declare function chartToneColor(tone: ChartTone): string;
|
|
90
91
|
export type ChartSeries = {
|
|
91
92
|
readonly key: string;
|
|
92
93
|
/** Translated by the caller. */
|
|
@@ -96,6 +97,12 @@ export type ChartSeries = {
|
|
|
96
97
|
export type ChartMarker = {
|
|
97
98
|
readonly atMs: number;
|
|
98
99
|
readonly label: string;
|
|
100
|
+
/** Colors the pin and, on StackedAreaChart, draws a dashed guide line in the plot. */
|
|
101
|
+
readonly color?: string;
|
|
102
|
+
};
|
|
103
|
+
/** Unstacked line drawn over a StackedAreaChart on the same y scale. */
|
|
104
|
+
export type ChartLine = ChartSeries & {
|
|
105
|
+
readonly dashed?: boolean;
|
|
99
106
|
};
|
|
100
107
|
export type SegmentBarRow = {
|
|
101
108
|
readonly key: string;
|
|
@@ -108,13 +115,16 @@ export type SegmentBarRow = {
|
|
|
108
115
|
}[];
|
|
109
116
|
};
|
|
110
117
|
type ToneMap = Readonly<Record<string, ChartTone>>;
|
|
118
|
+
/** Key -> raw CSS color; wins over the tone map. */
|
|
119
|
+
type ColorMap = Readonly<Record<string, string>>;
|
|
111
120
|
/** Vertically stacked bars per bucket (x axis = bucket, not time).
|
|
112
121
|
* The last bucket carries `todayLabel` when it contains `windowEndMs` and
|
|
113
122
|
* has day granularity. */
|
|
114
|
-
export declare function StackedBarChart({ series, windowEndMs, tones, ariaLabel, todayLabel, formatBucketLabel, formatValue, emptyContent, testId, }: {
|
|
123
|
+
export declare function StackedBarChart({ series, windowEndMs, tones, colors, ariaLabel, todayLabel, formatBucketLabel, formatValue, emptyContent, testId, }: {
|
|
115
124
|
readonly series: readonly ChartSeries[];
|
|
116
125
|
readonly windowEndMs: number;
|
|
117
126
|
readonly tones?: ToneMap;
|
|
127
|
+
readonly colors?: ColorMap;
|
|
118
128
|
readonly ariaLabel: string;
|
|
119
129
|
readonly todayLabel: string;
|
|
120
130
|
readonly formatBucketLabel: (atMs: number) => string;
|
|
@@ -124,22 +134,26 @@ export declare function StackedBarChart({ series, windowEndMs, tones, ariaLabel,
|
|
|
124
134
|
}): ReactNode;
|
|
125
135
|
/** One horizontally stacked bar per row; bar length is relative to the
|
|
126
136
|
* largest row so rows stay comparable. */
|
|
127
|
-
export declare function SegmentBarChart({ rows, tones, ariaLabel, formatValue, testId, }: {
|
|
137
|
+
export declare function SegmentBarChart({ rows, tones, colors, ariaLabel, formatValue, testId, }: {
|
|
128
138
|
readonly rows: readonly SegmentBarRow[];
|
|
129
139
|
readonly tones?: ToneMap;
|
|
140
|
+
readonly colors?: ColorMap;
|
|
130
141
|
readonly ariaLabel: string;
|
|
131
142
|
readonly formatValue?: (value: number) => string;
|
|
132
143
|
readonly testId?: string;
|
|
133
144
|
}): ReactNode;
|
|
134
145
|
/** Stacked bands over time. Right of `todayMs` is the forecast: lighter
|
|
135
146
|
* fill plus a vertical "today" line. */
|
|
136
|
-
export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, scrollable, emptyContent, testId, }: {
|
|
147
|
+
export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, colors, markers, lines, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, scrollable, showLegendTotals, emptyContent, testId, }: {
|
|
137
148
|
readonly series: readonly ChartSeries[];
|
|
138
149
|
readonly windowStartMs: number;
|
|
139
150
|
readonly windowEndMs: number;
|
|
140
151
|
readonly todayMs?: number;
|
|
141
152
|
readonly tones?: ToneMap;
|
|
153
|
+
readonly colors?: ColorMap;
|
|
142
154
|
readonly markers?: readonly ChartMarker[];
|
|
155
|
+
/** Drawn over the bands, not stacked. A `null` value breaks the line. */
|
|
156
|
+
readonly lines?: readonly ChartLine[];
|
|
143
157
|
readonly ariaLabel: string;
|
|
144
158
|
readonly todayLabel: string;
|
|
145
159
|
readonly formatBucketLabel: (atMs: number) => string;
|
|
@@ -147,6 +161,7 @@ export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, t
|
|
|
147
161
|
readonly formatMarkerTime?: (atMs: number) => string;
|
|
148
162
|
/** Fixed width per bucket; the plot scrolls horizontally, y ticks stay put. */
|
|
149
163
|
readonly scrollable?: boolean;
|
|
164
|
+
readonly showLegendTotals?: boolean;
|
|
150
165
|
readonly emptyContent?: ReactNode;
|
|
151
166
|
readonly testId?: string;
|
|
152
167
|
}): ReactNode;
|
package/dist/widgets/charts.js
CHANGED
|
@@ -130,8 +130,14 @@ const CHART_TONE_VAR = {
|
|
|
130
130
|
active: "var(--color-status-active)",
|
|
131
131
|
neutral: "var(--color-status-neutral)",
|
|
132
132
|
};
|
|
133
|
+
export function chartToneColor(tone) {
|
|
134
|
+
return CHART_TONE_VAR[tone];
|
|
135
|
+
}
|
|
133
136
|
const CHART_FALLBACK_TONES = ["active", "positive", "negative", "neutral"];
|
|
134
|
-
function chartColor(key, index, tones) {
|
|
137
|
+
function chartColor(key, index, tones, colors) {
|
|
138
|
+
const color = colors?.[key];
|
|
139
|
+
if (color !== undefined)
|
|
140
|
+
return color;
|
|
135
141
|
const tone = tones?.[key] ?? CHART_FALLBACK_TONES[index % CHART_FALLBACK_TONES.length] ?? "active";
|
|
136
142
|
return CHART_TONE_VAR[tone];
|
|
137
143
|
}
|
|
@@ -159,6 +165,19 @@ function bucketTimes(series) {
|
|
|
159
165
|
function valueAt(series, atMs) {
|
|
160
166
|
return series.points.find((p) => p.atMs === atMs)?.value ?? 0;
|
|
161
167
|
}
|
|
168
|
+
function linePathSegments(line, xOf, yOf) {
|
|
169
|
+
const commands = [];
|
|
170
|
+
let penDown = false;
|
|
171
|
+
for (const point of [...line.points].sort((a, b) => a.atMs - b.atMs)) {
|
|
172
|
+
if (point.value === null) {
|
|
173
|
+
penDown = false;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
commands.push(`${penDown ? "L" : "M"} ${xOf(point.atMs).toFixed(1)} ${yOf(point.value).toFixed(1)}`);
|
|
177
|
+
penDown = true;
|
|
178
|
+
}
|
|
179
|
+
return commands.join(" ");
|
|
180
|
+
}
|
|
162
181
|
function seriesTotal(series) {
|
|
163
182
|
return series.points.reduce((sum, p) => sum + (p.value ?? 0), 0);
|
|
164
183
|
}
|
|
@@ -166,7 +185,10 @@ function percent(fraction) {
|
|
|
166
185
|
return `${(fraction * 100).toFixed(2)}%`;
|
|
167
186
|
}
|
|
168
187
|
function ChartLegend({ items, formatValue, }) {
|
|
169
|
-
return (_jsx("ul", { className: "flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground", children: items.map((item) => (_jsxs("li", { "data-testid": `chart-legend-${item.key}`, className: "flex items-center gap-1.5", children: [_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-[2px]", style: { backgroundColor: item.color } })
|
|
188
|
+
return (_jsx("ul", { className: "flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground", children: items.map((item) => (_jsxs("li", { "data-testid": `chart-legend-${item.key}`, className: "flex items-center gap-1.5", children: [item.swatch === undefined || item.swatch === "area" ? (_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-[2px]", style: { backgroundColor: item.color } })) : (_jsx("span", { "aria-hidden": "true", className: "w-3 border-t-2", style: {
|
|
189
|
+
borderColor: item.color,
|
|
190
|
+
borderTopStyle: item.swatch === "dashed-line" ? "dashed" : "solid",
|
|
191
|
+
} })), _jsx("span", { children: item.label }), item.total !== undefined && (_jsx("span", { className: "tabular-nums text-foreground", children: formatValue(item.total) }))] }, item.key))) }));
|
|
170
192
|
}
|
|
171
193
|
function YTicks({ max, formatValue, className, }) {
|
|
172
194
|
return (_jsx("div", { "aria-hidden": "true", className: cn("flex flex-col-reverse justify-between text-right text-[11px] tabular-nums text-muted-foreground", className), children: Array.from({ length: Y_TICK_COUNT }, (_, i) => (_jsx("span", { children: formatValue((max / (Y_TICK_COUNT - 1)) * i) }, i))) }));
|
|
@@ -175,7 +197,7 @@ const BAR_LABEL_TARGET = 8;
|
|
|
175
197
|
/** Vertically stacked bars per bucket (x axis = bucket, not time).
|
|
176
198
|
* The last bucket carries `todayLabel` when it contains `windowEndMs` and
|
|
177
199
|
* has day granularity. */
|
|
178
|
-
export function StackedBarChart({ series, windowEndMs, tones, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, emptyContent, testId, }) {
|
|
200
|
+
export function StackedBarChart({ series, windowEndMs, tones, colors, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, emptyContent, testId, }) {
|
|
179
201
|
const times = bucketTimes(series);
|
|
180
202
|
if (times.length === 0)
|
|
181
203
|
return _jsx("div", { "data-testid": testId, children: emptyContent });
|
|
@@ -195,19 +217,19 @@ export function StackedBarChart({ series, windowEndMs, tones, ariaLabel, todayLa
|
|
|
195
217
|
return null;
|
|
196
218
|
return (_jsx("div", { "data-testid": `chart-bar-segment-${s.key}`, style: {
|
|
197
219
|
height: percent(value / (totals[i] ?? 1)),
|
|
198
|
-
backgroundColor: chartColor(s.key, si, tones),
|
|
220
|
+
backgroundColor: chartColor(s.key, si, tones, colors),
|
|
199
221
|
} }, s.key));
|
|
200
222
|
}) }) }), _jsx("div", { className: "h-5 pt-1 text-center text-[11px] leading-4 whitespace-nowrap text-muted-foreground", children: (lastIdx - i) % labelEvery === 0 ? label : "" })] }, atMs));
|
|
201
223
|
}) })] }), _jsx(ChartLegend, { formatValue: formatValue, items: series.map((s, i) => ({
|
|
202
224
|
key: s.key,
|
|
203
225
|
label: s.label,
|
|
204
|
-
color: chartColor(s.key, i, tones),
|
|
226
|
+
color: chartColor(s.key, i, tones, colors),
|
|
205
227
|
total: seriesTotal(s),
|
|
206
228
|
})) })] }));
|
|
207
229
|
}
|
|
208
230
|
/** One horizontally stacked bar per row; bar length is relative to the
|
|
209
231
|
* largest row so rows stay comparable. */
|
|
210
|
-
export function SegmentBarChart({ rows, tones, ariaLabel, formatValue = String, testId, }) {
|
|
232
|
+
export function SegmentBarChart({ rows, tones, colors, ariaLabel, formatValue = String, testId, }) {
|
|
211
233
|
const rowTotal = (row) => row.segments.length > 0 ? row.segments.reduce((sum, s) => sum + s.value, 0) : row.value;
|
|
212
234
|
const maxTotal = Math.max(1, ...rows.map(rowTotal));
|
|
213
235
|
const segmentTotals = new Map();
|
|
@@ -223,23 +245,35 @@ export function SegmentBarChart({ rows, tones, ariaLabel, formatValue = String,
|
|
|
223
245
|
const legendKeys = [...segmentTotals.keys()];
|
|
224
246
|
return (_jsxs("div", { "data-testid": testId, role: "img", "aria-label": ariaLabel, className: "flex flex-col gap-3", children: [_jsx("ul", { className: "flex flex-col gap-3", children: rows.map((row) => (_jsxs("li", { "data-testid": `chart-row-${row.key}`, className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium", children: row.label }), _jsx("span", { className: "tabular-nums text-muted-foreground", children: formatValue(row.value) })] }), _jsx("div", { className: "flex h-2 gap-px overflow-hidden rounded-full bg-muted", children: row.segments.map((seg) => (_jsx("div", { title: `${seg.label}: ${formatValue(seg.value)}`, className: "h-full", style: {
|
|
225
247
|
width: percent(seg.value / maxTotal),
|
|
226
|
-
backgroundColor: chartColor(seg.key, legendKeys.indexOf(seg.key), tones),
|
|
248
|
+
backgroundColor: chartColor(seg.key, legendKeys.indexOf(seg.key), tones, colors),
|
|
227
249
|
} }, seg.key))) })] }, row.key))) }), _jsx(ChartLegend, { formatValue: formatValue, items: legendKeys.map((key, i) => ({
|
|
228
250
|
key,
|
|
229
251
|
label: segmentTotals.get(key)?.label ?? key,
|
|
230
|
-
color: chartColor(key, i, tones),
|
|
252
|
+
color: chartColor(key, i, tones, colors),
|
|
231
253
|
total: segmentTotals.get(key)?.total ?? 0,
|
|
232
254
|
})) })] }));
|
|
233
255
|
}
|
|
256
|
+
// Two markers may share atMs and label, so the occurrence count keeps React keys unique.
|
|
234
257
|
function sortMarkers(markers) {
|
|
235
|
-
|
|
258
|
+
const seen = new Map();
|
|
259
|
+
return [...markers]
|
|
260
|
+
.sort((a, b) => a.atMs - b.atMs)
|
|
261
|
+
.map((marker) => {
|
|
262
|
+
const base = `${marker.atMs}-${marker.label}`;
|
|
263
|
+
const occurrence = seen.get(base) ?? 0;
|
|
264
|
+
seen.set(base, occurrence + 1);
|
|
265
|
+
return { ...marker, key: `${base}-${occurrence}` };
|
|
266
|
+
});
|
|
236
267
|
}
|
|
237
268
|
function MarkerPins({ markers, windowStartMs, windowEndMs, }) {
|
|
238
269
|
const span = Math.max(1, windowEndMs - windowStartMs);
|
|
239
|
-
return (_jsx("div", { "aria-hidden": "true", className: "relative mt-1 h-5", children: sortMarkers(markers).map((marker, i) => (_jsx("span", { "data-testid": "chart-marker-pin", className: "absolute flex size-4 -translate-x-1/2 items-center justify-center rounded-full
|
|
270
|
+
return (_jsx("div", { "aria-hidden": "true", className: "relative mt-1 h-5", children: sortMarkers(markers).map((marker, i) => (_jsx("span", { "data-testid": "chart-marker-pin", className: cn("absolute flex size-4 -translate-x-1/2 items-center justify-center rounded-full text-[10px] font-medium text-background", marker.color === undefined && "bg-foreground"), style: {
|
|
271
|
+
left: percent(Math.max(0, Math.min(1, (marker.atMs - windowStartMs) / span))),
|
|
272
|
+
...(marker.color !== undefined && { backgroundColor: marker.color }),
|
|
273
|
+
}, children: i + 1 }, marker.key))) }));
|
|
240
274
|
}
|
|
241
275
|
function MarkerLegend({ markers, formatMarkerTime, }) {
|
|
242
|
-
return (_jsx("ol", { className: "flex flex-col gap-1 text-xs text-muted-foreground", children: sortMarkers(markers).map((marker, i) => (_jsxs("li", { "data-testid": "chart-marker-item", className: "flex gap-2", children: [_jsx("span", { className: "w-4 shrink-0 text-center tabular-nums text-foreground", children: i + 1 }), _jsx("span", { children: marker.label }), formatMarkerTime !== undefined && (_jsx("span", { className: "ml-auto tabular-nums", children: formatMarkerTime(marker.atMs) }))] },
|
|
276
|
+
return (_jsx("ol", { className: "flex flex-col gap-1 text-xs text-muted-foreground", children: sortMarkers(markers).map((marker, i) => (_jsxs("li", { "data-testid": "chart-marker-item", className: "flex gap-2", children: [_jsx("span", { className: "w-4 shrink-0 text-center tabular-nums text-foreground", style: marker.color !== undefined ? { color: marker.color } : undefined, children: i + 1 }), _jsx("span", { children: marker.label }), formatMarkerTime !== undefined && (_jsx("span", { className: "ml-auto tabular-nums", children: formatMarkerTime(marker.atMs) }))] }, marker.key))) }));
|
|
243
277
|
}
|
|
244
278
|
const SCROLLABLE_BUCKET_WIDTH_PX = 48;
|
|
245
279
|
function bucketLabelTransform(index, count) {
|
|
@@ -249,10 +283,10 @@ function bucketLabelTransform(index, count) {
|
|
|
249
283
|
}
|
|
250
284
|
/** Stacked bands over time. Right of `todayMs` is the forecast: lighter
|
|
251
285
|
* fill plus a vertical "today" line. */
|
|
252
|
-
export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, scrollable = false, emptyContent, testId, }) {
|
|
286
|
+
export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, colors, markers, lines = [], ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, scrollable = false, showLegendTotals = true, emptyContent, testId, }) {
|
|
253
287
|
const clipId = useId();
|
|
254
288
|
const scrollRef = useRef(null);
|
|
255
|
-
const times = bucketTimes(series);
|
|
289
|
+
const times = bucketTimes([...series, ...lines]);
|
|
256
290
|
const bucketCount = times.length;
|
|
257
291
|
const span = Math.max(1, windowEndMs - windowStartMs);
|
|
258
292
|
const fractionOf = (atMs) => Math.max(0, Math.min(1, (atMs - windowStartMs) / span));
|
|
@@ -271,9 +305,10 @@ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs,
|
|
|
271
305
|
const width = 300;
|
|
272
306
|
const height = 120;
|
|
273
307
|
const totals = times.map((atMs) => series.reduce((sum, s) => sum + valueAt(s, atMs), 0));
|
|
308
|
+
const lineValues = lines.flatMap((line) => line.points.flatMap((p) => (p.value === null ? [] : [p.value])));
|
|
274
309
|
const isSingleBucket = times.length === 1;
|
|
275
310
|
// Doubling keeps a lone bucket away from the top edge of the y-scale.
|
|
276
|
-
const max = niceCeil(Math.max(...totals) * (isSingleBucket ? 2 : 1));
|
|
311
|
+
const max = niceCeil(Math.max(...totals, ...lineValues) * (isSingleBucket ? 2 : 1));
|
|
277
312
|
const yOf = (value) => height - (value / max) * height;
|
|
278
313
|
const lower = times.map(() => 0);
|
|
279
314
|
const bands = series.map((s, si) => {
|
|
@@ -288,21 +323,41 @@ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs,
|
|
|
288
323
|
lower[ti] = upper[ti] ?? 0;
|
|
289
324
|
return {
|
|
290
325
|
key: s.key,
|
|
291
|
-
color: chartColor(s.key, si, tones),
|
|
326
|
+
color: chartColor(s.key, si, tones, colors),
|
|
292
327
|
d: `M ${top.join(" L ")} L ${bottom.join(" L ")} Z`,
|
|
293
328
|
dot: isSingleBucket && bucketHeight > 0 ? clampDotY(yOf(bucketTop), height) : undefined,
|
|
294
329
|
};
|
|
295
330
|
});
|
|
331
|
+
const lineColorOffset = series.length;
|
|
332
|
+
const linePaths = lines.map((line, li) => ({
|
|
333
|
+
key: line.key,
|
|
334
|
+
label: line.label,
|
|
335
|
+
dashed: line.dashed === true,
|
|
336
|
+
color: chartColor(line.key, lineColorOffset + li, tones, colors),
|
|
337
|
+
d: linePathSegments(line, (atMs) => fractionOf(atMs) * width, yOf),
|
|
338
|
+
}));
|
|
339
|
+
const coloredMarkers = sortMarkers(markers ?? []).filter((marker) => marker.color !== undefined);
|
|
296
340
|
const todayX = (todayFraction ?? 1) * width;
|
|
297
341
|
const renderBands = (fillOpacity) => bands.map((band) => band.dot === undefined ? (_jsx("path", { d: band.d, fill: band.color, fillOpacity: fillOpacity, stroke: "none" }, band.key)) : (_jsx("circle", { "data-testid": "stacked-area-single-point", cx: width / 2, cy: band.dot, r: SINGLE_POINT_RADIUS, fill: band.color, fillOpacity: fillOpacity }, band.key)));
|
|
298
|
-
const plot = (_jsxs(_Fragment, { children: [todayFraction !== undefined && (_jsx("span", { "data-testid": "chart-today-label", className: "absolute -top-4 -translate-x-1/2 text-[11px] text-foreground", style: { left: percent(todayFraction) }, children: todayLabel })), _jsxs("svg", { viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", className: "block h-32 w-full", role: "img", "aria-label": ariaLabel, children: [_jsx("title", { children: ariaLabel }), _jsxs("defs", { children: [_jsx("clipPath", { id: `${clipId}-past`, children: _jsx("rect", { x: 0, y: 0, width: todayX, height: height }) }), _jsx("clipPath", { id: `${clipId}-future`, children: _jsx("rect", { x: todayX, y: 0, width: width - todayX, height: height }) })] }), _jsx("g", { clipPath: `url(#${clipId}-past)`, children: renderBands(0.85) }), todayFraction !== undefined && (_jsx("g", { "data-testid": "chart-forecast-region", clipPath: `url(#${clipId}-future)`, children: renderBands(0.3) })),
|
|
342
|
+
const plot = (_jsxs(_Fragment, { children: [todayFraction !== undefined && (_jsx("span", { "data-testid": "chart-today-label", className: "absolute -top-4 -translate-x-1/2 text-[11px] text-foreground", style: { left: percent(todayFraction) }, children: todayLabel })), _jsxs("svg", { viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", className: "block h-32 w-full", role: "img", "aria-label": ariaLabel, children: [_jsx("title", { children: ariaLabel }), _jsxs("defs", { children: [_jsx("clipPath", { id: `${clipId}-past`, children: _jsx("rect", { x: 0, y: 0, width: todayX, height: height }) }), _jsx("clipPath", { id: `${clipId}-future`, children: _jsx("rect", { x: todayX, y: 0, width: width - todayX, height: height }) })] }), _jsx("g", { clipPath: `url(#${clipId}-past)`, children: renderBands(0.85) }), todayFraction !== undefined && (_jsx("g", { "data-testid": "chart-forecast-region", clipPath: `url(#${clipId}-future)`, children: renderBands(0.3) })), coloredMarkers.map((marker) => {
|
|
343
|
+
const x = fractionOf(marker.atMs) * width;
|
|
344
|
+
return (_jsx("line", { "data-testid": "chart-marker-guide", x1: x, x2: x, y1: 0, y2: height, stroke: marker.color, strokeWidth: 1, strokeDasharray: "2 3", vectorEffect: "non-scaling-stroke" }, marker.key));
|
|
345
|
+
}), linePaths.map((line) => (_jsx("path", { "data-testid": `chart-line-${line.key}`, d: line.d, fill: "none", stroke: line.color, strokeWidth: 2, strokeDasharray: line.dashed ? "6 4" : undefined, vectorEffect: "non-scaling-stroke" }, line.key))), todayFraction !== undefined && (_jsx("line", { "data-testid": "chart-today-line", x1: todayX, x2: todayX, y1: 0, y2: height, stroke: "var(--color-foreground)", strokeWidth: 1, strokeDasharray: "3 3", vectorEffect: "non-scaling-stroke" }))] }), scrollable ? (_jsx("div", { "data-testid": "chart-bucket-labels", className: "relative mt-1 h-4 text-[11px] text-muted-foreground", children: times.map((atMs, i) => (_jsx("span", { className: cn("absolute whitespace-nowrap", bucketLabelTransform(i, times.length)), style: { left: percent(fractionOf(atMs)) }, children: formatBucketLabel(atMs) }, atMs))) })) : (_jsxs("div", { className: "mt-1 flex justify-between text-[11px] text-muted-foreground", children: [_jsx("span", { children: formatBucketLabel(windowStartMs) }), _jsx("span", { children: formatBucketLabel(windowEndMs) })] })), markers !== undefined && markers.length > 0 && (_jsx(MarkerPins, { markers: markers, windowStartMs: windowStartMs, windowEndMs: windowEndMs }))] }));
|
|
299
346
|
return (_jsxs("div", { "data-testid": testId, className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex gap-2", children: [_jsx(YTicks, { max: max, formatValue: formatValue, className: cn("h-32 pb-0", scrollable && "mt-4") }), scrollable ? (_jsx("div", { ref: scrollRef, "data-testid": "chart-scroll-container", className: "grow overflow-x-auto pt-4", children: _jsx("div", { "data-testid": "chart-scroll-content", className: "relative", style: {
|
|
300
347
|
minWidth: "100%",
|
|
301
348
|
width: `${times.length * SCROLLABLE_BUCKET_WIDTH_PX}px`,
|
|
302
|
-
}, children: plot }) })) : (_jsx("div", { className: "relative grow", children: plot }))] }), _jsx(ChartLegend, { formatValue: formatValue, items:
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
349
|
+
}, children: plot }) })) : (_jsx("div", { className: "relative grow", children: plot }))] }), _jsx(ChartLegend, { formatValue: formatValue, items: [
|
|
350
|
+
...series.map((s, i) => ({
|
|
351
|
+
key: s.key,
|
|
352
|
+
label: s.label,
|
|
353
|
+
color: chartColor(s.key, i, tones, colors),
|
|
354
|
+
...(showLegendTotals && { total: seriesTotal(s) }),
|
|
355
|
+
})),
|
|
356
|
+
...linePaths.map((line) => ({
|
|
357
|
+
key: line.key,
|
|
358
|
+
label: line.label,
|
|
359
|
+
color: line.color,
|
|
360
|
+
swatch: line.dashed ? "dashed-line" : "line",
|
|
361
|
+
})),
|
|
362
|
+
] }), markers !== undefined && markers.length > 0 && (_jsx(MarkerLegend, { markers: markers, formatMarkerTime: formatMarkerTime }))] }));
|
|
308
363
|
}
|
|
@@ -4,6 +4,8 @@ export type ProgressListRow = {
|
|
|
4
4
|
readonly label: string;
|
|
5
5
|
readonly value: string;
|
|
6
6
|
readonly fraction: number;
|
|
7
|
+
/** Small line under the bar, e.g. "42 % repaid". */
|
|
8
|
+
readonly sub?: string;
|
|
7
9
|
};
|
|
8
10
|
/** Liste aus Label/Wert-Kopfzeile + Fortschrittsbalken pro Eintrag (z.B.
|
|
9
11
|
* Tilgungsfortschritt pro Kredit). */
|
|
@@ -6,5 +6,5 @@ export function ProgressList({ rows, emptyContent, testId, }) {
|
|
|
6
6
|
if (rows.length === 0) {
|
|
7
7
|
return _jsx("div", { "data-testid": testId, children: emptyContent });
|
|
8
8
|
}
|
|
9
|
-
return (_jsx("ul", { "data-testid": testId, className: "flex max-h-64 flex-col gap-3 overflow-y-auto pr-1", children: rows.map((row) => (_jsxs("li", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium", children: row.label }), _jsx("span", { className: "tabular-nums text-muted-foreground", children: row.value })] }), _jsx(ProgressBar, { value: row.fraction })] }, row.id))) }));
|
|
9
|
+
return (_jsx("ul", { "data-testid": testId, className: "flex max-h-64 flex-col gap-3 overflow-y-auto pr-1", children: rows.map((row) => (_jsxs("li", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium", children: row.label }), _jsx("span", { className: "tabular-nums text-muted-foreground", children: row.value })] }), _jsx(ProgressBar, { value: row.fraction }), row.sub !== undefined && _jsx("div", { className: "text-xs text-muted-foreground", children: row.sub })] }, row.id))) }));
|
|
10
10
|
}
|
package/dist/widgets/stat.d.ts
CHANGED
|
@@ -28,11 +28,14 @@ export declare function StatCard({ icon, label, value, sub, tone, accentColor, d
|
|
|
28
28
|
}): ReactNode;
|
|
29
29
|
/** Flat KPI cell for a strip (divider instead of card): label, value with
|
|
30
30
|
* sparkline beside it, sub line. */
|
|
31
|
-
export declare function StatStripCell({ label, value, sub, tone, delta, spark, testId, }: {
|
|
31
|
+
export declare function StatStripCell({ icon, label, value, sub, tone, accentColor, delta, spark, testId, }: {
|
|
32
|
+
/** Chip before the label, colored like StatCard's (`accentColor` or the tone). */
|
|
33
|
+
readonly icon?: ReactNode;
|
|
32
34
|
readonly label: string;
|
|
33
35
|
readonly value: string;
|
|
34
36
|
readonly sub?: string;
|
|
35
37
|
readonly tone?: StatTone;
|
|
38
|
+
readonly accentColor?: string;
|
|
36
39
|
readonly delta?: StatDelta;
|
|
37
40
|
readonly spark?: readonly number[];
|
|
38
41
|
readonly testId?: string;
|
package/dist/widgets/stat.js
CHANGED
|
@@ -32,6 +32,14 @@ export function Sparkline({ points, className, }) {
|
|
|
32
32
|
.join(" ");
|
|
33
33
|
return (_jsxs("svg", { viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: "none", className: className ?? "h-7 w-full", "aria-hidden": "true", children: [_jsx("path", { d: `${line} L${w},${h} L0,${h} Z`, fill: "currentColor", fillOpacity: 0.12 }), _jsx("path", { d: line, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" })] }));
|
|
34
34
|
}
|
|
35
|
+
function StatIconChip({ icon, tone, accentColor, }) {
|
|
36
|
+
return (_jsx("span", { className: cn("flex size-7 shrink-0 items-center justify-center rounded-lg", accentColor === undefined && TONE_CHIP[tone]), style: accentColor === undefined
|
|
37
|
+
? undefined
|
|
38
|
+
: {
|
|
39
|
+
color: accentColor,
|
|
40
|
+
backgroundColor: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
|
|
41
|
+
}, children: icon }));
|
|
42
|
+
}
|
|
35
43
|
/** Icon-tragende Kennzahl-Kachel mit optionalem Delta-Chip, Trend-Zeile
|
|
36
44
|
* und Sparkline. `icon` ist ein fertiger Knoten (App liefert ihr SVG). */
|
|
37
45
|
export function StatCard({ icon, label, value, sub, tone = "default", accentColor, delta, trend, spark, testId, children, }) {
|
|
@@ -40,17 +48,12 @@ export function StatCard({ icon, label, value, sub, tone = "default", accentColo
|
|
|
40
48
|
// a whole-section frame) — an extension section mounting several of
|
|
41
49
|
// these renders a grid of tiles inside its own host Card, not a
|
|
42
50
|
// duplicate outer frame around the section's full content.
|
|
43
|
-
return (_jsxs(Card, { options: { padded: false }, className: "p-4", testId: testId, children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-x-2 gap-y-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 basis-[7rem] items-center gap-2 text-muted-foreground", children: [icon !== undefined && (_jsx("span", { className: cn("flex
|
|
44
|
-
? undefined
|
|
45
|
-
: {
|
|
46
|
-
color: accentColor,
|
|
47
|
-
backgroundColor: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
|
|
48
|
-
}, children: icon })), _jsx("span", { title: label, className: "min-w-0 line-clamp-2 text-xs font-medium", children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsx("div", { className: "mt-2 text-xl font-semibold tabular-nums text-foreground", children: value }), sub !== undefined && _jsx("div", { className: "mt-0.5 text-xs text-muted-foreground", children: sub }), trend !== undefined && (_jsx("div", { className: "mt-0.5 text-xs font-medium text-foreground/80", children: trend })), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("mt-2 h-7 w-full", TONE_VALUE[tone]) })), children !== undefined && _jsx("div", { className: "mt-3", children: children })] }));
|
|
51
|
+
return (_jsxs(Card, { options: { padded: false }, className: "p-4", testId: testId, children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-x-2 gap-y-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 basis-[7rem] items-center gap-2 text-muted-foreground", children: [icon !== undefined && _jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor }), _jsx("span", { title: label, className: "min-w-0 line-clamp-2 text-xs font-medium", children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsx("div", { className: "mt-2 text-xl font-semibold tabular-nums text-foreground", children: value }), sub !== undefined && _jsx("div", { className: "mt-0.5 text-xs text-muted-foreground", children: sub }), trend !== undefined && (_jsx("div", { className: "mt-0.5 text-xs font-medium text-foreground/80", children: trend })), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("mt-2 h-7 w-full", TONE_VALUE[tone]) })), children !== undefined && _jsx("div", { className: "mt-3", children: children })] }));
|
|
49
52
|
}
|
|
50
53
|
/** Flat KPI cell for a strip (divider instead of card): label, value with
|
|
51
54
|
* sparkline beside it, sub line. */
|
|
52
|
-
export function StatStripCell({ label, value, sub, tone = "default", delta, spark, testId, }) {
|
|
53
|
-
return (_jsxs("div", { "data-testid": testId, className: "flex min-w-0 flex-col gap-1 px-4 py-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
55
|
+
export function StatStripCell({ icon, label, value, sub, tone = "default", accentColor, delta, spark, testId, }) {
|
|
56
|
+
return (_jsxs("div", { "data-testid": testId, className: "flex min-w-0 flex-col gap-1 px-4 py-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2 text-xs font-medium text-muted-foreground", children: [icon !== undefined && _jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor }), _jsx("span", { children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: cn("text-xl font-semibold tabular-nums", TONE_VALUE[tone]), children: value }), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("h-7 w-20 shrink-0", TONE_VALUE[tone]) }))] }), sub !== undefined && _jsx("div", { className: "text-xs text-muted-foreground", children: sub })] }));
|
|
54
57
|
}
|
|
55
58
|
/** Kompakte Kennzahl-Kachel (ohne Icon) — für dichte KPI-Raster.
|
|
56
59
|
* StatCard ist die icon-tragende Variante. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.351.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
24
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
25
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
26
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
27
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
23
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.351.0",
|
|
24
|
+
"@cosmicdrift/kumiko-framework": "0.351.0",
|
|
25
|
+
"@cosmicdrift/kumiko-headless": "0.351.0",
|
|
26
|
+
"@cosmicdrift/kumiko-renderer": "0.351.0",
|
|
27
|
+
"@cosmicdrift/kumiko-types": "0.351.0",
|
|
28
28
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
29
29
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
30
30
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@types/react-dom": "^19.2.3",
|
|
73
73
|
"jsdom": "^29.1.1",
|
|
74
74
|
"tailwindcss": "^4.3.0",
|
|
75
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
75
|
+
"@cosmicdrift/kumiko-locale-de": "0.351.0"
|
|
76
76
|
},
|
|
77
77
|
"repository": {
|
|
78
78
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.351.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Dashboard panels take visibleWhen; stat-group subtitle and strip icons, progress-list sub line, stacked-area lines, markerKinds, seriesColors, legendTotals and ranges",
|
|
6
|
+
"detail": "Dashboard panel gates, stat-group subtitle, progress sub line, stacked-area lines, marker kinds and ranges\n`stat`, `stat-group`, `chart`, `list`, `feed` and `progress-list` panels take `visibleWhen` (`DashboardPanelGate`), like `screen` panels. The gate query gets the screen filter and time range. The panel renders nothing while the gate loads or is unmet, and shows an error with retry when the gate query fails. Panels with the same gate query and payload share one live request. The boot validator checks the gate query and field and rejects `visibleWhen` on stat-group children.\nA labeled `stat-group` takes a `subtitle`. In an unlabeled group (KPI strip) each child keeps its `icon` and `accentColor`. `progress-list` rows take an optional `sub` (`DashboardText`) shown under the bar.\n`stacked-area` results can carry `lines` (`{ key, label, points, dashed? }`, drawn unstacked over the bands) and `markers[].kind`. New chart options: `seriesColors` (key to CSS color, wins over `seriesTones`), and only for stacked-area `markerKinds` (kind to `{ tone }` or `{ color }`: colored pin plus dashed guide line), `legendTotals: false` and `ranges` (a range switch in the panel header that windows bands, lines and markers by `months`). `StackedAreaChart` gets `lines`, `colors` and `showLegendTotals`; `ChartMarker` gets `color`."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.351.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Tabbed projectionDetail panels taller than the viewport scroll again"
|
|
12
|
+
},
|
|
2
13
|
{
|
|
3
14
|
"version": "0.350.0",
|
|
4
15
|
"type": "improvement",
|