@micha.bigler/ui-core-micha 2.29.0 → 2.29.1
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/WORK_ORDERS.md
CHANGED
|
@@ -23,7 +23,7 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
23
23
|
|
|
24
24
|
| ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
|
|
25
25
|
|---|---|---|---|---|---|---|
|
|
26
|
-
| CHART-5 | Dual y-axis + always-visible x-axis ticks on `TimeSeriesChart` | Opt-in second y-axis via a new per-series `axis: 'secondary'` field (backward-compatible, off by default) with auto-detected integer-only tick labels per axis group; deterministic x-axis `tickLabelInterval` so bucket labels never all vanish under MUI's default collision-avoidance (the live symptom: zero x-axis labels with 24 hourly buckets). Reverses CHART-2's deliberate "no second y-axis" choice on fresh operator instruction; `DESIGN.md` #8 updated to record the scoped exception. Full spec: [work-orders/CHART-5.md](work-orders/CHART-5.md). | 2026-08-07 | done | 839e6c5, 84c37ac | Operator-requested after seeing the shipped chart live: y-axis showed decimal ticks (0.2, 0.4, ...) for an integer user-count series sharing an axis with fractional hours; x-axis showed no time labels at all. **Codex out of credits, known up front** — implemented directly (author-flip, independent review mandatory). Independent `reviewer` + `ui_reviewer` ran concurrently: **`reviewer` found R1 (P1, real bug)** — used `yAxisKey` (removed in MUI X-Charts v7, per the installed v8.29.2's own CHANGELOG and `useBarPlotData.mjs` source, which reads `series[i].yAxisId`) instead of `yAxisId`; the WO explicitly asked to verify this passthrough against real MUI behaviour rather than assume, and the first pass didn't — the dual-axis feature would have silently plotted every series on the primary axis in production despite all mocked tests passing (a mocked-prop test structurally cannot catch a wrong-field-name bug, since it only checks whatever the code itself sets). Fixed (`yAxisKey` → `yAxisId`, verified against `node_modules` source directly, not just the mock) and the pre-existing `tests/BarChart.test.jsx` "dual axis" case corrected too (R2 — it used the same wrong field name and was cited by the WO as precedent, giving false confidence). **`ui_reviewer` found U1 (P1, real bug)** — `tickLabelInterval`'s `index % step === 0` systematically dropped the LAST x-axis bucket unless it landed exactly on a step boundary (e.g. 24 labels/step 3 → indices 0..21, never 23), exactly the failure mode the WO's own SCOPE C explicitly warned to check for; fixed by force-including `index === lastIndex`, with a new `it.each([24,12,7,1])` regression test, confirmed non-vacuous by reverting the fix. **U2 (P2)**: `DESIGN.md` #8's "Never dual-axis" now contradicted the shipped code — updated to record dual-axis as a scoped, opt-in exception (CHART-5) rather than a general rule change. U3 (P3, fixed `MAX_X_TICKS` under-displays low-density ranges e.g. 12 monthly buckets showing only 6) and U4 (P3, no UI indication of which axis a toggle's series plots against) both accepted, not fixed — informational, no code change. Re-verified after fixes: 31/31 scoped vitest tests (`TimeSeriesChart.test.jsx` + `BarChart.test.jsx`) green, `tsc` build clean. Published: ucm 2.29.0 (minor — new optional per-series field + prop, additive, no breaking change).
|
|
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, 0dd3bac, f4e5586, 4a12a71 | 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). **Third bug found post-publish (2.29.1 patch)**: after jg-ferien adopted it (ACT-7) the operator reported live that the secondary axis was "obviously there but invisible" and x-axis labels were still missing. Investigated by adding a new DX-1 harness entry (`dev/entries.jsx` `TimeSeriesChartEntry`, previously missing — this component had NEVER been visually verified in a real browser through CHART-2/3/4/5) and reading the real rendered DOM directly. X-axis confirmed working (9 evenly-spread labels for 24 buckets) — the live report was very likely a stale PWA service-worker cache on jg-ferien's side, not a code defect. Y-axis confirmed broken: only 1 of 2 `yAxis` entries rendered in the DOM. Root cause traced to the installed MUI X-Charts source (`defaultizeAxis.js`): `defaultPosition = index === 0 ? 'left' : 'none'` — every yAxis array entry past the first silently renders nothing unless given an explicit `position`, a class of bug no mocked-`BarChart` test can catch (the mock never runs MUI's own defaulting logic). Fixed by setting `position: 'left'`/`'right'` explicitly on the primary/secondary entries; independent `reviewer` traced the fix against the same MUI source and confirmed no second gating layer exists. Live-reverified in the dev harness after the fix: both axes render with independent scales (primary `0,1,2`; secondary `0.0,0.5,1.0,1.5,2.0`). 32/32 tests green, build clean. Published: ucm 2.29.1 (patch). |
|
|
27
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. |
|
|
28
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. |
|
|
29
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). |
|
package/dev/entries.jsx
CHANGED
|
@@ -4,6 +4,7 @@ import { Box, Button, Stack, Typography } from '@mui/material';
|
|
|
4
4
|
import { AuthContext } from '../src/auth/AuthContext';
|
|
5
5
|
import { BarChart } from '../src/components/charts/BarChart';
|
|
6
6
|
import { ChartFrame } from '../src/components/charts/ChartFrame';
|
|
7
|
+
import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
|
|
7
8
|
import { NotificationBell } from '../src/notifications/NotificationBell';
|
|
8
9
|
import { NotificationsProvider } from '../src/notifications/NotificationsProvider';
|
|
9
10
|
import { useRealtime } from '../src/notifications/realtime';
|
|
@@ -40,6 +41,34 @@ function BarChartEntry() {
|
|
|
40
41
|
);
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
function TimeSeriesChartEntry() {
|
|
45
|
+
// Mirrors the live jg-ferien Aktivität bug: 24 hourly buckets (1-day
|
|
46
|
+
// range), distinct_users (integer) on primary, presence_hours
|
|
47
|
+
// (fractional) on a CHART-5 secondary axis.
|
|
48
|
+
const buckets = Array.from({ length: 24 }, (_, hour) => ({
|
|
49
|
+
hour,
|
|
50
|
+
distinct_users: [0, 1, 1, 2, 2, 1, 1, 1, 2, 1, 1, 1, 0, 0, 1, 1, 0, 1, 2, 1, 0, 1, 1, 1][hour],
|
|
51
|
+
presence_hours: [0, 0.02, 0.09, 1.8, 0.85, 0.55, 0.52, 0, 0.3, 0.1, 0.05, 0.02, 0, 0, 0.12, 0.08, 0, 0.02, 1.1, 0.4, 0, 0.1, 0.12, 0.22][hour],
|
|
52
|
+
}));
|
|
53
|
+
const data = {
|
|
54
|
+
xLabels: buckets.map((b) => `${String(b.hour).padStart(2, '0')}:00`),
|
|
55
|
+
series: [
|
|
56
|
+
{ key: 'distinct_users', label: 'Eindeutige Nutzer', data: buckets.map((b) => b.distinct_users) },
|
|
57
|
+
{ key: 'presence_hours', label: 'Anwesenheitszeit (Std.)', data: buckets.map((b) => b.presence_hours), axis: 'secondary' },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
return (
|
|
61
|
+
<TimeSeriesChart
|
|
62
|
+
title="Aktivität"
|
|
63
|
+
xAxisLabel="Zeit"
|
|
64
|
+
yAxisLabel="Anzahl"
|
|
65
|
+
secondaryYAxisLabel="Stunden"
|
|
66
|
+
data={data}
|
|
67
|
+
defaultRange="1d"
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
43
72
|
function RealtimeAdapterEntry() {
|
|
44
73
|
const { subscribe } = useRealtime();
|
|
45
74
|
const transport = useMockTransport();
|
|
@@ -157,6 +186,7 @@ function MessagingScopeConfigEntry() { return <MessagingProvider api={messagingH
|
|
|
157
186
|
export const entries = [
|
|
158
187
|
{ id: 'notification-bell', label: 'Notifications / Bell', Component: NotificationBellEntry },
|
|
159
188
|
{ id: 'bar-chart', label: 'Charts / BarChart', Component: BarChartEntry },
|
|
189
|
+
{ id: 'time-series-chart', label: 'Charts / TimeSeriesChart', Component: TimeSeriesChartEntry },
|
|
160
190
|
{ id: 'realtime-adapter', label: 'Transport / Realtime adapter', Component: RealtimeAdapterEntry },
|
|
161
191
|
{ id: 'messaging-provider', label: 'Messaging / Provider state', Component: MessagingProviderEntry },
|
|
162
192
|
{ id: 'messaging-conversation-list', label: 'Messaging / Conversation list', Component: ConversationListEntry },
|
|
@@ -143,8 +143,14 @@ export function TimeSeriesChart({ title, subtitle, xAxisLabel, yAxisLabel, secon
|
|
|
143
143
|
const secondaryVisibleSeries = visibleSeries.filter((series) => series.axis === 'secondary');
|
|
144
144
|
const yAxis = hasSecondaryAxis
|
|
145
145
|
? [
|
|
146
|
-
Object.assign({ id: 'primary', label: yAxisLabel
|
|
147
|
-
|
|
146
|
+
Object.assign({ id: 'primary', label: yAxisLabel,
|
|
147
|
+
// MUI defaultizes an axis with no explicit `position` to 'left' ONLY
|
|
148
|
+
// for the first entry in the array -- every subsequent entry
|
|
149
|
+
// defaults to 'none' (never drawn) unless given one explicitly.
|
|
150
|
+
// Both are set here, not just 'secondary', so this stays correct
|
|
151
|
+
// even if the array order ever changes.
|
|
152
|
+
position: 'left' }, (isIntegerAxis(primaryVisibleSeries) ? { valueFormatter: integerTickFormatter } : {})),
|
|
153
|
+
Object.assign({ id: 'secondary', label: secondaryYAxisLabel, position: 'right' }, (isIntegerAxis(secondaryVisibleSeries) ? { valueFormatter: integerTickFormatter } : {})),
|
|
148
154
|
]
|
|
149
155
|
: (isIntegerAxis(primaryVisibleSeries) ? [{ valueFormatter: integerTickFormatter }] : undefined);
|
|
150
156
|
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: {
|
package/package.json
CHANGED
|
@@ -177,11 +177,18 @@ export function TimeSeriesChart({
|
|
|
177
177
|
{
|
|
178
178
|
id: 'primary',
|
|
179
179
|
label: yAxisLabel,
|
|
180
|
+
// MUI defaultizes an axis with no explicit `position` to 'left' ONLY
|
|
181
|
+
// for the first entry in the array -- every subsequent entry
|
|
182
|
+
// defaults to 'none' (never drawn) unless given one explicitly.
|
|
183
|
+
// Both are set here, not just 'secondary', so this stays correct
|
|
184
|
+
// even if the array order ever changes.
|
|
185
|
+
position: 'left',
|
|
180
186
|
...(isIntegerAxis(primaryVisibleSeries) ? { valueFormatter: integerTickFormatter } : {}),
|
|
181
187
|
},
|
|
182
188
|
{
|
|
183
189
|
id: 'secondary',
|
|
184
190
|
label: secondaryYAxisLabel,
|
|
191
|
+
position: 'right',
|
|
185
192
|
...(isIntegerAxis(secondaryVisibleSeries) ? { valueFormatter: integerTickFormatter } : {}),
|
|
186
193
|
},
|
|
187
194
|
]
|
|
@@ -254,6 +254,32 @@ describe('TimeSeriesChart', () => {
|
|
|
254
254
|
expect(presenceSeries.yAxisId).toBe('secondary');
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
+
// CHART-5 (found live via the ui-core-micha dev harness -- MUI defaultizes
|
|
258
|
+
// every yAxis entry after the first to `position: 'none'` unless told
|
|
259
|
+
// otherwise, per node_modules/@mui/x-charts/internals/.../defaultizeAxis.js
|
|
260
|
+
// (`defaultPosition = index === 0 ? 'left' : 'none'`), so the secondary
|
|
261
|
+
// axis never actually rendered in a real browser despite chartSpy's mocked
|
|
262
|
+
// props looking correct -- a class of bug no mocked-BarChart test in this
|
|
263
|
+
// file can catch by construction (the mock doesn't run MUI's own
|
|
264
|
+
// defaulting logic). Left here as the one explicit prop-shape assertion
|
|
265
|
+
// that would have caught it, plus the note above for future readers.
|
|
266
|
+
it('CHART-5: dual axis entries explicitly set position (left/right), not just id/label', () => {
|
|
267
|
+
const data = {
|
|
268
|
+
xLabels: ['Mon', 'Tue', 'Wed'],
|
|
269
|
+
series: [
|
|
270
|
+
{ key: 'users', label: 'Users', data: [1, 2, 3] },
|
|
271
|
+
{ key: 'presence', label: 'Presence hours', data: [4.5, 5.2, 6.1], axis: 'secondary' },
|
|
272
|
+
],
|
|
273
|
+
};
|
|
274
|
+
renderChart({ data, secondaryYAxisLabel: 'Hours' });
|
|
275
|
+
|
|
276
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
277
|
+
const primaryAxis = props.yAxis.find((axis) => axis.id === 'primary');
|
|
278
|
+
const secondaryAxis = props.yAxis.find((axis) => axis.id === 'secondary');
|
|
279
|
+
expect(primaryAxis.position).toBe('left');
|
|
280
|
+
expect(secondaryAxis.position).toBe('right');
|
|
281
|
+
});
|
|
282
|
+
|
|
257
283
|
it('CHART-5: dual axis applies the integer formatter only to the axis that is actually all-integer', () => {
|
|
258
284
|
const data = {
|
|
259
285
|
xLabels: ['Mon', 'Tue', 'Wed'],
|