@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 +2 -1
- package/WORK_ORDERS.md +2 -0
- package/dist/components/charts/TimeSeriesChart.js +74 -6
- package/package.json +1 -1
- package/src/components/charts/TimeSeriesChart.jsx +97 -6
- package/tests/BarChart.test.jsx +1 -1
- package/tests/TimeSeriesChart.test.jsx +142 -0
- package/work-orders/CHART-4.md +203 -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,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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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: [{
|
|
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
|
@@ -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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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={[{
|
|
164
|
-
|
|
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>
|
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];
|
|
@@ -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>`.
|