@micha.bigler/ui-core-micha 2.28.2 → 2.29.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/DESIGN.md +2 -1
- package/WORK_ORDERS.md +1 -0
- package/dist/components/charts/TimeSeriesChart.js +58 -6
- package/package.json +1 -1
- package/src/components/charts/TimeSeriesChart.jsx +78 -6
- package/tests/BarChart.test.jsx +1 -1
- package/tests/TimeSeriesChart.test.jsx +125 -0
- package/work-orders/CHART-5.md +226 -0
package/DESIGN.md
CHANGED
|
@@ -19,7 +19,8 @@ These principles are the spec a **prototype** encodes and the implementation mat
|
|
|
19
19
|
|
|
20
20
|
## Charts (read by humans, executed by code)
|
|
21
21
|
|
|
22
|
-
8. **Every chart earns:** axis labels (with units), a legend only when >1 series, a tooltip, theme-token colours, container-responsive sizing, and empty/loading states. **Single-hue for magnitude; diverging (blue/red around zero) ONLY for signed data
|
|
22
|
+
8. **Every chart earns:** axis labels (with units), a legend only when >1 series, a tooltip, theme-token colours, container-responsive sizing, and empty/loading states. **Single-hue for magnitude; diverging (blue/red around zero) ONLY for signed data.** The concrete data palette is per-app / the `ui-core-micha` chart kit — this states the rules, not a fixed palette.
|
|
23
|
+
**Dual-axis is a default-off exception, opt-in only** (CHART-5, operator decision): the shared `TimeSeriesChart` preset supports a second y-axis via a per-series `axis: 'secondary'` field specifically for two series sharing one axis at very different scales/units (e.g. a user count vs. hours) where series-toggles alone leave one series unreadable. Reach for it only under that same condition, not as a general layout choice — a single shared axis (or toggles) stays the default everywhere else.
|
|
23
24
|
9. **Ground it in the real subject matter.** Faithful structures from the real panels (hram: Morris-tornado, Pareto/knee, CI-strip, allocation-scatter with shape-encoded sources) — no lorem, no generic charts. Hence: **scout the real subject before building.**
|
|
24
25
|
|
|
25
26
|
## Craft
|
package/WORK_ORDERS.md
CHANGED
|
@@ -23,6 +23,7 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
23
23
|
|
|
24
24
|
| ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
|
|
25
25
|
|---|---|---|---|---|---|---|
|
|
26
|
+
| CHART-5 | Dual y-axis + always-visible x-axis ticks on `TimeSeriesChart` | Opt-in second y-axis via a new per-series `axis: 'secondary'` field (backward-compatible, off by default) with auto-detected integer-only tick labels per axis group; deterministic x-axis `tickLabelInterval` so bucket labels never all vanish under MUI's default collision-avoidance (the live symptom: zero x-axis labels with 24 hourly buckets). Reverses CHART-2's deliberate "no second y-axis" choice on fresh operator instruction; `DESIGN.md` #8 updated to record the scoped exception. Full spec: [work-orders/CHART-5.md](work-orders/CHART-5.md). | 2026-08-07 | done | 839e6c5, 84c37ac | Operator-requested after seeing the shipped chart live: y-axis showed decimal ticks (0.2, 0.4, ...) for an integer user-count series sharing an axis with fractional hours; x-axis showed no time labels at all. **Codex out of credits, known up front** — implemented directly (author-flip, independent review mandatory). Independent `reviewer` + `ui_reviewer` ran concurrently: **`reviewer` found R1 (P1, real bug)** — used `yAxisKey` (removed in MUI X-Charts v7, per the installed v8.29.2's own CHANGELOG and `useBarPlotData.mjs` source, which reads `series[i].yAxisId`) instead of `yAxisId`; the WO explicitly asked to verify this passthrough against real MUI behaviour rather than assume, and the first pass didn't — the dual-axis feature would have silently plotted every series on the primary axis in production despite all mocked tests passing (a mocked-prop test structurally cannot catch a wrong-field-name bug, since it only checks whatever the code itself sets). Fixed (`yAxisKey` → `yAxisId`, verified against `node_modules` source directly, not just the mock) and the pre-existing `tests/BarChart.test.jsx` "dual axis" case corrected too (R2 — it used the same wrong field name and was cited by the WO as precedent, giving false confidence). **`ui_reviewer` found U1 (P1, real bug)** — `tickLabelInterval`'s `index % step === 0` systematically dropped the LAST x-axis bucket unless it landed exactly on a step boundary (e.g. 24 labels/step 3 → indices 0..21, never 23), exactly the failure mode the WO's own SCOPE C explicitly warned to check for; fixed by force-including `index === lastIndex`, with a new `it.each([24,12,7,1])` regression test, confirmed non-vacuous by reverting the fix. **U2 (P2)**: `DESIGN.md` #8's "Never dual-axis" now contradicted the shipped code — updated to record dual-axis as a scoped, opt-in exception (CHART-5) rather than a general rule change. U3 (P3, fixed `MAX_X_TICKS` under-displays low-density ranges e.g. 12 monthly buckets showing only 6) and U4 (P3, no UI indication of which axis a toggle's series plots against) both accepted, not fixed — informational, no code change. Re-verified after fixes: 31/31 scoped vitest tests (`TimeSeriesChart.test.jsx` + `BarChart.test.jsx`) green, `tsc` build clean. Published: ucm 2.29.0 (minor — new optional per-series field + prop, additive, no breaking change). No app pin bumps in this WO. |
|
|
26
27
|
| CHART-4 | Fix `TimeSeriesChart` chart canvas rendering at zero height | Neither `ChartFrame` nor `BarChart` ever receive a height from `TimeSeriesChart` — MUI X-Charts' responsive container measures a zero-height parent and draws no bars/axes, while the legend (sized by its own content) renders regardless, which is why the live symptom was "legend shows, chart canvas doesn't". Fix supplies a preset default height (`CHART_HEIGHT = 320`, `minHeight` on `ChartFrame` + `height` forwarded to the real `MuiBarChart`), matching the working `dev/entries.jsx` reference pattern. Full spec: [work-orders/CHART-4.md](work-orders/CHART-4.md). | 2026-08-07 | done | f0953a1, fcb289b | Second, independent bug found live in jg-ferien immediately after CHART-3 + the ucm pin bump landed — CHART-3's own fix is what let this one become visible for the first time (it was previously masked by the permanently-empty-chart bug). Root cause of it shipping twice undetected: every test in `tests/TimeSeriesChart.test.jsx`/`tests/BarChart.test.jsx` mocks the real `@mui/x-charts/BarChart`, so no test has ever mounted the real chart and observed its size. **Codex out of credits, known up front** — implemented directly (author-flip, independent review mandatory). **Test-writing pitfall found and self-corrected during implementation**: first wrote an *unmocked* render test asserting the real chart SVG's `viewBox` height was non-zero — verified vacuous (stays green even fully reverting the fix, since jsdom's `ResizeObserver`/layout handling doesn't reproduce the real zero-height-parent browser behavior this bug depends on). Deleted; replaced with a `chartSpy`-mocked prop assertion (`props.height` non-zero) matching the file's existing convention, confirmed non-vacuous (fails pre-fix with `props.height` undefined). Independent `reviewer` + `ui_reviewer` ran concurrently: `reviewer` verified the fix mechanism end-to-end (no prop-name collision between `BarChart`'s own `minHeight`/`aspect` and the forwarded `height`) and the test's non-vacuity claim, no findings. `ui_reviewer`: two P3 informational notes, neither blocking — (U1) the fixed 320px height is a deliberate, documented exception to `DESIGN.md` #14 ("responsive = container-sized, not fixed px"), needed specifically because MUI X-Charts' own `ResizeObserver`-based height measurement is what caused CHART-4 in the first place; a code comment was added warning future readers not to "fix" this back to a responsive height without re-verifying the underlying MUI bug is resolved. (U2) `CHART_HEIGHT` uses 320 for both `ChartFrame.minHeight` and `BarChart.height`, vs. `dev/entries.jsx`'s 360/320 split — functionally harmless (WO explicitly permits one fixed value), no change made. 13/13 scoped vitest tests green, `tsc` build clean. Published: ucm 2.28.2 (patch, no interface change). No app pin bumps in this WO — jg-ferien picks it up on its next ucm bump. |
|
|
27
28
|
| CHART-3 | Fix `TimeSeriesChart` rendering nothing on async data load | `visibleKeys` was a lazy `useState` initialized once from `data` at first mount; any host that fetches asynchronously after mount (the normal case, e.g. jg-ferien's `ActivitySection`) ends up with a permanently-empty `visibleKeys`, so `isDataEmpty` stays `true` forever and the chart body never renders even once real data arrives. Fix syncs newly-seen series keys to visible-by-default while preserving a user's manual toggle-off across data updates. Full spec: [work-orders/CHART-3.md](work-orders/CHART-3.md). | 2026-08-06 | done | 9a1bd9d, 48cefa8 | Found live in jg-ferien's Aktivität screen (ACT-3 consumer) — toolbar rendered, chart body stayed on `ChartFrame`'s empty state permanently. **Codex was out of workspace credits** (genuine quota error on invocation, no partial work) — orchestrator implemented directly per the Codex→Claude fallback rule; independence flip applies (author = orchestrator, so independent review was mandatory, not self-check). Independent `reviewer` + `ui_reviewer` both ran (background, concurrent): `ui_reviewer` found no findings; `reviewer` found R1 (P2, real bug — the seen-keys tracker was a `useRef` mutated during the render body alongside a paired `setVisibleKeys` call; under React concurrent rendering an interrupted/discarded render leaves the ref mutation applied while the paired state update is discarded, permanently hiding a key without it ever reaching `visibleKeys` — a different-shaped recurrence of the exact bug this WO exists to fix) and R2 (P3, test-file duplication — a second near-duplicate render helper). Both fixed: seen-keys tracking moved from a ref to a second `useState` (both states updated together in the render body, so a discarded render rolls both back together — React's documented pattern for this), and the duplicate test helper collapsed into the existing `renderChart`'s `rerenderWith`. Re-verified: 12/12 scoped vitest tests green (3 new CHART-3 regression tests, 2 of which confirmed non-vacuous by failing against the pre-fix code), `tsc` build clean. Published: ucm 2.28.1 (patch, no interface change). No app pin bumps in this WO — jg-ferien picks it up on its next ucm bump. |
|
|
28
29
|
| ONB-1 | Per-app configurable notifications onboarding step | New `browserPush` prop `{nagUntil, showOnce}` on `OnboardingProvider`; parameterizes the `browser_push` descriptor's condition (default changes from implicit "all-channels" to "any-channel", stopping the over-nag); `showOnce` via a persisted `onboarding_seen` set (frozen-at-mount ref, no mid-session flicker) | 2026-07-17 | done | 1ae7c20 | Default behavior change affects cockpit and all consumers on their next ucm bump. jg-ferien companion WO pins the new version with `browserPush={{nagUntil: 'any-channel'}}` explicit (matches new default, but pinned explicitly per the WO). |
|
|
@@ -24,6 +24,42 @@ import { useNeutralChartPalette } from './palette';
|
|
|
24
24
|
// to a responsive/aspect-ratio height without re-verifying the underlying
|
|
25
25
|
// MUI measurement bug is actually resolved.
|
|
26
26
|
const CHART_HEIGHT = 320;
|
|
27
|
+
// CHART-5: how many x-axis tick labels to show, evenly spread across
|
|
28
|
+
// data.xLabels, regardless of container width or label length. Replaces
|
|
29
|
+
// MUI's own collision-based tick filtering (which, given enough densely
|
|
30
|
+
// packed categories, can end up hiding every single label — confirmed live
|
|
31
|
+
// with 24 hourly buckets) with a deterministic guarantee that some labels
|
|
32
|
+
// always render. See makeTickLabelInterval below.
|
|
33
|
+
const MAX_X_TICKS = 8;
|
|
34
|
+
function makeTickLabelInterval(labelCount) {
|
|
35
|
+
const step = Math.max(1, Math.ceil(labelCount / MAX_X_TICKS));
|
|
36
|
+
const lastIndex = labelCount - 1;
|
|
37
|
+
// `index % step === 0` alone systematically drops the final bucket unless
|
|
38
|
+
// it happens to land exactly on a step boundary (e.g. 24 labels / step 3
|
|
39
|
+
// covers indices 0..21, never 23) -- the most recent time bucket is
|
|
40
|
+
// usually the one that matters most, so it is always force-included.
|
|
41
|
+
return (_value, index) => index % step === 0 || index === lastIndex;
|
|
42
|
+
}
|
|
43
|
+
// CHART-5: a y-axis is "integer" when every value across its currently
|
|
44
|
+
// visible series is a whole number — re-evaluated on every toggle (an axis
|
|
45
|
+
// can flip from fractional to integer, or vice versa, as series are shown/
|
|
46
|
+
// hidden). Empty (no visible series on this axis) is deliberately NOT
|
|
47
|
+
// integer: there's nothing to format, and treating an empty list as
|
|
48
|
+
// vacuously "all integer" would apply the formatter to an axis with no data.
|
|
49
|
+
function isIntegerAxis(seriesList) {
|
|
50
|
+
return seriesList.length > 0 && seriesList.every((series) => (series.data || []).every((value) => Number.isInteger(value)));
|
|
51
|
+
}
|
|
52
|
+
// Blanks the tick label for a non-integer value on an axis whose visible
|
|
53
|
+
// series are otherwise all whole numbers -- a deterministic backstop since
|
|
54
|
+
// d3's own "nice tick" choice for a small integer domain can still land on
|
|
55
|
+
// a fractional position (e.g. domain [0,3] ticking at 0, 1.5, 3). Tooltip/
|
|
56
|
+
// legend formatting (a different `location`) is left untouched -- only the
|
|
57
|
+
// on-axis tick text is ever blanked.
|
|
58
|
+
function integerTickFormatter(value, context) {
|
|
59
|
+
if ((context === null || context === void 0 ? void 0 : context.location) !== 'tick')
|
|
60
|
+
return String(value);
|
|
61
|
+
return Number.isInteger(value) ? String(value) : '';
|
|
62
|
+
}
|
|
27
63
|
const RANGE_OPTIONS = [
|
|
28
64
|
{ key: '1d', granularity: 'hour', labelKey: 'TimeSeriesChart.RANGE_1_DAY' },
|
|
29
65
|
{ key: '1w', granularity: '4hour', labelKey: 'TimeSeriesChart.RANGE_1_WEEK' },
|
|
@@ -39,12 +75,13 @@ const RANGE_OPTIONS = [
|
|
|
39
75
|
* owns fetching; this component owns which range is selected and which
|
|
40
76
|
* series are visible, telling the host what changed via `onRangeChange`.
|
|
41
77
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
78
|
+
* Second y-axis is opt-in per series (CHART-5, reversing CHART-2's original
|
|
79
|
+
* "toggles only" choice on operator instruction): a series with
|
|
80
|
+
* `axis: 'secondary'` plots against a second, independently-scaled y-axis
|
|
81
|
+
* (requires `secondaryYAxisLabel`). Omit `axis` (or set it to `'primary'`)
|
|
82
|
+
* on every series for today's single-shared-axis behaviour, unchanged.
|
|
46
83
|
*/
|
|
47
|
-
export function TimeSeriesChart({ title, subtitle, xAxisLabel, yAxisLabel, data, loading = false, error = false, onRangeChange, defaultRange = '1w', }) {
|
|
84
|
+
export function TimeSeriesChart({ title, subtitle, xAxisLabel, yAxisLabel, secondaryYAxisLabel, data, loading = false, error = false, onRangeChange, defaultRange = '1w', }) {
|
|
48
85
|
const { t } = useTranslation();
|
|
49
86
|
const palette = useNeutralChartPalette().categorical;
|
|
50
87
|
const seriesConfig = (data === null || data === void 0 ? void 0 : data.series) || [];
|
|
@@ -98,10 +135,25 @@ export function TimeSeriesChart({ title, subtitle, xAxisLabel, yAxisLabel, data,
|
|
|
98
135
|
// series' color, breaking the swatch-to-series identity the toggles rely on.
|
|
99
136
|
const visiblePalette = visibleSeries.map((series) => palette[seriesConfig.indexOf(series) % palette.length]);
|
|
100
137
|
const isDataEmpty = seriesConfig.length === 0 || visibleSeries.length === 0;
|
|
138
|
+
const hasSecondaryAxis = seriesConfig.some((series) => series.axis === 'secondary');
|
|
139
|
+
if (hasSecondaryAxis && !secondaryYAxisLabel) {
|
|
140
|
+
throw new Error('TimeSeriesChart requires secondaryYAxisLabel when a series uses axis: "secondary".');
|
|
141
|
+
}
|
|
142
|
+
const primaryVisibleSeries = visibleSeries.filter((series) => series.axis !== 'secondary');
|
|
143
|
+
const secondaryVisibleSeries = visibleSeries.filter((series) => series.axis === 'secondary');
|
|
144
|
+
const yAxis = hasSecondaryAxis
|
|
145
|
+
? [
|
|
146
|
+
Object.assign({ id: 'primary', label: yAxisLabel }, (isIntegerAxis(primaryVisibleSeries) ? { valueFormatter: integerTickFormatter } : {})),
|
|
147
|
+
Object.assign({ id: 'secondary', label: secondaryYAxisLabel }, (isIntegerAxis(secondaryVisibleSeries) ? { valueFormatter: integerTickFormatter } : {})),
|
|
148
|
+
]
|
|
149
|
+
: (isIntegerAxis(primaryVisibleSeries) ? [{ valueFormatter: integerTickFormatter }] : undefined);
|
|
101
150
|
const toolbar = (_jsxs(Box, { sx: { display: 'flex', flexWrap: 'wrap', gap: 3, alignItems: 'center' }, children: [_jsx(RadioGroup, { row: true, value: range, onChange: handleRangeChange, "aria-label": t('TimeSeriesChart.RANGE_LABEL'), children: RANGE_OPTIONS.map((option) => (_jsx(FormControlLabel, { value: option.key, control: _jsx(Radio, { size: "small" }), label: t(option.labelKey) }, option.key))) }), seriesConfig.length > 0 && (_jsx(FormGroup, { row: true, "aria-label": t('TimeSeriesChart.SERIES_LABEL'), children: seriesConfig.map((series, index) => (_jsx(FormControlLabel, { control: (_jsx(Checkbox, { size: "small", checked: visibleKeys.has(series.key), onChange: () => toggleSeries(series.key), sx: {
|
|
102
151
|
color: palette[index % palette.length],
|
|
103
152
|
'&.Mui-checked': { color: palette[index % palette.length] },
|
|
104
153
|
} })), label: series.label }, series.key))) }))] }));
|
|
105
|
-
return (_jsx(ChartFrame, { title: title, subtitle: subtitle, toolbar: toolbar, loading: loading, error: error, isEmpty: isDataEmpty, minHeight: CHART_HEIGHT, children: !isDataEmpty && (_jsx(BarChart, { xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xAxis: [{
|
|
154
|
+
return (_jsx(ChartFrame, { title: title, subtitle: subtitle, toolbar: toolbar, loading: loading, error: error, isEmpty: isDataEmpty, minHeight: CHART_HEIGHT, children: !isDataEmpty && (_jsx(BarChart, { xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xAxis: [{
|
|
155
|
+
data: (data === null || data === void 0 ? void 0 : data.xLabels) || [],
|
|
156
|
+
tickLabelInterval: makeTickLabelInterval(((data === null || data === void 0 ? void 0 : data.xLabels) || []).length),
|
|
157
|
+
}], yAxis: yAxis, series: visibleSeries.map((series) => (Object.assign({ data: series.data, label: series.label }, (hasSecondaryAxis && series.axis === 'secondary' ? { yAxisId: 'secondary' } : {})))), palette: visiblePalette, height: CHART_HEIGHT })) }));
|
|
106
158
|
}
|
|
107
159
|
export default TimeSeriesChart;
|
package/package.json
CHANGED
|
@@ -32,6 +32,47 @@ import { useNeutralChartPalette } from './palette';
|
|
|
32
32
|
// MUI measurement bug is actually resolved.
|
|
33
33
|
const CHART_HEIGHT = 320;
|
|
34
34
|
|
|
35
|
+
// CHART-5: how many x-axis tick labels to show, evenly spread across
|
|
36
|
+
// data.xLabels, regardless of container width or label length. Replaces
|
|
37
|
+
// MUI's own collision-based tick filtering (which, given enough densely
|
|
38
|
+
// packed categories, can end up hiding every single label — confirmed live
|
|
39
|
+
// with 24 hourly buckets) with a deterministic guarantee that some labels
|
|
40
|
+
// always render. See makeTickLabelInterval below.
|
|
41
|
+
const MAX_X_TICKS = 8;
|
|
42
|
+
|
|
43
|
+
function makeTickLabelInterval(labelCount) {
|
|
44
|
+
const step = Math.max(1, Math.ceil(labelCount / MAX_X_TICKS));
|
|
45
|
+
const lastIndex = labelCount - 1;
|
|
46
|
+
// `index % step === 0` alone systematically drops the final bucket unless
|
|
47
|
+
// it happens to land exactly on a step boundary (e.g. 24 labels / step 3
|
|
48
|
+
// covers indices 0..21, never 23) -- the most recent time bucket is
|
|
49
|
+
// usually the one that matters most, so it is always force-included.
|
|
50
|
+
return (_value, index) => index % step === 0 || index === lastIndex;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// CHART-5: a y-axis is "integer" when every value across its currently
|
|
54
|
+
// visible series is a whole number — re-evaluated on every toggle (an axis
|
|
55
|
+
// can flip from fractional to integer, or vice versa, as series are shown/
|
|
56
|
+
// hidden). Empty (no visible series on this axis) is deliberately NOT
|
|
57
|
+
// integer: there's nothing to format, and treating an empty list as
|
|
58
|
+
// vacuously "all integer" would apply the formatter to an axis with no data.
|
|
59
|
+
function isIntegerAxis(seriesList) {
|
|
60
|
+
return seriesList.length > 0 && seriesList.every(
|
|
61
|
+
(series) => (series.data || []).every((value) => Number.isInteger(value)),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Blanks the tick label for a non-integer value on an axis whose visible
|
|
66
|
+
// series are otherwise all whole numbers -- a deterministic backstop since
|
|
67
|
+
// d3's own "nice tick" choice for a small integer domain can still land on
|
|
68
|
+
// a fractional position (e.g. domain [0,3] ticking at 0, 1.5, 3). Tooltip/
|
|
69
|
+
// legend formatting (a different `location`) is left untouched -- only the
|
|
70
|
+
// on-axis tick text is ever blanked.
|
|
71
|
+
function integerTickFormatter(value, context) {
|
|
72
|
+
if (context?.location !== 'tick') return String(value);
|
|
73
|
+
return Number.isInteger(value) ? String(value) : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
35
76
|
const RANGE_OPTIONS = [
|
|
36
77
|
{ key: '1d', granularity: 'hour', labelKey: 'TimeSeriesChart.RANGE_1_DAY' },
|
|
37
78
|
{ key: '1w', granularity: '4hour', labelKey: 'TimeSeriesChart.RANGE_1_WEEK' },
|
|
@@ -48,16 +89,18 @@ const RANGE_OPTIONS = [
|
|
|
48
89
|
* owns fetching; this component owns which range is selected and which
|
|
49
90
|
* series are visible, telling the host what changed via `onRangeChange`.
|
|
50
91
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
92
|
+
* Second y-axis is opt-in per series (CHART-5, reversing CHART-2's original
|
|
93
|
+
* "toggles only" choice on operator instruction): a series with
|
|
94
|
+
* `axis: 'secondary'` plots against a second, independently-scaled y-axis
|
|
95
|
+
* (requires `secondaryYAxisLabel`). Omit `axis` (or set it to `'primary'`)
|
|
96
|
+
* on every series for today's single-shared-axis behaviour, unchanged.
|
|
55
97
|
*/
|
|
56
98
|
export function TimeSeriesChart({
|
|
57
99
|
title,
|
|
58
100
|
subtitle,
|
|
59
101
|
xAxisLabel,
|
|
60
102
|
yAxisLabel,
|
|
103
|
+
secondaryYAxisLabel,
|
|
61
104
|
data,
|
|
62
105
|
loading = false,
|
|
63
106
|
error = false,
|
|
@@ -123,6 +166,27 @@ export function TimeSeriesChart({
|
|
|
123
166
|
);
|
|
124
167
|
const isDataEmpty = seriesConfig.length === 0 || visibleSeries.length === 0;
|
|
125
168
|
|
|
169
|
+
const hasSecondaryAxis = seriesConfig.some((series) => series.axis === 'secondary');
|
|
170
|
+
if (hasSecondaryAxis && !secondaryYAxisLabel) {
|
|
171
|
+
throw new Error('TimeSeriesChart requires secondaryYAxisLabel when a series uses axis: "secondary".');
|
|
172
|
+
}
|
|
173
|
+
const primaryVisibleSeries = visibleSeries.filter((series) => series.axis !== 'secondary');
|
|
174
|
+
const secondaryVisibleSeries = visibleSeries.filter((series) => series.axis === 'secondary');
|
|
175
|
+
const yAxis = hasSecondaryAxis
|
|
176
|
+
? [
|
|
177
|
+
{
|
|
178
|
+
id: 'primary',
|
|
179
|
+
label: yAxisLabel,
|
|
180
|
+
...(isIntegerAxis(primaryVisibleSeries) ? { valueFormatter: integerTickFormatter } : {}),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
id: 'secondary',
|
|
184
|
+
label: secondaryYAxisLabel,
|
|
185
|
+
...(isIntegerAxis(secondaryVisibleSeries) ? { valueFormatter: integerTickFormatter } : {}),
|
|
186
|
+
},
|
|
187
|
+
]
|
|
188
|
+
: (isIntegerAxis(primaryVisibleSeries) ? [{ valueFormatter: integerTickFormatter }] : undefined);
|
|
189
|
+
|
|
126
190
|
const toolbar = (
|
|
127
191
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, alignItems: 'center' }}>
|
|
128
192
|
<RadioGroup
|
|
@@ -178,8 +242,16 @@ export function TimeSeriesChart({
|
|
|
178
242
|
<BarChart
|
|
179
243
|
xAxisLabel={xAxisLabel}
|
|
180
244
|
yAxisLabel={yAxisLabel}
|
|
181
|
-
xAxis={[{
|
|
182
|
-
|
|
245
|
+
xAxis={[{
|
|
246
|
+
data: data?.xLabels || [],
|
|
247
|
+
tickLabelInterval: makeTickLabelInterval((data?.xLabels || []).length),
|
|
248
|
+
}]}
|
|
249
|
+
yAxis={yAxis}
|
|
250
|
+
series={visibleSeries.map((series) => ({
|
|
251
|
+
data: series.data,
|
|
252
|
+
label: series.label,
|
|
253
|
+
...(hasSecondaryAxis && series.axis === 'secondary' ? { yAxisId: 'secondary' } : {}),
|
|
254
|
+
}))}
|
|
183
255
|
palette={visiblePalette}
|
|
184
256
|
height={CHART_HEIGHT}
|
|
185
257
|
/>
|
package/tests/BarChart.test.jsx
CHANGED
|
@@ -34,7 +34,7 @@ describe('BarChart preset', () => {
|
|
|
34
34
|
it.each([
|
|
35
35
|
['grouped', [{ data: [1] }, { data: [2] }], undefined],
|
|
36
36
|
['stacked', [{ data: [1], stack: 'total' }, { data: [2], stack: 'total' }], undefined],
|
|
37
|
-
['dual axis', [{ data: [1],
|
|
37
|
+
['dual axis', [{ data: [1], yAxisId: 'secondary' }, { data: [2] }], [{ id: 'primary' }, { id: 'secondary' }]],
|
|
38
38
|
])('renders the %s variant with a legend for multiple series', (_name, series, yAxis) => {
|
|
39
39
|
renderChart({ series, yAxis });
|
|
40
40
|
const props = chartSpy.mock.calls.at(-1)[0];
|
|
@@ -200,4 +200,129 @@ describe('TimeSeriesChart', () => {
|
|
|
200
200
|
const props = chartSpy.mock.calls.at(-1)[0];
|
|
201
201
|
expect(props.series.map((series) => series.label)).toEqual(['Presence hours']);
|
|
202
202
|
});
|
|
203
|
+
|
|
204
|
+
// CHART-5: second y-axis (opt-in per series via `axis: 'secondary'`),
|
|
205
|
+
// auto-detected integer-only tick formatting per axis, and a deterministic
|
|
206
|
+
// x-axis tickLabelInterval. Learned from CHART-4's own history in this
|
|
207
|
+
// file: assertions here are on the captured functions/props passed to the
|
|
208
|
+
// mocked MuiBarChart, never on real jsdom-rendered tick visibility/position
|
|
209
|
+
// (proven vacuous for that in CHART-4 -- jsdom's layout/ResizeObserver
|
|
210
|
+
// handling doesn't reproduce real-browser collision behaviour).
|
|
211
|
+
const MIXED_DATA = {
|
|
212
|
+
xLabels: ['Mon', 'Tue', 'Wed'],
|
|
213
|
+
series: [
|
|
214
|
+
{ key: 'users', label: 'Users', data: [1, 2, 3] },
|
|
215
|
+
{ key: 'presence', label: 'Presence hours', data: [4.5, 5.2, 6.1] },
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
it('CHART-5: no custom yAxis when the shared single axis is not all-integer (today\'s real case)', () => {
|
|
220
|
+
renderChart({ data: MIXED_DATA });
|
|
221
|
+
|
|
222
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
223
|
+
expect(props.yAxis).toHaveLength(1);
|
|
224
|
+
expect(props.yAxis[0].valueFormatter).toBeUndefined();
|
|
225
|
+
expect(props.yAxis[0].label).toBe('Count');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('CHART-5: auto-applies an integer tick formatter to a single shared axis that is all-integer', () => {
|
|
229
|
+
renderChart(); // SAMPLE_DATA: both series are whole numbers
|
|
230
|
+
|
|
231
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
232
|
+
expect(props.yAxis).toHaveLength(1);
|
|
233
|
+
expect(typeof props.yAxis[0].valueFormatter).toBe('function');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('CHART-5: dual axis activates with two labelled yAxis entries and correct yAxisId wiring', () => {
|
|
237
|
+
const data = {
|
|
238
|
+
xLabels: ['Mon', 'Tue', 'Wed'],
|
|
239
|
+
series: [
|
|
240
|
+
{ key: 'users', label: 'Users', data: [1, 2, 3] },
|
|
241
|
+
{ key: 'presence', label: 'Presence hours', data: [4.5, 5.2, 6.1], axis: 'secondary' },
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
renderChart({ data, secondaryYAxisLabel: 'Hours' });
|
|
245
|
+
|
|
246
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
247
|
+
expect(props.yAxis).toEqual([
|
|
248
|
+
expect.objectContaining({ id: 'primary', label: 'Count' }),
|
|
249
|
+
expect.objectContaining({ id: 'secondary', label: 'Hours' }),
|
|
250
|
+
]);
|
|
251
|
+
const usersSeries = props.series.find((series) => series.label === 'Users');
|
|
252
|
+
const presenceSeries = props.series.find((series) => series.label === 'Presence hours');
|
|
253
|
+
expect(usersSeries.yAxisId).toBeUndefined();
|
|
254
|
+
expect(presenceSeries.yAxisId).toBe('secondary');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('CHART-5: dual axis applies the integer formatter only to the axis that is actually all-integer', () => {
|
|
258
|
+
const data = {
|
|
259
|
+
xLabels: ['Mon', 'Tue', 'Wed'],
|
|
260
|
+
series: [
|
|
261
|
+
{ key: 'users', label: 'Users', data: [1, 2, 3], axis: 'primary' },
|
|
262
|
+
{ key: 'presence', label: 'Presence hours', data: [4.5, 5.2, 6.1], axis: 'secondary' },
|
|
263
|
+
],
|
|
264
|
+
};
|
|
265
|
+
renderChart({ data, secondaryYAxisLabel: 'Hours' });
|
|
266
|
+
|
|
267
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
268
|
+
const primaryAxis = props.yAxis.find((axis) => axis.id === 'primary');
|
|
269
|
+
const secondaryAxis = props.yAxis.find((axis) => axis.id === 'secondary');
|
|
270
|
+
expect(typeof primaryAxis.valueFormatter).toBe('function');
|
|
271
|
+
expect(secondaryAxis.valueFormatter).toBeUndefined();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('CHART-5: throws when a series declares axis "secondary" without secondaryYAxisLabel', () => {
|
|
275
|
+
const data = {
|
|
276
|
+
xLabels: ['Mon'],
|
|
277
|
+
series: [{ key: 'presence', label: 'Presence hours', data: [4.5], axis: 'secondary' }],
|
|
278
|
+
};
|
|
279
|
+
expect(() => renderChart({ data })).toThrow(
|
|
280
|
+
'TimeSeriesChart requires secondaryYAxisLabel when a series uses axis: "secondary".',
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('CHART-5: the integer tick formatter blanks non-integer tick labels but not tooltip/legend values', () => {
|
|
285
|
+
renderChart(); // SAMPLE_DATA, single all-integer axis
|
|
286
|
+
|
|
287
|
+
const { valueFormatter } = chartSpy.mock.calls.at(-1)[0].yAxis[0];
|
|
288
|
+
expect(valueFormatter(2, { location: 'tick' })).toBe('2');
|
|
289
|
+
expect(valueFormatter(2.5, { location: 'tick' })).toBe('');
|
|
290
|
+
expect(valueFormatter(2.5, { location: 'tooltip' })).toBe('2.5');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('CHART-5: toggling the non-integer series off switches the shared axis to integer-formatted', () => {
|
|
294
|
+
renderChart({ data: MIXED_DATA });
|
|
295
|
+
expect(chartSpy.mock.calls.at(-1)[0].yAxis[0].valueFormatter).toBeUndefined();
|
|
296
|
+
|
|
297
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Presence hours' }));
|
|
298
|
+
|
|
299
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
300
|
+
expect(typeof props.yAxis[0].valueFormatter).toBe('function');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('CHART-5: x-axis tickLabelInterval guarantees some labels always show, evenly spread', () => {
|
|
304
|
+
const manyLabels = Array.from({ length: 24 }, (_, i) => `Bucket ${i}`);
|
|
305
|
+
const data = { ...SAMPLE_DATA, xLabels: manyLabels, series: [SAMPLE_DATA.series[0]] };
|
|
306
|
+
renderChart({ data });
|
|
307
|
+
|
|
308
|
+
const { tickLabelInterval } = chartSpy.mock.calls.at(-1)[0].xAxis[0];
|
|
309
|
+
expect(typeof tickLabelInterval).toBe('function');
|
|
310
|
+
|
|
311
|
+
const visibleIndices = manyLabels.map((_, index) => index).filter((index) => tickLabelInterval(manyLabels[index], index));
|
|
312
|
+
expect(visibleIndices.length).toBeGreaterThanOrEqual(3);
|
|
313
|
+
expect(visibleIndices).toContain(0);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// CHART-5 (found by ui_reviewer): `index % step === 0` alone systematically
|
|
317
|
+
// drops the FINAL bucket unless it happens to land on a step boundary --
|
|
318
|
+
// e.g. 24 labels / step 3 covers 0..21, never 23. The most recent time
|
|
319
|
+
// bucket is usually the one that matters most on a time-series chart.
|
|
320
|
+
it.each([24, 12, 7, 1])('CHART-5: tickLabelInterval always includes the last bucket (labelCount=%i)', (labelCount) => {
|
|
321
|
+
const manyLabels = Array.from({ length: labelCount }, (_, i) => `Bucket ${i}`);
|
|
322
|
+
const data = { ...SAMPLE_DATA, xLabels: manyLabels, series: [SAMPLE_DATA.series[0]] };
|
|
323
|
+
renderChart({ data });
|
|
324
|
+
|
|
325
|
+
const { tickLabelInterval } = chartSpy.mock.calls.at(-1)[0].xAxis[0];
|
|
326
|
+
expect(tickLabelInterval(manyLabels.at(-1), labelCount - 1)).toBe(true);
|
|
327
|
+
});
|
|
203
328
|
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# WORK ORDER CHART-5 (ui-core-micha) — dual y-axis + always-visible x-axis ticks
|
|
2
|
+
|
|
3
|
+
**EXECUTION DIRECTIVE.** If you are the implementer reading this as your own spec, this section is not
|
|
4
|
+
addressed to you — it tells the Orchestrator how to invoke you; you ARE that invocation, do not shell
|
|
5
|
+
out to `codex exec`. Orchestrator: implement through `codex exec` in the background, invoked **directly
|
|
6
|
+
via Bash** (never the `debugger`/`*_coder` Agent wrappers) with **both** flags `--skip-git-repo-check`
|
|
7
|
+
and `--dangerously-bypass-approvals-and-sandbox`, prompt passed as a positional argument from this
|
|
8
|
+
file. Fall back to direct Claude implementation only on Codex quota / rate-limit / non-zero exit.
|
|
9
|
+
**Known at WO authoring time: the Codex workspace is out of credits for this session — the fallback
|
|
10
|
+
applies immediately, do not spend time retrying `codex exec` first.**
|
|
11
|
+
|
|
12
|
+
Feature WO on `TimeSeriesChart` (CHART-2/3/4). Operator request, found live in jg-ferien's Aktivität
|
|
13
|
+
screen: (a) the shared y-axis mixes an integer count series ("Eindeutige Nutzer") with a fractional
|
|
14
|
+
hours series ("Anwesenheitszeit"), producing decimal ticks (0.2, 0.4, ...) that don't make sense for a
|
|
15
|
+
user count; (b) the x-axis (time buckets) shows **zero** visible tick labels once there are enough
|
|
16
|
+
buckets that MUI X-Charts' built-in collision-avoidance decides none of them fit — worse than showing
|
|
17
|
+
a sparse subset, this shows nothing at all.
|
|
18
|
+
|
|
19
|
+
## TIER
|
|
20
|
+
Tier 2 — shared-core UI, new public surface (a new optional per-series field). Independent `reviewer`
|
|
21
|
+
+ `ui_reviewer` mandatory.
|
|
22
|
+
|
|
23
|
+
## WHY
|
|
24
|
+
|
|
25
|
+
**Y-axis.** CHART-2 deliberately rejected a second y-axis in favor of series toggles ("if you do not
|
|
26
|
+
add one, say so deliberately" — CHART-2 said so, choosing toggles). The operator has now explicitly
|
|
27
|
+
requested the second y-axis for this exact case, with full awareness of that prior decision (asked
|
|
28
|
+
in-chat, chose it knowingly over forcing shared-axis integer ticks — which would make the fractional
|
|
29
|
+
series nearly invisible — and over hiding the tick label only when one series is toggled off). This
|
|
30
|
+
WO reverses CHART-2's choice on operator authority, it does not silently second-guess it.
|
|
31
|
+
|
|
32
|
+
**X-axis.** `node_modules/@mui/x-charts/ChartsXAxis/getVisibleLabels.js`'s default behaviour drops a
|
|
33
|
+
tick label the moment it would overlap the previous one (`tickLabelMinGap`) — with many narrow-band
|
|
34
|
+
categories (e.g. 24 hourly buckets) and non-trivial label text (jg-ferien's `formatBucketLabel`
|
|
35
|
+
produces e.g. `"08/06, 08:00"`), every label can end up colliding and the *entire* axis goes blank.
|
|
36
|
+
MUI's own escape hatch is `tickLabelInterval` as a **function** — when provided, it fully replaces the
|
|
37
|
+
collision-based filtering (`getVisibleLabels.js`: `if (typeof tickLabelInterval === 'function') return
|
|
38
|
+
new Set(xTicks.filter((item, index) => tickLabelInterval(item.value, index)))`), so a deterministic,
|
|
39
|
+
evenly-spaced selection can guarantee *some* labels always render regardless of container width or
|
|
40
|
+
label length.
|
|
41
|
+
|
|
42
|
+
## SCOPE
|
|
43
|
+
|
|
44
|
+
**A. Per-series `axis` field, backward-compatible.** Each entry in `data.series[i]` gains an optional
|
|
45
|
+
`axis: 'primary' | 'secondary'` field. Default (omitted, or every series `'primary'`): **byte-for-byte
|
|
46
|
+
today's single-shared-axis behaviour** — no `yAxis` array change, no `yAxisKey` on series, nothing
|
|
47
|
+
observably different for existing consumers (jg-ferien pre-adoption, or any future host that doesn't
|
|
48
|
+
use the field). Only when **at least one** series declares `axis: 'secondary'` does dual-axis mode
|
|
49
|
+
activate:
|
|
50
|
+
- Build `yAxis` as two entries: primary (`id: 'primary'`, label = existing `yAxisLabel` prop) and
|
|
51
|
+
secondary (`id: 'secondary'`, label = new prop `secondaryYAxisLabel`).
|
|
52
|
+
- Each series passed to `BarChart` gets `yAxisKey: 'secondary'` when its `axis` is `'secondary'`,
|
|
53
|
+
omitted (defaults to the first/primary axis) otherwise — `series` items already pass straight
|
|
54
|
+
through `BarChart`'s `...chartProps` to the real `MuiBarChart`, which natively supports
|
|
55
|
+
`yAxisKey` per series; no `BarChart.jsx` change needed for this part, verify that during
|
|
56
|
+
implementation rather than assuming.
|
|
57
|
+
- `secondaryYAxisLabel` is required (throw, matching `BarChart`'s own `xAxisLabel`/`yAxisLabel`
|
|
58
|
+
required-prop convention) **only when dual-axis mode is actually active** (i.e. when computing
|
|
59
|
+
it: if any series has `axis === 'secondary'` and `secondaryYAxisLabel` is falsy, throw with a
|
|
60
|
+
clear message) — do not require it unconditionally, that would break every existing single-axis
|
|
61
|
+
caller.
|
|
62
|
+
|
|
63
|
+
**B. Auto-detected integer-only ticks per axis group, not a new prop.** For each y-axis actually in
|
|
64
|
+
use (primary always; secondary only in dual-axis mode), if **every** data value across that axis's
|
|
65
|
+
currently-**visible** series (respecting the existing series-toggle `visibleKeys` state — an axis
|
|
66
|
+
whose only integer series just got toggled off should not keep forcing integer ticks on whatever
|
|
67
|
+
remains) is a whole number (`Number.isInteger`), apply a `valueFormatter` to that axis's config that
|
|
68
|
+
only emits a label for integer tick values and an empty string otherwise (mirrors the X-axis
|
|
69
|
+
collision convention: `getVisibleLabels.js` already treats `formattedValue === ''` as "don't show
|
|
70
|
+
this tick" for the X axis; verify the Y-axis tick renderer honours the same convention during
|
|
71
|
+
implementation — if it does not, use whatever mechanism it actually supports to the same effect, and
|
|
72
|
+
say so in the completion note). If not all-integer, leave the axis's default (no custom formatter) —
|
|
73
|
+
this is what keeps `Anwesenheitszeit` (fractional hours) unaffected on its own axis, and correctly
|
|
74
|
+
does nothing extra for a primary axis that legitimately has fractional data.
|
|
75
|
+
|
|
76
|
+
**C. Deterministic x-axis tick visibility.** Replace MUI's default collision-based tick filtering with
|
|
77
|
+
an explicit `tickLabelInterval` function on the x-axis config, evenly sampling up to `MAX_X_TICKS`
|
|
78
|
+
(pick a sensible constant, e.g. 8) labels across `data.xLabels`, e.g. `index % Math.max(1,
|
|
79
|
+
Math.ceil(xLabels.length / MAX_X_TICKS)) === 0` (adjust as needed so the *last* category is also
|
|
80
|
+
reachable/considered, not just an artifact of the modulo — check this doesn't systematically drop the
|
|
81
|
+
final bucket). This must apply regardless of dual-axis mode (independent scope).
|
|
82
|
+
|
|
83
|
+
## NON-GOALS / DO NOT TOUCH
|
|
84
|
+
- No third axis, no per-axis custom colors, no new axis-styling props beyond what SCOPE describes.
|
|
85
|
+
- Do not touch the range picker, series-toggle checkboxes' own rendering, `chartsTranslations`, or the
|
|
86
|
+
CHART-3/CHART-4 fixes (`visibleKeys` sync, `CHART_HEIGHT`).
|
|
87
|
+
- Do not change `BarChart.jsx`/`ChartFrame.jsx` unless SCOPE A's `yAxisKey` passthrough turns out to
|
|
88
|
+
need it (verify first, per SCOPE A's own note) — if it does, extend deliberately and say so, matching
|
|
89
|
+
CHART-2's own "do not work around a primitive gap in the preset" rule.
|
|
90
|
+
- jg-ferien's own adoption (setting `axis: 'secondary'` on `presence_hours`, adding a
|
|
91
|
+
`secondaryYAxisLabel`, shortening `formatBucketLabel`) is **explicitly out of scope for this WO** —
|
|
92
|
+
a separate companion change in that repo, after this publishes.
|
|
93
|
+
|
|
94
|
+
## RISKS
|
|
95
|
+
- `tickLabelInterval` as a function fully bypasses MUI's built-in overlap detection (per SCOPE C's
|
|
96
|
+
quoted source) — a fixed `MAX_X_TICKS` could still produce overlapping text at extreme narrow
|
|
97
|
+
widths with very long labels. Accepted: "some labels, deterministically" is strictly better than
|
|
98
|
+
"zero labels, unpredictably" (today's bug), and host-side label shortening (jg-ferien's future
|
|
99
|
+
companion change) is the complementary fix for extreme cases, not this WO's job.
|
|
100
|
+
- Every host that adopts `axis: 'secondary'` changes its chart's visual scale contract (two
|
|
101
|
+
independent y-domains can visually mislead if not clearly labelled) — mitigated by requiring
|
|
102
|
+
`secondaryYAxisLabel`, not by this WO deciding it's always fine.
|
|
103
|
+
- The integer-tick auto-detection (SCOPE B) re-evaluates on every toggle (depends on `visibleKeys`) —
|
|
104
|
+
confirm this doesn't cause a visible axis-label flicker/jump on toggle beyond the intended "ticks
|
|
105
|
+
change because domain changed" behaviour the toggles already produce today.
|
|
106
|
+
|
|
107
|
+
## REQUIRED TESTS TO WRITE
|
|
108
|
+
Extend `tests/TimeSeriesChart.test.jsx`. Do NOT run the full suite.
|
|
109
|
+
|
|
110
|
+
**Learn from CHART-4's own history in this file (read `WORK_ORDERS.md`'s CHART-4 row before writing
|
|
111
|
+
these): an unmocked test asserting real rendered DOM/tick visibility was proven VACUOUS in this exact
|
|
112
|
+
component (jsdom's layout/ResizeObserver handling doesn't reproduce real-browser collision behaviour).
|
|
113
|
+
Do not repeat that mistake here.** For anything about tick *visibility* or *positions* (SCOPE C, and
|
|
114
|
+
SCOPE B's empty-string-for-non-integer-ticks mechanism), assert on the **function/config actually
|
|
115
|
+
passed** to the mocked `MuiBarChart` (`chartSpy.mock.calls`) — e.g. call the captured
|
|
116
|
+
`tickLabelInterval` function directly with sample `(value, index)` pairs and assert its boolean
|
|
117
|
+
results, or call the captured y-axis `valueFormatter` directly with sample tick values and assert its
|
|
118
|
+
string output — never assert on rendered pixel positions or DOM overlap in jsdom.
|
|
119
|
+
|
|
120
|
+
1. **Dual-axis backward compatibility**: with no series declaring `axis`, `chartSpy`'s `yAxis` prop
|
|
121
|
+
must be unchanged from today's single-entry shape (or however it's currently constructed — assert
|
|
122
|
+
equivalently to the existing tests, not a new shape).
|
|
123
|
+
2. **Dual-axis activation**: with one series `axis: 'secondary'`, assert `chartSpy`'s `yAxis` has two
|
|
124
|
+
entries (primary/secondary ids, correct labels from `yAxisLabel`/`secondaryYAxisLabel`), and assert
|
|
125
|
+
the secondary series' entry in `chartSpy`'s `series` prop carries `yAxisKey` matching the secondary
|
|
126
|
+
axis id while the other series' entry does not (or carries the primary id — whichever the
|
|
127
|
+
implementation settles on, be explicit and consistent).
|
|
128
|
+
3. **Missing `secondaryYAxisLabel` throws** when a series declares `axis: 'secondary'` but the prop is
|
|
129
|
+
absent — mirrors `BarChart`'s own required-prop throw test convention (check
|
|
130
|
+
`tests/BarChart.test.jsx` for that pattern).
|
|
131
|
+
4. **Integer-tick auto-detection, both directions**: one case where a y-axis's visible series are all
|
|
132
|
+
whole numbers (assert the captured `valueFormatter` returns a non-empty string for an integer tick
|
|
133
|
+
value and `''` for a non-integer one), and one case where they are not all whole numbers (assert no
|
|
134
|
+
custom `valueFormatter` is applied, or that it returns the value formatted normally for every tick —
|
|
135
|
+
whichever the implementation does, assert it explicitly).
|
|
136
|
+
5. **Toggle re-evaluation**: toggling off the one non-integer series on a shared/primary axis (single-
|
|
137
|
+
axis mode) changes that axis from "not all-integer" to "all-integer" — assert the formatter
|
|
138
|
+
behaviour changes accordingly after the toggle. (This is the SCOPE B "re-evaluates on toggle" case,
|
|
139
|
+
RISKS section.)
|
|
140
|
+
6. **X-axis `tickLabelInterval` guarantees coverage**: call the captured function against a large
|
|
141
|
+
`xLabels` array (e.g. 24 entries) and assert it returns `true` for at least `N` evenly-spread
|
|
142
|
+
indices (pick a concrete, checkable lower bound, e.g. at least 3 for 24 items at `MAX_X_TICKS=8`)
|
|
143
|
+
and that it does not return `true` for literally zero indices (the non-vacuity check for the whole
|
|
144
|
+
WO — the very bug this section fixes).
|
|
145
|
+
7. **Non-vacuity**: each new test must fail against the current (pre-fix) code — verify before
|
|
146
|
+
considering the fix complete. (SCOPE C's test 6 obviously fails today since there is no
|
|
147
|
+
`tickLabelInterval` at all; confirm the others similarly.)
|
|
148
|
+
|
|
149
|
+
## TARGET REPO
|
|
150
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main`. Publish per the repo's release flow
|
|
151
|
+
(minor bump — new optional field/prop, additive, no breaking change to existing callers).
|
|
152
|
+
|
|
153
|
+
## TEST SCOPE FOR THE GATE (orchestrator)
|
|
154
|
+
`tests/TimeSeriesChart.test.jsx`. Not the full suite.
|
|
155
|
+
|
|
156
|
+
## CONTEXT PACKAGE — verified current state (Orchestrator, Implementation map)
|
|
157
|
+
|
|
158
|
+
Work from this package; do not explore broadly from scratch — open only the named files to verify.
|
|
159
|
+
|
|
160
|
+
**File to change: `src/components/charts/TimeSeriesChart.jsx`** — current full return statement and
|
|
161
|
+
surrounding derived-state variables (`:117-189`, verified current):
|
|
162
|
+
```jsx
|
|
163
|
+
const visibleSeries = seriesConfig.filter((series) => visibleKeys.has(series.key));
|
|
164
|
+
const visiblePalette = visibleSeries.map((series) => palette[seriesConfig.indexOf(series) % palette.length]);
|
|
165
|
+
const isDataEmpty = seriesConfig.length === 0 || visibleSeries.length === 0;
|
|
166
|
+
...
|
|
167
|
+
return (
|
|
168
|
+
<ChartFrame ... minHeight={CHART_HEIGHT}>
|
|
169
|
+
{!isDataEmpty && (
|
|
170
|
+
<BarChart
|
|
171
|
+
xAxisLabel={xAxisLabel}
|
|
172
|
+
yAxisLabel={yAxisLabel}
|
|
173
|
+
xAxis={[{ data: data?.xLabels || [] }]}
|
|
174
|
+
series={visibleSeries.map((series) => ({ data: series.data, label: series.label }))}
|
|
175
|
+
palette={visiblePalette}
|
|
176
|
+
height={CHART_HEIGHT}
|
|
177
|
+
/>
|
|
178
|
+
)}
|
|
179
|
+
</ChartFrame>
|
|
180
|
+
);
|
|
181
|
+
```
|
|
182
|
+
Component signature currently: `{ title, subtitle, xAxisLabel, yAxisLabel, data, loading, error,
|
|
183
|
+
onRangeChange, defaultRange }` (`:56-66`) — add `secondaryYAxisLabel` here.
|
|
184
|
+
|
|
185
|
+
**`BarChart.jsx`** (full file, unchanged by this WO unless SCOPE A's verification says otherwise):
|
|
186
|
+
`series`/`xAxis`/`yAxis` are BarChart's own named props; `series` is passed straight through
|
|
187
|
+
(`series={series}` onto `MuiBarChart`, `:35`) with NO per-item transformation — so a `yAxisKey` field
|
|
188
|
+
already present on a series object the caller builds should reach `MuiBarChart` unmodified. `yAxis`
|
|
189
|
+
goes through `labelledAxis(yAxis, yAxisLabel, {scaleType:'linear'})` (`:37`), which preserves any
|
|
190
|
+
`label` already set on an axis entry (only fills in the default when absent) — so `TimeSeriesChart`
|
|
191
|
+
passing two fully-labelled `yAxis` entries will not have `BarChart`'s own `yAxisLabel` prop override
|
|
192
|
+
them; **`TimeSeriesChart` should stop passing a single top-level `yAxisLabel` to `BarChart` once it is
|
|
193
|
+
building its own multi-entry `yAxis` array explicitly** (or `BarChart`'s `yAxisLabel` requirement
|
|
194
|
+
throws — check `BarChart.jsx:27-29`'s `if (!xAxisLabel || !yAxisLabel) throw` and account for it:
|
|
195
|
+
either keep passing `yAxisLabel` as before, since `labelledAxis` won't clobber pre-labelled entries
|
|
196
|
+
anyway, or restructure — pick whichever keeps `BarChart`'s existing required-prop contract intact
|
|
197
|
+
without a special case).
|
|
198
|
+
|
|
199
|
+
**X-axis**: `xAxis={[{ data: data?.xLabels || [] }]}` (`:181` current) — add `tickLabelInterval` to
|
|
200
|
+
this object; `labelledAxis` will preserve it (only fills `label`, spreads everything else through).
|
|
201
|
+
|
|
202
|
+
**Existing tests to model conventions on**: `tests/TimeSeriesChart.test.jsx` (chartSpy mock pattern,
|
|
203
|
+
already described above) and `tests/BarChart.test.jsx` (required-prop-throws test pattern for the
|
|
204
|
+
`secondaryYAxisLabel` throw case).
|
|
205
|
+
|
|
206
|
+
## MINI-HANDOVER (pastable)
|
|
207
|
+
|
|
208
|
+
> Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`).
|
|
209
|
+
> Work order: `work-orders/CHART-5.md` — read it fully, then follow the `orchestrate-codex` skill.
|
|
210
|
+
> **Codex workspace is out of credits this session — go straight to the direct-Claude fallback.**
|
|
211
|
+
>
|
|
212
|
+
> Two independent additions to `TimeSeriesChart`, both operator-requested after seeing the shipped
|
|
213
|
+
> CHART-2/3/4 chart live: (1) an opt-in second y-axis via a new per-series `axis: 'secondary'` field,
|
|
214
|
+
> fully backward-compatible when unused, with auto-detected integer-only tick labels per axis group
|
|
215
|
+
> (not a new prop — derived from whether that axis's visible series data is all whole numbers); (2) a
|
|
216
|
+
> deterministic x-axis `tickLabelInterval` so time-bucket labels never all vanish under MUI's default
|
|
217
|
+
> collision-avoidance, which is exactly what happened live (zero labels shown with 24 hourly buckets).
|
|
218
|
+
> **Test-writing pitfall already hit once in this file (CHART-4): do not write a test that asserts on
|
|
219
|
+
> real jsdom-rendered tick visibility/position — it will be vacuous. Assert on the captured
|
|
220
|
+
> `tickLabelInterval`/`valueFormatter` functions' own return values instead**, calling them directly
|
|
221
|
+
> with sample inputs against the mocked `chartSpy` call's props.
|
|
222
|
+
|
|
223
|
+
## PROGRESS CONTRACT
|
|
224
|
+
Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
|
|
225
|
+
relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
|
|
226
|
+
stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
|