@micha.bigler/ui-core-micha 2.28.1 → 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 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. Never dual-axis.** The concrete data palette is per-app / the `ui-core-micha` chart kit — this states the rules, not a fixed palette.
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,8 @@ 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. |
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. |
26
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. |
27
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). |
28
30
  | PERF-3B1 | Parallel auth bootstrap after CSRF | Starts auth-methods and current-user concurrently once CSRF is available, while preserving error handling and loading semantics. | 2026-07-19 | done | ee89329 | Independent `reviewer` + `sec_reviewer` passes both clean (no findings); one P3 test-coverage gap from the reviewer (missing mirror case: auth-methods rejects, current-user succeeds) closed with an added regression test — 45/45 tests green. Published as 2.10.1 (patch, no interface change) and pinned in jg-ferien alongside its PERF-3A companion WO. |
@@ -8,6 +8,58 @@ import { useNeutralChartPalette } from './palette';
8
8
  // Fixed platform-wide range -> granularity mapping (operator decision, CHART-2 /
9
9
  // dcm ACT-1) — the resolution follows the window and is never an independent
10
10
  // caller-supplied control. Every preset lands between a dozen and fifty points.
11
+ // Preset default height (CHART-4): MUI X-Charts' responsive container
12
+ // measures its actual parent height via ResizeObserver and draws nothing
13
+ // if that parent has none — ChartFrame's flex-centered content Box does
14
+ // NOT stretch its child to fill available height (alignItems: 'center'),
15
+ // so BarChart needs its own explicit size, not just ChartFrame's.
16
+ // `height` forwards straight to MuiBarChart's own native height prop
17
+ // (bypassing ResizeObserver-based measurement entirely), matching the
18
+ // confirmed-working dev/entries.jsx BarChartEntry reference.
19
+ // Deliberate, known exception to DESIGN.md #14 ("responsive = container-
20
+ // sized, not fixed px"): width stays responsive (unset, still auto-
21
+ // measured), only height is fixed, and only because MUI X-Charts' own
22
+ // ResizeObserver-based height measurement is what silently produced a
23
+ // zero-height chart in the first place (CHART-4). Do not "fix" this back
24
+ // to a responsive/aspect-ratio height without re-verifying the underlying
25
+ // MUI measurement bug is actually resolved.
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
+ }
11
63
  const RANGE_OPTIONS = [
12
64
  { key: '1d', granularity: 'hour', labelKey: 'TimeSeriesChart.RANGE_1_DAY' },
13
65
  { key: '1w', granularity: '4hour', labelKey: 'TimeSeriesChart.RANGE_1_WEEK' },
@@ -23,12 +75,13 @@ const RANGE_OPTIONS = [
23
75
  * owns fetching; this component owns which range is selected and which
24
76
  * series are visible, telling the host what changed via `onRangeChange`.
25
77
  *
26
- * No second y-axis: the series toggles (not a second axis) are the chosen
27
- * fix for two series sharing one y-axis at very different scales — toggling
28
- * one off rescales the axis and makes the other readable. This was a
29
- * deliberate choice, not an oversight (CHART-2 scope B).
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.
30
83
  */
31
- 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', }) {
32
85
  const { t } = useTranslation();
33
86
  const palette = useNeutralChartPalette().categorical;
34
87
  const seriesConfig = (data === null || data === void 0 ? void 0 : data.series) || [];
@@ -82,10 +135,25 @@ export function TimeSeriesChart({ title, subtitle, xAxisLabel, yAxisLabel, data,
82
135
  // series' color, breaking the swatch-to-series identity the toggles rely on.
83
136
  const visiblePalette = visibleSeries.map((series) => palette[seriesConfig.indexOf(series) % palette.length]);
84
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);
85
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: {
86
151
  color: palette[index % palette.length],
87
152
  '&.Mui-checked': { color: palette[index % palette.length] },
88
153
  } })), label: series.label }, series.key))) }))] }));
89
- return (_jsx(ChartFrame, { title: title, subtitle: subtitle, toolbar: toolbar, loading: loading, error: error, isEmpty: isDataEmpty, children: !isDataEmpty && (_jsx(BarChart, { xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xAxis: [{ data: (data === null || data === void 0 ? void 0 : data.xLabels) || [] }], series: visibleSeries.map((series) => ({ data: series.data, label: series.label })), palette: visiblePalette })) }));
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 })) }));
90
158
  }
91
159
  export default TimeSeriesChart;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.28.1",
3
+ "version": "2.29.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -15,6 +15,64 @@ import { useNeutralChartPalette } from './palette';
15
15
  // Fixed platform-wide range -> granularity mapping (operator decision, CHART-2 /
16
16
  // dcm ACT-1) — the resolution follows the window and is never an independent
17
17
  // caller-supplied control. Every preset lands between a dozen and fifty points.
18
+ // Preset default height (CHART-4): MUI X-Charts' responsive container
19
+ // measures its actual parent height via ResizeObserver and draws nothing
20
+ // if that parent has none — ChartFrame's flex-centered content Box does
21
+ // NOT stretch its child to fill available height (alignItems: 'center'),
22
+ // so BarChart needs its own explicit size, not just ChartFrame's.
23
+ // `height` forwards straight to MuiBarChart's own native height prop
24
+ // (bypassing ResizeObserver-based measurement entirely), matching the
25
+ // confirmed-working dev/entries.jsx BarChartEntry reference.
26
+ // Deliberate, known exception to DESIGN.md #14 ("responsive = container-
27
+ // sized, not fixed px"): width stays responsive (unset, still auto-
28
+ // measured), only height is fixed, and only because MUI X-Charts' own
29
+ // ResizeObserver-based height measurement is what silently produced a
30
+ // zero-height chart in the first place (CHART-4). Do not "fix" this back
31
+ // to a responsive/aspect-ratio height without re-verifying the underlying
32
+ // MUI measurement bug is actually resolved.
33
+ const CHART_HEIGHT = 320;
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
+
18
76
  const RANGE_OPTIONS = [
19
77
  { key: '1d', granularity: 'hour', labelKey: 'TimeSeriesChart.RANGE_1_DAY' },
20
78
  { key: '1w', granularity: '4hour', labelKey: 'TimeSeriesChart.RANGE_1_WEEK' },
@@ -31,16 +89,18 @@ const RANGE_OPTIONS = [
31
89
  * owns fetching; this component owns which range is selected and which
32
90
  * series are visible, telling the host what changed via `onRangeChange`.
33
91
  *
34
- * No second y-axis: the series toggles (not a second axis) are the chosen
35
- * fix for two series sharing one y-axis at very different scales — toggling
36
- * one off rescales the axis and makes the other readable. This was a
37
- * deliberate choice, not an oversight (CHART-2 scope B).
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.
38
97
  */
39
98
  export function TimeSeriesChart({
40
99
  title,
41
100
  subtitle,
42
101
  xAxisLabel,
43
102
  yAxisLabel,
103
+ secondaryYAxisLabel,
44
104
  data,
45
105
  loading = false,
46
106
  error = false,
@@ -106,6 +166,27 @@ export function TimeSeriesChart({
106
166
  );
107
167
  const isDataEmpty = seriesConfig.length === 0 || visibleSeries.length === 0;
108
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
+
109
190
  const toolbar = (
110
191
  <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, alignItems: 'center' }}>
111
192
  <RadioGroup
@@ -155,14 +236,24 @@ export function TimeSeriesChart({
155
236
  loading={loading}
156
237
  error={error}
157
238
  isEmpty={isDataEmpty}
239
+ minHeight={CHART_HEIGHT}
158
240
  >
159
241
  {!isDataEmpty && (
160
242
  <BarChart
161
243
  xAxisLabel={xAxisLabel}
162
244
  yAxisLabel={yAxisLabel}
163
- xAxis={[{ data: data?.xLabels || [] }]}
164
- series={visibleSeries.map((series) => ({ data: series.data, label: series.label }))}
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
+ }))}
165
255
  palette={visiblePalette}
256
+ height={CHART_HEIGHT}
166
257
  />
167
258
  )}
168
259
  </ChartFrame>
@@ -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], yAxisKey: 'secondary' }, { data: [2] }], [{ id: 'primary' }, { id: 'secondary' }]],
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];
@@ -125,6 +125,23 @@ describe('TimeSeriesChart', () => {
125
125
  expect(screen.getByLabelText('Loading chart.')).toBeTruthy();
126
126
  });
127
127
 
128
+ // CHART-4 regression: neither ChartFrame nor BarChart ever received a
129
+ // height, so MUI X-Charts' responsive container measured a zero-height
130
+ // parent and drew no bars/axes (only the legend, sized by its own content,
131
+ // showed) -- confirmed live in production. A structural/rendered-size
132
+ // assertion in jsdom is NOT reliable here (verified: it stays green even
133
+ // without the fix, since jsdom's ResizeObserver handling doesn't reflect
134
+ // the real zero-height-parent browser behaviour) -- this asserts directly
135
+ // on the wiring instead: BarChart must receive a real, non-zero `height`,
136
+ // matching the confirmed-working dev/entries.jsx BarChartEntry reference
137
+ // (MUI's own native height prop, forwarded via BarChart's ...chartProps).
138
+ it('CHART-4: passes a real, non-zero height through to the underlying MUI BarChart', () => {
139
+ renderChart();
140
+
141
+ const props = chartSpy.mock.calls.at(-1)[0];
142
+ expect(props.height).toBeGreaterThan(0);
143
+ });
144
+
128
145
  // CHART-3 regression: visibleKeys must not be captured only at first mount.
129
146
  // Reproduces the live bug — host mounts with empty data, fetches
130
147
  // asynchronously, then rerenders with real data. Before the fix this stayed
@@ -183,4 +200,129 @@ describe('TimeSeriesChart', () => {
183
200
  const props = chartSpy.mock.calls.at(-1)[0];
184
201
  expect(props.series.map((series) => series.label)).toEqual(['Presence hours']);
185
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
+ });
186
328
  });
@@ -0,0 +1,203 @@
1
+ # WORK ORDER CHART-4 (ui-core-micha) — TimeSeriesChart renders no chart body (zero height)
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
+ Bug-fix WO on CHART-2/CHART-3. Found live in jg-ferien's "Aktivität" screen (ACT-3/ACT-5 consumer)
13
+ immediately after CHART-3 shipped and jg's pin was bumped: the toolbar, series checkboxes, and MUI
14
+ X-Charts **legend** now all render correctly (proving CHART-3's fix works and real data is present),
15
+ but the chart canvas itself — bars and axes — is entirely absent. The card ends right after the
16
+ legend row with visible empty space beneath it.
17
+
18
+ ## TIER
19
+ Tier 2 — shared-core UI consumed by every app that adopts `TimeSeriesChart`.
20
+ Independent `reviewer` + `ui_reviewer` mandatory.
21
+
22
+ ## WHY
23
+
24
+ `TimeSeriesChart` composes `ChartFrame` + `BarChart` but never supplies either with a height:
25
+
26
+ ```jsx
27
+ <ChartFrame title={title} subtitle={subtitle} toolbar={toolbar} loading={loading} error={error} isEmpty={isDataEmpty}>
28
+ {!isDataEmpty && (
29
+ <BarChart xAxisLabel={xAxisLabel} yAxisLabel={yAxisLabel} xAxis={[{ data: data?.xLabels || [] }]}
30
+ series={visibleSeries.map(...)} palette={visiblePalette} />
31
+ )}
32
+ </ChartFrame>
33
+ ```
34
+
35
+ Neither `minHeight`/`aspect` (ChartFrame's own sizing props) nor `minHeight`/`aspect`/a raw MUI
36
+ `height` (BarChart's) are passed. `ChartFrame`'s inner content `Box` has `display: flex, alignItems:
37
+ center, justifyContent: center` with no defined height — with `alignItems: center` (not `stretch`),
38
+ a flex child gets its own intrinsic height, not the parent's. `BarChart`'s own wrapping `Box` (`sx:
39
+ {width:'100%', minHeight, aspectRatio}`) therefore also has no height when neither prop is supplied.
40
+ MUI X-Charts' internal responsive container measures its actual parent height via `ResizeObserver` to
41
+ size the SVG — with a zero-height parent, the chart canvas (bars, axes) renders at zero size. The
42
+ **legend** is a separate MUI X-Charts sub-component sized by its own content (text + swatches), so it
43
+ renders regardless of container height — which is exactly why the legend was the only visible sign
44
+ of a mounted chart in the live screenshot.
45
+
46
+ This gap was invisible until now because CHART-3's own bug (`visibleKeys` never populating) kept
47
+ `isDataEmpty` permanently `true`, so `BarChart` never actually mounted in any live deployment before
48
+ today. It also went uncaught by CHART-2's and CHART-3's own tests: both `tests/BarChart.test.jsx` and
49
+ `tests/TimeSeriesChart.test.jsx` mock `@mui/x-charts/BarChart` itself (`vi.mock('@mui/x-charts/BarChart', ...)`
50
+ per CHART-2's own required-tests convention), so no test in this repo has ever mounted the *real* MUI
51
+ chart and observed its rendered size. The working reference is `dev/entries.jsx`'s `BarChartEntry`
52
+ (DX-1 harness), which explicitly sets `minHeight={360}` on `ChartFrame` and `height={320}` directly
53
+ on `BarChart` — i.e. every other real consumer of these primitives in this repo already knows it must
54
+ supply a height; `TimeSeriesChart`, as a preset built on top, was supposed to supply this itself
55
+ (hosts don't see `ChartFrame`/`BarChart` directly through the preset) and doesn't.
56
+
57
+ ## SCOPE
58
+
59
+ **Give `TimeSeriesChart` a sensible built-in height for the chart it composes**, matching the
60
+ DX-1-proven working pattern:
61
+ 1. Pass `minHeight` to `ChartFrame` (sizes the loading/error/empty states too, not just the chart).
62
+ 2. Pass a height down to `BarChart` so the actual MUI chart canvas gets a real, non-zero pixel size —
63
+ either `BarChart`'s own `minHeight`/`aspect` wrapper props, or a raw `height` forwarded via
64
+ `BarChart`'s `...chartProps` pass-through to `MuiBarChart` (the mechanism `dev/entries.jsx` uses
65
+ directly against `BarChart`, confirmed working there). Pick whichever actually renders a non-zero
66
+ chart canvas when verified (see REQUIRED TESTS) — do not assume `aspectRatio` CSS alone reliably
67
+ drives MUI X-Charts' `ResizeObserver` sizing without checking; the raw `height` prop is the
68
+ confirmed-working mechanism in this repo's own reference usage and is the safer default if there
69
+ is any doubt.
70
+ 3. Pick one fixed value (e.g. `320`/`360`, matching `dev/entries.jsx`'s own numbers) as a preset
71
+ default — this is a preset, not a raw primitive; the host does not choose chart height, matching
72
+ `TimeSeriesChart`'s existing "no new props" presentational contract. Do not add a new prop for
73
+ this unless a reviewer specifically asks for one.
74
+
75
+ **This WO's real fix is the test gap, not just the pixels.** The reason this shipped broken twice is
76
+ that every test in this file mocks the real MUI chart component. Required test 1 below must exercise
77
+ the **unmocked** `@mui/x-charts/BarChart` and assert a real rendered size — a mocked-chart test would
78
+ be exactly the kind of vacuous coverage that let this ship.
79
+
80
+ ## NON-GOALS / DO NOT TOUCH
81
+ - Do not change the range picker, series toggles, `chartsTranslations`, or the CHART-3 `visibleKeys`
82
+ sync logic — this WO is scoped to sizing only.
83
+ - Do not change `ChartFrame`'s or `BarChart`'s own prop contracts (`minHeight`/`aspect` already exist
84
+ on both) — only how `TimeSeriesChart` calls them.
85
+ - Do not add a new prop to `TimeSeriesChart`'s public API for this — see SCOPE point 3.
86
+
87
+ ## RISKS
88
+ - Every host that has adopted `TimeSeriesChart` is affected (currently jg-ferien via ACT-3/ACT-5) —
89
+ this changes rendering behaviour for all of them, in the correct direction (chart canvas now
90
+ actually visible).
91
+ - Getting this wrong a second time (e.g. an `aspectRatio`-only fix that still doesn't reliably drive
92
+ MUI X-Charts' internal measurement) ships a third silent failure in the same component. The
93
+ required unmocked-render test exists specifically to catch that before it reaches a live app again.
94
+
95
+ ## REQUIRED TESTS TO WRITE
96
+ Extend `tests/TimeSeriesChart.test.jsx`. Do NOT run the full suite.
97
+
98
+ 1. **The real bug's mechanism, not a jsdom layout measurement.** An initial attempt at this WO wrote
99
+ an *unmocked* test (real `@mui/x-charts/BarChart`, no `vi.mock`) asserting the rendered chart
100
+ surface's `viewBox` height was non-zero — verified, in practice, to be **vacuous**: it stayed green
101
+ even with the pre-fix code (no `height` prop anywhere), because jsdom's `ResizeObserver`/layout
102
+ handling does not reproduce the real-browser zero-height-parent behaviour this bug depends on. Do
103
+ not repeat that approach. Instead, keep the existing `chartSpy` mock convention (`vi.hoisted` +
104
+ `vi.mock('@mui/x-charts/BarChart', ...)`, already at `tests/TimeSeriesChart.test.jsx:9-10`) and
105
+ assert directly on the props the real `MuiBarChart` receives: `chartSpy.mock.calls.at(-1)[0].height`
106
+ must be a real, non-zero number. This is what actually distinguishes fixed from broken — confirmed
107
+ by reverting the fix and re-running: the prop assertion fails pre-fix (`props.height` is
108
+ `undefined`), the unmocked viewBox assertion does not.
109
+ 2. **Non-vacuity**: this test must fail against the current (pre-fix) code — verify before considering
110
+ the fix complete (see note above — already verified once for the prop-assertion approach).
111
+ 3. Existing mocked-`BarChart` tests (range/toggle/empty-state behaviour) stay as they are.
112
+
113
+ ## CONTEXT PACKAGE — verified current state (Orchestrator, Implementation map)
114
+
115
+ Work from this package; do not explore broadly from scratch — open only the named files to verify.
116
+
117
+ **File to change: `src/components/charts/TimeSeriesChart.jsx`.**
118
+
119
+ Current return statement (verified, full slice, `:150-169`):
120
+ ```jsx
121
+ return (
122
+ <ChartFrame
123
+ title={title}
124
+ subtitle={subtitle}
125
+ toolbar={toolbar}
126
+ loading={loading}
127
+ error={error}
128
+ isEmpty={isDataEmpty}
129
+ >
130
+ {!isDataEmpty && (
131
+ <BarChart
132
+ xAxisLabel={xAxisLabel}
133
+ yAxisLabel={yAxisLabel}
134
+ xAxis={[{ data: data?.xLabels || [] }]}
135
+ series={visibleSeries.map((series) => ({ data: series.data, label: series.label }))}
136
+ palette={visiblePalette}
137
+ />
138
+ )}
139
+ </ChartFrame>
140
+ );
141
+ ```
142
+
143
+ **Reference (working) pattern — `dev/entries.jsx:35-39`:**
144
+ ```jsx
145
+ function BarChartEntry() {
146
+ return (
147
+ <ChartFrame title="Monthly cases" subtitle="Standalone BarChart entry" minHeight={360}>
148
+ <BarChart {...barChartFixture} xAxisLabel="Month" yAxisLabel="Cases" height={320} />
149
+ </ChartFrame>
150
+ );
151
+ }
152
+ ```
153
+ Note `height={320}` on `BarChart` here is NOT `BarChart`'s own `minHeight`/`aspect` wrapper prop — it
154
+ lands in `BarChart`'s `...chartProps` rest spread (`BarChart.jsx:24,34`) and is forwarded straight to
155
+ `MuiBarChart`'s own native `height` prop, which MUI X-Charts uses as a fixed pixel height instead of
156
+ measuring the parent via `ResizeObserver`. This is the confirmed-working mechanism in this repo.
157
+
158
+ **`ChartFrame.jsx:86-99`** — the content `Box`: `sx={{ width:'100%', minHeight, aspectRatio: aspect,
159
+ display:'flex', alignItems:'center', justifyContent:'center' }}`. `alignItems:'center'` means a flex
160
+ child (here, `BarChart`'s own `Box`) is NOT stretched to fill this Box's height — it keeps its own
161
+ intrinsic height. Passing `minHeight` to `ChartFrame` sizes this outer Box correctly for the
162
+ loading/error/empty `Alert`/`CircularProgress` states (which have no fixed height of their own and
163
+ DO benefit from the flex-centered parent height), but does **not**, by itself, give `BarChart`'s
164
+ child `Box` any height — that must come from `BarChart` itself (its own `minHeight`/`aspect`, or the
165
+ raw `height` chartProp per the reference pattern above).
166
+
167
+ **`BarChart.jsx:15-44`** — full file, already shown above in this WO's WHY section — `minHeight`/
168
+ `aspect` are explicit named props (`:22-23`) applied to `BarChart`'s own wrapping `Box` (`:32`);
169
+ anything else lands in `...chartProps` (`:24`) and is spread onto `MuiBarChart` directly (`:34`),
170
+ which is how a raw `height` reaches the underlying MUI component.
171
+
172
+ **Test file: `tests/TimeSeriesChart.test.jsx`** — already mocks `@mui/x-charts/BarChart` via
173
+ `vi.hoisted` + `vi.mock` (`:9-10`) and every existing test reads `chartSpy.mock.calls.at(-1)[0]` to
174
+ assert exact props (e.g. the `series`/`colors` assertions at `:87-89,94-99`). Add the new test in the
175
+ same style, asserting `props.height` — see REQUIRED TESTS above for why an unmocked render test does
176
+ NOT work here.
177
+
178
+ ## TARGET REPO
179
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main`. Publish per the repo's release
180
+ flow (patch bump — bug fix, no interface change).
181
+
182
+ ## TEST SCOPE FOR THE GATE (orchestrator)
183
+ `tests/TimeSeriesChart.test.jsx`. Not the full suite.
184
+
185
+ ## MINI-HANDOVER (pastable)
186
+
187
+ > Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`).
188
+ > Work order: `work-orders/CHART-4.md` — read it fully, then follow the `orchestrate-codex` skill.
189
+ > **Codex workspace is out of credits this session — go straight to the direct-Claude fallback, do
190
+ > not attempt `codex exec` first.**
191
+ >
192
+ > Second bug in `TimeSeriesChart` (after CHART-3): the chart legend renders but the actual bars/axes
193
+ > never appear because neither `ChartFrame` nor `BarChart` ever receive a height — MUI X-Charts'
194
+ > responsive container measures a zero-height parent and draws nothing. `dev/entries.jsx`'s
195
+ > `BarChartEntry` is the proof this is fixable: `minHeight={360}` on `ChartFrame` + `height={320}`
196
+ > forwarded straight to `BarChart` (MUI's own native height prop, not the wrapper's `minHeight`/
197
+ > `aspect`). The required regression test must NOT mock `@mui/x-charts/BarChart` — every existing test
198
+ > in this component mocks it, which is exactly how two sizing/rendering bugs shipped undetected.
199
+
200
+ ## PROGRESS CONTRACT
201
+ Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
202
+ relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
203
+ stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
@@ -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>`.