@cosmicdrift/kumiko-renderer-web 0.348.1 → 0.349.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.
@@ -4,6 +4,7 @@ import { dispatcherErrorText, EmbeddedScreenProvider, evalVisibleWhen, extension
4
4
  import { createContext, useContext, useEffect, useMemo, useState } from "react";
5
5
  import { EmbeddedFormProvider } from "../primitives/index.js";
6
6
  import { PageSection } from "../primitives/layout.js";
7
+ import { formatMoney } from "../primitives/money-input.js";
7
8
  import { Skeleton } from "../ui/skeleton.js";
8
9
  import { SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
9
10
  import { DashboardListTable } from "../widgets/dashboard-list.js";
@@ -41,7 +42,14 @@ function useDashboardFormats() {
41
42
  timeStyle: "short",
42
43
  timeZone,
43
44
  });
45
+ const medium = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone });
46
+ const formatMinor = (amountMinor, currency, fractionDigits) => formatMoney(amountMinor, currency, locale, fractionDigits);
44
47
  return {
48
+ formatMedium: (atMs) => medium.format(atMs),
49
+ formatMinor,
50
+ formatPanelValue: (valueFormat) => valueFormat === undefined
51
+ ? (value) => number.format(value)
52
+ : (value) => formatMinor(value, valueFormat.currency, valueFormat.fractionDigits),
45
53
  formatNumber: (value) => number.format(value),
46
54
  formatPercent: (fraction) => percent.format(fraction),
47
55
  formatDay: (atMs) => day.format(atMs),
@@ -49,6 +57,65 @@ function useDashboardFormats() {
49
57
  formatDateTime: (atMs) => dateTime.format(atMs),
50
58
  };
51
59
  }
60
+ const MAX_TEXT_PARAM_DEPTH = 4;
61
+ function isRecord(value) {
62
+ return typeof value === "object" && value !== null && !Array.isArray(value);
63
+ }
64
+ function isDashboardI18nText(value) {
65
+ return isRecord(value) && typeof value["i18nKey"] === "string";
66
+ }
67
+ function isMoneyParam(value) {
68
+ return (isRecord(value) &&
69
+ value["kind"] === "money" &&
70
+ typeof value["amountMinor"] === "number" &&
71
+ typeof value["currency"] === "string");
72
+ }
73
+ function isDateParam(value) {
74
+ return isRecord(value) && value["kind"] === "date" && typeof value["atMs"] === "number";
75
+ }
76
+ function isTextParam(value) {
77
+ return (typeof value === "string" ||
78
+ typeof value === "number" ||
79
+ isDashboardI18nText(value) ||
80
+ isMoneyParam(value) ||
81
+ isDateParam(value));
82
+ }
83
+ function resolveTextParam(param, translate, formats, depth) {
84
+ if (typeof param === "string" || typeof param === "number")
85
+ return param;
86
+ if (isMoneyParam(param))
87
+ return formats.formatMinor(param.amountMinor, param.currency);
88
+ if (isDateParam(param))
89
+ return formats.formatMedium(param.atMs);
90
+ if (isDashboardI18nText(param)) {
91
+ // Too deep: show the key instead of recursing further.
92
+ return depth >= MAX_TEXT_PARAM_DEPTH
93
+ ? param.i18nKey
94
+ : resolveI18nText(param, translate, formats, depth + 1);
95
+ }
96
+ return "";
97
+ }
98
+ function resolveI18nText(text, translate, formats, depth) {
99
+ if (text.i18nParams === undefined)
100
+ return translate(text.i18nKey);
101
+ const params = {};
102
+ for (const [name, param] of Object.entries(text.i18nParams)) {
103
+ if (isTextParam(param))
104
+ params[name] = resolveTextParam(param, translate, formats, depth);
105
+ }
106
+ return translate(text.i18nKey, params);
107
+ }
108
+ /** Plain strings pass through; DashboardI18nText is translated (nested params first).
109
+ * Anything else yields undefined so callers fall back to their own placeholder. */
110
+ function resolveDashboardText(value, translate, formats) {
111
+ if (typeof value === "string")
112
+ return value;
113
+ if (typeof value === "number")
114
+ return String(value);
115
+ if (isDashboardI18nText(value))
116
+ return resolveI18nText(value, translate, formats, 1);
117
+ return undefined;
118
+ }
52
119
  function PanelShell({ testId, title, subtitle, children, }) {
53
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 })] }));
54
121
  }
@@ -100,8 +167,13 @@ function readSparkline(panel, record) {
100
167
  function readTone(raw) {
101
168
  return typeof raw === "string" && STAT_TONES.has(raw) ? raw : undefined;
102
169
  }
103
- function StatPanelBody({ panel, label, screenId, screenParams, variant, }) {
104
- const { formatNumber } = useDashboardFormats();
170
+ function fallbackText(raw, placeholder) {
171
+ if (raw === undefined || raw === null || isRecord(raw))
172
+ return placeholder;
173
+ return String(raw);
174
+ }
175
+ function StatPanelBody({ panel, label, screenId, screenParams, translate, variant, }) {
176
+ const formats = useDashboardFormats();
105
177
  // Resolved HERE (not in a separate always-rendered child) so `icon` on
106
178
  // <StatCard> is `undefined` — not a React element that renders empty —
107
179
  // when the icon name isn't registered. StatCard gates its accent chip on
@@ -129,10 +201,12 @@ function StatPanelBody({ panel, label, screenId, screenParams, variant, }) {
129
201
  (rawValue === 0 ? undefined : panel.tone);
130
202
  const sub = panel.subField !== undefined ? record[panel.subField] : undefined;
131
203
  const delta = readDelta(panel, record);
132
- const value = typeof rawValue === "number" ? formatNumber(rawValue) : String(rawValue ?? "—");
204
+ const value = typeof rawValue === "number"
205
+ ? formats.formatPanelValue(panel.valueFormat)(rawValue)
206
+ : (resolveDashboardText(rawValue, translate, formats) ?? fallbackText(rawValue, "—"));
133
207
  const spark = readSparkline(panel, record);
134
208
  const testId = `dashboard-panel-${panel.id}`;
135
- const subText = sub !== undefined && sub !== null ? String(sub) : undefined;
209
+ const subText = resolveDashboardText(sub, translate, formats) ?? fallbackText(sub, undefined);
136
210
  if (variant === "strip") {
137
211
  return (_jsx(StatStripCell, { label: label, value: value, tone: tone ?? "default", ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
138
212
  }
@@ -153,9 +227,15 @@ function readDelta(panel, record) {
153
227
  function StatGroupPanelBody({ panel, label, screenId, screenParams, translate, }) {
154
228
  const testId = `dashboard-panel-${panel.id}`;
155
229
  if (label === undefined) {
156
- 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, variant: "strip" }, stat.id))) }));
230
+ 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))) }));
157
231
  }
158
- 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, variant: "card" }, stat.id))) }) }));
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
+ }
234
+ function resolveMarkers(markers, translate, formats) {
235
+ return markers?.map((marker) => ({
236
+ atMs: marker.atMs,
237
+ label: resolveDashboardText(marker.label, translate, formats) ?? "",
238
+ }));
159
239
  }
160
240
  // A plain bar chart ("changes per day") ships `points` without `series`.
161
241
  const SINGLE_SERIES_KEY = "value";
@@ -196,12 +276,14 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
196
276
  return (_jsx(QueryPanel, { panel: panel, label: label, subtitle: panel.subtitle !== undefined ? translate(panel.subtitle) : undefined, screenParams: screenParams, translate: translate, skeleton: panel.chart === "segment-bars" ? "segments" : "bars", isEmpty: (data) => isChartEmpty(panel.chart, data, chartSeries(data, label, translate)), children: (data) => {
197
277
  const series = chartSeries(data, label, translate);
198
278
  const { startMs, endMs } = windowOf(data, series);
279
+ const markers = resolveMarkers(data.markers, translate, formats);
280
+ const formatValue = formats.formatPanelValue(panel.valueFormat);
199
281
  if (panel.chart === "segment-bars") {
200
282
  return (_jsx(SegmentBarChart, { rows: (data.rows ?? []).map((row) => ({
201
283
  ...row,
202
284
  label: translate(row.label),
203
285
  segments: row.segments.map((seg) => ({ ...seg, label: translate(seg.label) })),
204
- })), tones: tones, ariaLabel: label, formatValue: formats.formatNumber, testId: testId }));
286
+ })), tones: tones, ariaLabel: label, formatValue: formatValue, testId: testId }));
205
287
  }
206
288
  if (panel.chart === "stacked-bars") {
207
289
  const times = series.flatMap((s) => s.points.map((p) => p.atMs));
@@ -211,13 +293,15 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
211
293
  const prev = all[i - 1];
212
294
  return prev === undefined ? [] : [atMs - prev];
213
295
  }), DAY_MS);
214
- return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue: formats.formatNumber, testId: testId }));
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 }));
215
297
  }
216
298
  if (panel.chart === "stacked-area") {
217
299
  return (_jsx(StackedAreaChart, { series: series, windowStartMs: startMs, windowEndMs: endMs, ...(data.todayMs !== undefined &&
218
- data.todayMs !== null && { todayMs: data.todayMs }), tones: tones, markers: data.markers, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: formats.formatDay, formatValue: formats.formatNumber, formatMarkerTime: formats.formatDay, testId: testId }));
300
+ data.todayMs !== null && { todayMs: data.todayMs }), tones: tones, markers: markers, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: formats.formatDay, formatValue: formatValue, formatMarkerTime: formats.formatDay, scrollable: panel.scrollable === true, testId: testId }));
219
301
  }
220
- return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: data.markers, formatMarkerTime: formats.formatDay, ariaLabel: label, testId: testId }));
302
+ return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: markers, formatMarkerTime: formats.formatDay, ...(panel.valueFormat !== undefined && {
303
+ yAxis: { ticks: 3, format: formatValue },
304
+ }), ariaLabel: label, testId: testId }));
221
305
  } }));
222
306
  }
223
307
  function ListPanelBody({ panel, label, screenParams, translate, }) {
@@ -236,19 +320,27 @@ function ListPanelBody({ panel, label, screenParams, translate, }) {
236
320
  return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => (_jsx(DashboardListTable, { columns: columns, rows: data.rows ?? [], formatPercent: formatPercent, formatNumber: formatNumber, formatDateTime: formatDateTime, translate: translate })) }));
237
321
  }
238
322
  function FeedPanelBody({ panel, label, screenParams, translate, }) {
323
+ const formats = useDashboardFormats();
239
324
  return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
240
- const rows = (data.rows ?? []).map((row, i) => ({
241
- id: String(i),
242
- ...row,
243
- }));
325
+ const rows = (data.rows ?? []).map((row, i) => {
326
+ const trailing = resolveDashboardText(row.trailing, translate, formats);
327
+ return {
328
+ id: String(i),
329
+ primary: resolveDashboardText(row.primary, translate, formats) ?? "—",
330
+ ...(trailing !== undefined && { trailing }),
331
+ };
332
+ });
244
333
  return _jsx(FeedList, { rows: rows });
245
334
  } }));
246
335
  }
247
336
  function ProgressListPanelBody({ panel, label, screenParams, translate, }) {
337
+ const formats = useDashboardFormats();
248
338
  return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
249
339
  const rows = (data.rows ?? []).map((row, i) => ({
250
340
  id: String(i),
251
- ...row,
341
+ label: resolveDashboardText(row.label, translate, formats) ?? "—",
342
+ value: resolveDashboardText(row.value, translate, formats) ?? "—",
343
+ fraction: row.fraction,
252
344
  }));
253
345
  return _jsx(ProgressList, { rows: rows });
254
346
  } }));
@@ -333,7 +425,7 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
333
425
  }
334
426
  const text = label ?? "";
335
427
  if (panel.kind === "stat") {
336
- return (_jsx(StatPanelBody, { panel: panel, label: text, screenId: screenId, screenParams: screenParams, variant: "card" }));
428
+ return (_jsx(StatPanelBody, { panel: panel, label: text, screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }));
337
429
  }
338
430
  if (panel.kind === "chart") {
339
431
  return (_jsx(ChartPanelBody, { panel: panel, label: text, screenParams: screenParams, translate: translate }));
@@ -14,4 +14,4 @@ export type MoneyInputProps = {
14
14
  readonly hasError?: boolean;
15
15
  };
16
16
  export declare function MoneyInput({ id, name, value, onChange, currency, locale, disabled, required, hasError, }: MoneyInputProps): ReactNode;
17
- export declare function formatMoney(amountMinor: number, currency: string, locale?: string): string;
17
+ export declare function formatMoney(amountMinor: number, currency: string, locale?: string, fractionDigits?: number): string;
@@ -88,16 +88,17 @@ export function MoneyInput({ id, name, value, onChange, currency, locale, disabl
88
88
  // rehydrateMoney) throws a RangeError inside Intl.NumberFormat, and with
89
89
  // no ErrorBoundary in this render tree that would take down the whole
90
90
  // page instead of just this cell.
91
- export function formatMoney(amountMinor, currency, locale) {
91
+ export function formatMoney(amountMinor, currency, locale, fractionDigits) {
92
92
  if (!/^[A-Za-z]{3}$/.test(currency))
93
93
  return String(amountMinor);
94
94
  const decimals = currencyDecimals(currency);
95
+ const displayDigits = fractionDigits ?? decimals;
95
96
  const resolvedLocale = resolveSafeLocale(locale);
96
97
  return new Intl.NumberFormat(resolvedLocale, {
97
98
  style: "currency",
98
99
  currency,
99
- minimumFractionDigits: decimals,
100
- maximumFractionDigits: decimals,
100
+ minimumFractionDigits: displayDigits,
101
+ maximumFractionDigits: displayDigits,
101
102
  }).format(amountMinor / 10 ** decimals);
102
103
  }
103
104
  // The currency symbol is rendered as a separate adornment, so the field
@@ -133,7 +133,7 @@ export declare function SegmentBarChart({ rows, tones, ariaLabel, formatValue, t
133
133
  }): ReactNode;
134
134
  /** Stacked bands over time. Right of `todayMs` is the forecast: lighter
135
135
  * fill plus a vertical "today" line. */
136
- export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, emptyContent, testId, }: {
136
+ export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, scrollable, emptyContent, testId, }: {
137
137
  readonly series: readonly ChartSeries[];
138
138
  readonly windowStartMs: number;
139
139
  readonly windowEndMs: number;
@@ -145,6 +145,8 @@ export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, t
145
145
  readonly formatBucketLabel: (atMs: number) => string;
146
146
  readonly formatValue?: (value: number) => string;
147
147
  readonly formatMarkerTime?: (atMs: number) => string;
148
+ /** Fixed width per bucket; the plot scrolls horizontally, y ticks stay put. */
149
+ readonly scrollable?: boolean;
148
150
  readonly emptyContent?: ReactNode;
149
151
  readonly testId?: string;
150
152
  }): ReactNode;
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId } from "react";
2
+ import { useEffect, useId, useRef } from "react";
3
3
  import { cn } from "../lib/cn.js";
4
4
  import { STATUS_TONE_TEXT } from "./status-badge.js";
5
5
  // Inline-SVG-Charts — kein Chart-Dep. Farben ausschließlich über die
@@ -241,17 +241,35 @@ function MarkerPins({ markers, windowStartMs, windowEndMs, }) {
241
241
  function MarkerLegend({ markers, formatMarkerTime, }) {
242
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) }))] }, `${marker.atMs}-${marker.label}`))) }));
243
243
  }
244
+ const SCROLLABLE_BUCKET_WIDTH_PX = 48;
245
+ function bucketLabelTransform(index, count) {
246
+ if (index === 0)
247
+ return "";
248
+ return index === count - 1 ? "-translate-x-full" : "-translate-x-1/2";
249
+ }
244
250
  /** Stacked bands over time. Right of `todayMs` is the forecast: lighter
245
251
  * fill plus a vertical "today" line. */
246
- export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, emptyContent, testId, }) {
252
+ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, scrollable = false, emptyContent, testId, }) {
247
253
  const clipId = useId();
254
+ const scrollRef = useRef(null);
248
255
  const times = bucketTimes(series);
256
+ const bucketCount = times.length;
257
+ const span = Math.max(1, windowEndMs - windowStartMs);
258
+ const fractionOf = (atMs) => Math.max(0, Math.min(1, (atMs - windowStartMs) / span));
259
+ const todayFraction = todayMs !== undefined ? fractionOf(todayMs) : undefined;
260
+ useEffect(() => {
261
+ const el = scrollRef.current;
262
+ if (!scrollable || el === null || bucketCount === 0)
263
+ return;
264
+ el.scrollLeft =
265
+ todayFraction === undefined
266
+ ? el.scrollWidth
267
+ : Math.max(0, todayFraction * el.scrollWidth - el.clientWidth / 2);
268
+ }, [scrollable, todayFraction, bucketCount]);
249
269
  if (times.length === 0)
250
270
  return _jsx("div", { "data-testid": testId, children: emptyContent });
251
271
  const width = 300;
252
272
  const height = 120;
253
- const span = Math.max(1, windowEndMs - windowStartMs);
254
- const fractionOf = (atMs) => Math.max(0, Math.min(1, (atMs - windowStartMs) / span));
255
273
  const totals = times.map((atMs) => series.reduce((sum, s) => sum + valueAt(s, atMs), 0));
256
274
  const isSingleBucket = times.length === 1;
257
275
  // Doubling keeps a lone bucket away from the top edge of the y-scale.
@@ -275,10 +293,13 @@ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs,
275
293
  dot: isSingleBucket && bucketHeight > 0 ? clampDotY(yOf(bucketTop), height) : undefined,
276
294
  };
277
295
  });
278
- const todayFraction = todayMs !== undefined ? fractionOf(todayMs) : undefined;
279
296
  const todayX = (todayFraction ?? 1) * width;
280
297
  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)));
281
- 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: "h-32 pb-0" }), _jsxs("div", { className: "relative grow", 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) })), 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" }))] }), _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 }))] })] }), _jsx(ChartLegend, { formatValue: formatValue, items: series.map((s, i) => ({
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) })), 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
+ 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
+ minWidth: "100%",
301
+ width: `${times.length * SCROLLABLE_BUCKET_WIDTH_PX}px`,
302
+ }, children: plot }) })) : (_jsx("div", { className: "relative grow", children: plot }))] }), _jsx(ChartLegend, { formatValue: formatValue, items: series.map((s, i) => ({
282
303
  key: s.key,
283
304
  label: s.label,
284
305
  color: chartColor(s.key, i, tones),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.348.1",
3
+ "version": "0.349.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.348.1",
24
- "@cosmicdrift/kumiko-framework": "0.348.1",
25
- "@cosmicdrift/kumiko-headless": "0.348.1",
26
- "@cosmicdrift/kumiko-renderer": "0.348.1",
27
- "@cosmicdrift/kumiko-types": "0.348.1",
23
+ "@cosmicdrift/kumiko-dispatcher-live": "0.349.0",
24
+ "@cosmicdrift/kumiko-framework": "0.349.0",
25
+ "@cosmicdrift/kumiko-headless": "0.349.0",
26
+ "@cosmicdrift/kumiko-renderer": "0.349.0",
27
+ "@cosmicdrift/kumiko-types": "0.349.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.348.1"
75
+ "@cosmicdrift/kumiko-locale-de": "0.349.0"
76
76
  },
77
77
  "repository": {
78
78
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.349.0",
4
+ "type": "improvement",
5
+ "title": "Dashboard panel texts are translatable (i18nKey/i18nParams with nested, money and date params), panels get a currency valueFormat and stacked-area charts can scroll",
6
+ "detail": "Dashboard panels: translatable texts, currency format, scrollable stacked-area\nQuery handlers can send `{ i18nKey, i18nParams? }` (`DashboardI18nText`) wherever a dashboard panel shows text: stat value and sub line, feed `primary`/`trailing`, progress-list `label`/`value`, chart marker labels. Plain strings are shown unchanged. Params may be strings, numbers, nested `DashboardI18nText` (resolved first, e.g. a duration built from two plural keys), `{ kind: \"money\", amountMinor, currency }` (user locale) or `{ kind: \"date\", atMs }` (medium date, user time zone).\n`valueFormat: { kind: \"currency\", currency, fractionDigits? }` on stat and chart panels formats minor-unit values as currency in stat values, y ticks, legend totals and tooltips. `scrollable: true` on a `stacked-area` chart gives each bucket a fixed width, scrolls the plot horizontally with the y ticks fixed on the left, and opens at \"today\". The boot validator rejects an invalid currency code, `fractionDigits` outside 0..4 and `scrollable` on other chart kinds. `formatMoney` takes an optional `fractionDigits`."
7
+ },
2
8
  {
3
9
  "version": "0.342.0",
4
10
  "type": "improvement",