@micha.bigler/ui-core-micha 2.25.2 → 2.26.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/WORK_ORDERS.md +1 -0
- package/dist/components/charts/TimeSeriesChart.js +67 -0
- package/dist/i18n/chartsTranslations.js +36 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/src/components/charts/TimeSeriesChart.jsx +149 -0
- package/src/i18n/chartsTranslations.ts +36 -0
- package/src/index.js +1 -0
- package/tests/TimeSeriesChart.test.jsx +131 -0
- package/work-orders/CHART-2.md +222 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -44,3 +44,4 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
44
44
|
| MSG-6i | Attachment tile 120px, poll spacing "luftiger", poll % denominator = respondents not sum(votes), wider Composer emoji picker | `AttachmentList.jsx` tiles are 64px. `PollCard.jsx` reads cramped per an operator screenshot. Poll percentages currently divide by `sum(vote_count)`, which for multi-select can exceed respondent count. Composer reuses `ReactionBar`s 5-emoji `QUICK_EMOJIS` for free-form text composition. | 2026-08-03 | done | a6ef578 | Envelope + Part B as-built: [work-orders/MSG-6i.md](work-orders/MSG-6i.md). Operator requirements round after MSG-6h shipped. Scope C deliberately reverses an explicit MSG-6g non-goal ("denominator = votes cast, not participant count") -- operator-confirmed correction, not an oversight. No backend change needed for scope C (union of existing `voters` arrays, client-side). **Implemented directly by the Orchestrator** (Codex unavailable). Also fixed a companion bug found live while verifying jg-ferien's MSG-18: `Thread.jsx`'s root Stack/scroll Box had no flex participation and a hardcoded `maxHeight:560`, so it never shrank to fit a short mobile viewport regardless of host sizing — added `flex:1, minHeight:0` to both (kept `maxHeight:560` as a no-op fallback outside a flex context). **Independent `reviewer` + `ui_reviewer`, run in parallel, 7 findings total, 5 fixed + 2 disclosed-not-fixed** (full detail in the WO's own record): headline finding, confirmed independently by both reviewers — scope D's emoji grid broke `Menu`'s built-in arrow-key roving focus (IconButtons nested under an intervening `role="group"` Box aren't direct children of `MenuList`, so keyboard nav went dead); fixed by switching `Menu`→`Popover` (a grid of buttons was never really a linear menu). `reviewer` also found the optimistic vote projection could desync `vote_count` from `voters` when `userId` is momentarily unknown (the new respondent-count denominator reads only `voters`) — fixed with a sentinel voter id so both always move together; and that the `Thread.jsx` companion fix had zero regression coverage — added a `getComputedStyle`-based test. `ui_reviewer` P3s: the emoji grid's `aria-label` duplicated the trigger button's own label (fixed with a dedicated `EMOJI_PICKER` key); a hardcoded `fontSize` on the emoji buttons (no theme token exists for this, left as-is, disclosed). Full suite (single-forked, an unrelated worker-pool timeout under the default parallel pool made this necessary) 249 → 256 (7 new), `tsc` clean. Publishing as npm 2.25.0. |
|
|
45
45
|
| NOTIF-14 | Push-preview toggle in NotificationSettings | Companion to `django-core-micha` MSG-13 (landed `39135e9`), which added a per-user `NotificationPreference.push_preview_opt_in` backend field (default on) that hides sender/text from a push body when off, already exposed on the `notifications/preferences/` endpoint — but nothing in ucm reads or writes it yet, so there is no UI path to reach it. | 2026-08-04 | done | 72e6697 | Envelope: [work-orders/NOTIF-14.md](work-orders/NOTIF-14.md). **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (twice) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` ran before commit. Adds a third `Switch` to `NotificationSettings.jsx`, mirroring the existing `email_opt_in` toggle shape exactly (not the more involved device-subscription push toggle). **Load-bearing subtlety from the envelope, confirmed correct by `reviewer`:** `push_preview_opt_in` defaults `True` on the backend (opposite of `push_opt_in`'s `False` default) — checked as `!== false`, not `Boolean(...)`, with a non-vacuous test (field absent from the mocked API response) proving it. UX call from the Expertenchat, implemented as specified: the toggle is disabled unless `push_opt_in` is already on. Two new i18n keys across all four languages (de/fr/en/sw). While writing the new tests, surfaced and fixed two latent, pre-existing gaps in `tests/NotificationSettings.test.jsx` that had simply never been exercised before (the file had only ever rendered the component twice total): no `afterEach(cleanup)` between tests (added, matches the convention already used in ~33 other test files in this repo), and the file's mocked `useTranslation` returned a fresh `t` function reference on every call, unlike real react-i18next (which memoizes it) — this re-triggered the component's `useEffect(..., [t])` on every re-render in tests and clobbered a toggle's own state update with a stale refetch; fixed by hoisting `t` to a module-level stable reference in the test file only, production code untouched. `reviewer` independently confirmed both are legitimate test-infra fixes, not a production defect. Gate: `tests/NotificationSettings.test.jsx` 2→8 passed; full suite (measured baseline via `git stash -u`) 256 passed/0 failed → after 262 passed/0 failed, 0 regressions; `tsc -p tsconfig.build.json --noEmit` clean. Tier 2 (ui-core-micha is shared-core, forces Tier 2 regardless of size per AGENTS.md) — `sec_reviewer` not needed (the content-in-push security question was already ruled in dcm MSG-13; this WO only wires an already-shipped, already-reviewed backend preference into the settings UI). |
|
|
46
46
|
| SHELL-1 | A shared user menu: avatar, profile, logout | Measured 2026-08-04: **seven render sites across two apps** for two adjacent actions — spesix has an account link (`:184`) plus a separate logout (`:224`), and the same pair again at `:269`/`:307` for mobile; survey_app has account nav (`:67`), logout desktop (`:152`) and logout mobile (`:90`); jg-ferien has an avatar linking to `/account` and **no header logout at all** (it sits at the bottom of `/more`). The duplication is worse *within* each app than between them. ucm exports nothing header-shaped today and has no avatar component, though it has owned `logout` in `auth/AuthContext.jsx:132` all along. | 2026-08-04 | done | 8ef8cbf (WO), bf4cdd1 (impl) | Envelope: [work-orders/SHELL-1.md](work-orders/SHELL-1.md). New `SHELL-*` prefix for shared application-shell surfaces, distinct from `MSG-*`/`NOTIF-*`. **Design settled with the operator against an interactive prototype — the boundary is not to be renegotiated:** ucm owns the avatar, the menu, Profil (`/account`, verified identical in all four apps) and Abmelden; the host passes only account-related entries, already permission-filtered and already translated; **the context/structure picker never enters the menu.** That last point is measured, not assumed — spesix's `activeSchoolId` is consumed by **13 files** (Approvals/Budgets/Claims/AppLayout) and jg's `selectedEventId` by **18**; it re-scopes the entire application's data, so it stays a permanently visible control of its own. Operator 2026-08-04: org/structure pickers are app-specific and already solved where they live; a second route to them here would be the very duplication this WO removes. **Expected integration for spesix and survey_app is `<UserMenu resolveLink={navigate} />` and nothing else** — only jg passes an entry. Two traps carried over from elsewhere in this estate: an earlier prototype had Benachrichtigungen as a ucm built-in, but **only jg** embeds `NotificationSettings` and `?tab=notifications` exists only there, so three apps would have got a menu entry pointing at a route they lack — verify every built-in against every app; and **labels arrive as finished strings, never i18n keys**, which is exactly why `django-core-micha` MSG-13 is currently broken. Stated cost: **Profil goes from one tap to two in every app** (paid consciously — the price of the consolidation). hram is **unverified** (no header file under `components/`) — do not claim four apps benefit until someone looks. Scope E (dcm MSG-13 preview `Switch`) had already landed separately as NOTIF-14 (`72e6697`) before this WO started, so A-D ship alongside it in the same release. Host header rewires (spesix/survey_app/jg/hram) are four separate small WOs, one per app, including removing jg's now-redundant local `UserAvatar` — not done here. **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (same failure as NOTIF-14) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit (WO's TIER line makes both mandatory). **Decisions stated per WO's explicit requirement** (both reviewers flagged these as initially undocumented, now recorded here): scope D icons — **no entry carries a leading or trailing icon** (uniform with none, avoids an unstated icon contract for host items); Profil/Abmelden i18n — **new `UserMenu.PROFILE`/`UserMenu.LOGOUT` keys** in a fresh `src/i18n/userMenuTranslations.ts`, not a reuse of `Account.TAB_PROFILE` (different UI surface, keeps the component independently consumable). New component `src/auth/UserMenu.jsx` mirrors `NotificationBell({ resolveLink })`'s exact anchor/Menu/onClose shape — no custom keyboard/focus-trap code, relies entirely on MUI `Menu`'s native Escape/click-outside/focus-return. Gate: `tests/UserMenu.test.jsx` (new, 6/6 passed, all WO-required cases incl. non-vacuous ordering + logout-stub assertions) + affected-areas set `tests/AuthContext.test.jsx` + `tests/NotificationSettings.test.jsx` (untouched by this diff, included per WO's test-scope note since scope E is in the same release) → 18/18 passed, 0 regressions; `tsc -p tsconfig.build.json --noEmit` clean. Both reviewers' only findings were the now-resolved documentation gaps above — no code-correctness findings from either. |
|
|
47
|
+
| CHART-2 | Time-series chart preset with range picker and series toggles | Extends CHART-1, whose `ChartFrame` already ships a **toolbar slot that no consumer uses yet**. Operator request 2026-08-04 from jg's activity chart, generalised: every app charting a time series wants a duration picker and per-series toggles, and `django-core-micha` ACT-1 will make activity data available to all of them. jg's `ActivitySection.jsx:9` currently imports `@mui/x-charts` directly and hand-rolls its own Paper/loading/error/empty. | 2026-08-04 | done | a277c82, abf11b0 | Envelope: [work-orders/CHART-2.md](work-orders/CHART-2.md). **Presentational only — the host fetches**; the component takes data plus callbacks and calls no endpoint. **The half most likely to be dropped: the range picker must emit a GRANULARITY alongside the window** — new `TimeSeriesChart` component emits `onRangeChange(rangeKey, granularity)` per the fixed table (1d→hour, 1w→4hour, 1m→day, 1y→month), test 1 asserts the exact emitted payload, non-vacuity confirmed by manually removing the granularity arg and observing the test fail. **Series toggles implemented as `Checkbox`+`FormControlLabel`** (this repo's established multi-choice pattern, matching `PollCard.jsx`); range picker as `RadioGroup`/`Radio`/`FormControlLabel` (same repo's deliberate single-choice convention, chosen over `ToggleButtonGroup` for which this repo has zero precedent). **Second y-axis: explicitly NOT added** — series toggles are the chosen fix (stated in the component's own JSDoc), not silently absent. **Color/identity stability**: each series' color is keyed to its original (unfiltered) index, not its position after toggling others off — verified independently by `reviewer`, prevents a toggle from shifting a *different* series' color. **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit (mandatory per WO tier), **both clean, no findings**. `ui_reviewer` confirmed accessible labels (series name text, not color alone), native MUI keyboard nav untouched, all 4 languages present for new i18n keys, host-supplied series labels never passed through `t()`. Gate: 9/9 new `TimeSeriesChart` tests + 25/25 across the full chart-component test set (`ChartFrame`/`BarChart`/`LineChart`/`chartsPalette`/`TimeSeriesChart`), `tsc --noEmit` clean. `ChartFrame`/`BarChart`/`LineChart`/`palette.js` confirmed untouched. |
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { Box, Checkbox, FormControlLabel, FormGroup, Radio, RadioGroup, } from '@mui/material';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { ChartFrame } from './ChartFrame';
|
|
6
|
+
import { BarChart } from './BarChart';
|
|
7
|
+
import { useNeutralChartPalette } from './palette';
|
|
8
|
+
// Fixed platform-wide range -> granularity mapping (operator decision, CHART-2 /
|
|
9
|
+
// dcm ACT-1) — the resolution follows the window and is never an independent
|
|
10
|
+
// caller-supplied control. Every preset lands between a dozen and fifty points.
|
|
11
|
+
const RANGE_OPTIONS = [
|
|
12
|
+
{ key: '1d', granularity: 'hour', labelKey: 'TimeSeriesChart.RANGE_1_DAY' },
|
|
13
|
+
{ key: '1w', granularity: '4hour', labelKey: 'TimeSeriesChart.RANGE_1_WEEK' },
|
|
14
|
+
{ key: '1m', granularity: 'day', labelKey: 'TimeSeriesChart.RANGE_1_MONTH' },
|
|
15
|
+
{ key: '1y', granularity: 'month', labelKey: 'TimeSeriesChart.RANGE_1_YEAR' },
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* A time-series preset on ChartFrame + BarChart: a range picker (emits the
|
|
19
|
+
* selected range AND its mapped granularity to the host) and series toggle
|
|
20
|
+
* checkboxes, in the frame's toolbar slot.
|
|
21
|
+
*
|
|
22
|
+
* Presentational only — takes `data` and callbacks, never fetches. The host
|
|
23
|
+
* owns fetching; this component owns which range is selected and which
|
|
24
|
+
* series are visible, telling the host what changed via `onRangeChange`.
|
|
25
|
+
*
|
|
26
|
+
* No second y-axis: the series toggles (not a second axis) are the chosen
|
|
27
|
+
* fix for two series sharing one y-axis at very different scales — toggling
|
|
28
|
+
* one off rescales the axis and makes the other readable. This was a
|
|
29
|
+
* deliberate choice, not an oversight (CHART-2 scope B).
|
|
30
|
+
*/
|
|
31
|
+
export function TimeSeriesChart({ title, subtitle, xAxisLabel, yAxisLabel, data, loading = false, error = false, onRangeChange, defaultRange = '1w', }) {
|
|
32
|
+
const { t } = useTranslation();
|
|
33
|
+
const palette = useNeutralChartPalette().categorical;
|
|
34
|
+
const seriesConfig = (data === null || data === void 0 ? void 0 : data.series) || [];
|
|
35
|
+
const [range, setRange] = useState(defaultRange);
|
|
36
|
+
// Series identity (keys) is assumed stable for a given chart instance — the
|
|
37
|
+
// toggle set is initialized once from the first data the chart receives.
|
|
38
|
+
const [visibleKeys, setVisibleKeys] = useState(() => new Set(seriesConfig.map((series) => series.key)));
|
|
39
|
+
const handleRangeChange = (event) => {
|
|
40
|
+
const nextRange = event.target.value;
|
|
41
|
+
setRange(nextRange);
|
|
42
|
+
const option = RANGE_OPTIONS.find((candidate) => candidate.key === nextRange);
|
|
43
|
+
onRangeChange === null || onRangeChange === void 0 ? void 0 : onRangeChange(nextRange, option === null || option === void 0 ? void 0 : option.granularity);
|
|
44
|
+
};
|
|
45
|
+
const toggleSeries = (key) => {
|
|
46
|
+
setVisibleKeys((previous) => {
|
|
47
|
+
const next = new Set(previous);
|
|
48
|
+
if (next.has(key))
|
|
49
|
+
next.delete(key);
|
|
50
|
+
else
|
|
51
|
+
next.add(key);
|
|
52
|
+
return next;
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const visibleSeries = seriesConfig.filter((series) => visibleKeys.has(series.key));
|
|
56
|
+
// Preserve each series' color by its ORIGINAL index, not the filtered
|
|
57
|
+
// position — otherwise toggling one series off shifts every remaining
|
|
58
|
+
// series' color, breaking the swatch-to-series identity the toggles rely on.
|
|
59
|
+
const visiblePalette = visibleSeries.map((series) => palette[seriesConfig.indexOf(series) % palette.length]);
|
|
60
|
+
const isDataEmpty = seriesConfig.length === 0 || visibleSeries.length === 0;
|
|
61
|
+
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: {
|
|
62
|
+
color: palette[index % palette.length],
|
|
63
|
+
'&.Mui-checked': { color: palette[index % palette.length] },
|
|
64
|
+
} })), label: series.label }, series.key))) }))] }));
|
|
65
|
+
return (_jsx(ChartFrame, { title: title, subtitle: subtitle, toolbar: toolbar, loading: loading, error: error, isEmpty: isDataEmpty, children: !isDataEmpty && (_jsx(BarChart, { xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xAxis: [{ data: (data === null || data === void 0 ? void 0 : data.xLabels) || [] }], series: visibleSeries.map((series) => ({ data: series.data, label: series.label })), palette: visiblePalette })) }));
|
|
66
|
+
}
|
|
67
|
+
export default TimeSeriesChart;
|
|
@@ -35,4 +35,40 @@ export const chartsTranslations = {
|
|
|
35
35
|
en: 'The chart could not be exported.',
|
|
36
36
|
sw: 'Chati haikuweza kuhamishwa.',
|
|
37
37
|
},
|
|
38
|
+
'TimeSeriesChart.RANGE_LABEL': {
|
|
39
|
+
de: 'Zeitraum',
|
|
40
|
+
fr: 'Période',
|
|
41
|
+
en: 'Range',
|
|
42
|
+
sw: 'Kipindi',
|
|
43
|
+
},
|
|
44
|
+
'TimeSeriesChart.RANGE_1_DAY': {
|
|
45
|
+
de: '1 Tag',
|
|
46
|
+
fr: '1 jour',
|
|
47
|
+
en: '1 day',
|
|
48
|
+
sw: 'Siku 1',
|
|
49
|
+
},
|
|
50
|
+
'TimeSeriesChart.RANGE_1_WEEK': {
|
|
51
|
+
de: '1 Woche',
|
|
52
|
+
fr: '1 semaine',
|
|
53
|
+
en: '1 week',
|
|
54
|
+
sw: 'Wiki 1',
|
|
55
|
+
},
|
|
56
|
+
'TimeSeriesChart.RANGE_1_MONTH': {
|
|
57
|
+
de: '1 Monat',
|
|
58
|
+
fr: '1 mois',
|
|
59
|
+
en: '1 month',
|
|
60
|
+
sw: 'Mwezi 1',
|
|
61
|
+
},
|
|
62
|
+
'TimeSeriesChart.RANGE_1_YEAR': {
|
|
63
|
+
de: '1 Jahr',
|
|
64
|
+
fr: '1 an',
|
|
65
|
+
en: '1 year',
|
|
66
|
+
sw: 'Mwaka 1',
|
|
67
|
+
},
|
|
68
|
+
'TimeSeriesChart.SERIES_LABEL': {
|
|
69
|
+
de: 'Serien',
|
|
70
|
+
fr: 'Séries',
|
|
71
|
+
en: 'Series',
|
|
72
|
+
sw: 'Mfululizo',
|
|
73
|
+
},
|
|
38
74
|
};
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,7 @@ export { QrSignupManager } from './components/QrSignupManager';
|
|
|
32
32
|
export { ChartFrame } from './components/charts/ChartFrame';
|
|
33
33
|
export { BarChart } from './components/charts/BarChart';
|
|
34
34
|
export { LineChart } from './components/charts/LineChart';
|
|
35
|
+
export { TimeSeriesChart } from './components/charts/TimeSeriesChart';
|
|
35
36
|
export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
|
|
36
37
|
export { formatPercentage, formatCompact, formatRatio, createChartFormatters, useChartFormatters, } from './components/charts/formatters';
|
|
37
38
|
// --- 7. Translations ---
|
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Checkbox,
|
|
5
|
+
FormControlLabel,
|
|
6
|
+
FormGroup,
|
|
7
|
+
Radio,
|
|
8
|
+
RadioGroup,
|
|
9
|
+
} from '@mui/material';
|
|
10
|
+
import { useTranslation } from 'react-i18next';
|
|
11
|
+
import { ChartFrame } from './ChartFrame';
|
|
12
|
+
import { BarChart } from './BarChart';
|
|
13
|
+
import { useNeutralChartPalette } from './palette';
|
|
14
|
+
|
|
15
|
+
// Fixed platform-wide range -> granularity mapping (operator decision, CHART-2 /
|
|
16
|
+
// dcm ACT-1) — the resolution follows the window and is never an independent
|
|
17
|
+
// caller-supplied control. Every preset lands between a dozen and fifty points.
|
|
18
|
+
const RANGE_OPTIONS = [
|
|
19
|
+
{ key: '1d', granularity: 'hour', labelKey: 'TimeSeriesChart.RANGE_1_DAY' },
|
|
20
|
+
{ key: '1w', granularity: '4hour', labelKey: 'TimeSeriesChart.RANGE_1_WEEK' },
|
|
21
|
+
{ key: '1m', granularity: 'day', labelKey: 'TimeSeriesChart.RANGE_1_MONTH' },
|
|
22
|
+
{ key: '1y', granularity: 'month', labelKey: 'TimeSeriesChart.RANGE_1_YEAR' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A time-series preset on ChartFrame + BarChart: a range picker (emits the
|
|
27
|
+
* selected range AND its mapped granularity to the host) and series toggle
|
|
28
|
+
* checkboxes, in the frame's toolbar slot.
|
|
29
|
+
*
|
|
30
|
+
* Presentational only — takes `data` and callbacks, never fetches. The host
|
|
31
|
+
* owns fetching; this component owns which range is selected and which
|
|
32
|
+
* series are visible, telling the host what changed via `onRangeChange`.
|
|
33
|
+
*
|
|
34
|
+
* No second y-axis: the series toggles (not a second axis) are the chosen
|
|
35
|
+
* fix for two series sharing one y-axis at very different scales — toggling
|
|
36
|
+
* one off rescales the axis and makes the other readable. This was a
|
|
37
|
+
* deliberate choice, not an oversight (CHART-2 scope B).
|
|
38
|
+
*/
|
|
39
|
+
export function TimeSeriesChart({
|
|
40
|
+
title,
|
|
41
|
+
subtitle,
|
|
42
|
+
xAxisLabel,
|
|
43
|
+
yAxisLabel,
|
|
44
|
+
data,
|
|
45
|
+
loading = false,
|
|
46
|
+
error = false,
|
|
47
|
+
onRangeChange,
|
|
48
|
+
defaultRange = '1w',
|
|
49
|
+
}) {
|
|
50
|
+
const { t } = useTranslation();
|
|
51
|
+
const palette = useNeutralChartPalette().categorical;
|
|
52
|
+
const seriesConfig = data?.series || [];
|
|
53
|
+
|
|
54
|
+
const [range, setRange] = useState(defaultRange);
|
|
55
|
+
// Series identity (keys) is assumed stable for a given chart instance — the
|
|
56
|
+
// toggle set is initialized once from the first data the chart receives.
|
|
57
|
+
const [visibleKeys, setVisibleKeys] = useState(
|
|
58
|
+
() => new Set(seriesConfig.map((series) => series.key)),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const handleRangeChange = (event) => {
|
|
62
|
+
const nextRange = event.target.value;
|
|
63
|
+
setRange(nextRange);
|
|
64
|
+
const option = RANGE_OPTIONS.find((candidate) => candidate.key === nextRange);
|
|
65
|
+
onRangeChange?.(nextRange, option?.granularity);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const toggleSeries = (key) => {
|
|
69
|
+
setVisibleKeys((previous) => {
|
|
70
|
+
const next = new Set(previous);
|
|
71
|
+
if (next.has(key)) next.delete(key);
|
|
72
|
+
else next.add(key);
|
|
73
|
+
return next;
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const visibleSeries = seriesConfig.filter((series) => visibleKeys.has(series.key));
|
|
78
|
+
// Preserve each series' color by its ORIGINAL index, not the filtered
|
|
79
|
+
// position — otherwise toggling one series off shifts every remaining
|
|
80
|
+
// series' color, breaking the swatch-to-series identity the toggles rely on.
|
|
81
|
+
const visiblePalette = visibleSeries.map(
|
|
82
|
+
(series) => palette[seriesConfig.indexOf(series) % palette.length],
|
|
83
|
+
);
|
|
84
|
+
const isDataEmpty = seriesConfig.length === 0 || visibleSeries.length === 0;
|
|
85
|
+
|
|
86
|
+
const toolbar = (
|
|
87
|
+
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, alignItems: 'center' }}>
|
|
88
|
+
<RadioGroup
|
|
89
|
+
row
|
|
90
|
+
value={range}
|
|
91
|
+
onChange={handleRangeChange}
|
|
92
|
+
aria-label={t('TimeSeriesChart.RANGE_LABEL')}
|
|
93
|
+
>
|
|
94
|
+
{RANGE_OPTIONS.map((option) => (
|
|
95
|
+
<FormControlLabel
|
|
96
|
+
key={option.key}
|
|
97
|
+
value={option.key}
|
|
98
|
+
control={<Radio size="small" />}
|
|
99
|
+
label={t(option.labelKey)}
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
</RadioGroup>
|
|
103
|
+
{seriesConfig.length > 0 && (
|
|
104
|
+
<FormGroup row aria-label={t('TimeSeriesChart.SERIES_LABEL')}>
|
|
105
|
+
{seriesConfig.map((series, index) => (
|
|
106
|
+
<FormControlLabel
|
|
107
|
+
key={series.key}
|
|
108
|
+
control={(
|
|
109
|
+
<Checkbox
|
|
110
|
+
size="small"
|
|
111
|
+
checked={visibleKeys.has(series.key)}
|
|
112
|
+
onChange={() => toggleSeries(series.key)}
|
|
113
|
+
sx={{
|
|
114
|
+
color: palette[index % palette.length],
|
|
115
|
+
'&.Mui-checked': { color: palette[index % palette.length] },
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
label={series.label}
|
|
120
|
+
/>
|
|
121
|
+
))}
|
|
122
|
+
</FormGroup>
|
|
123
|
+
)}
|
|
124
|
+
</Box>
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<ChartFrame
|
|
129
|
+
title={title}
|
|
130
|
+
subtitle={subtitle}
|
|
131
|
+
toolbar={toolbar}
|
|
132
|
+
loading={loading}
|
|
133
|
+
error={error}
|
|
134
|
+
isEmpty={isDataEmpty}
|
|
135
|
+
>
|
|
136
|
+
{!isDataEmpty && (
|
|
137
|
+
<BarChart
|
|
138
|
+
xAxisLabel={xAxisLabel}
|
|
139
|
+
yAxisLabel={yAxisLabel}
|
|
140
|
+
xAxis={[{ data: data?.xLabels || [] }]}
|
|
141
|
+
series={visibleSeries.map((series) => ({ data: series.data, label: series.label }))}
|
|
142
|
+
palette={visiblePalette}
|
|
143
|
+
/>
|
|
144
|
+
)}
|
|
145
|
+
</ChartFrame>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default TimeSeriesChart;
|
|
@@ -35,4 +35,40 @@ export const chartsTranslations = {
|
|
|
35
35
|
en: 'The chart could not be exported.',
|
|
36
36
|
sw: 'Chati haikuweza kuhamishwa.',
|
|
37
37
|
},
|
|
38
|
+
'TimeSeriesChart.RANGE_LABEL': {
|
|
39
|
+
de: 'Zeitraum',
|
|
40
|
+
fr: 'Période',
|
|
41
|
+
en: 'Range',
|
|
42
|
+
sw: 'Kipindi',
|
|
43
|
+
},
|
|
44
|
+
'TimeSeriesChart.RANGE_1_DAY': {
|
|
45
|
+
de: '1 Tag',
|
|
46
|
+
fr: '1 jour',
|
|
47
|
+
en: '1 day',
|
|
48
|
+
sw: 'Siku 1',
|
|
49
|
+
},
|
|
50
|
+
'TimeSeriesChart.RANGE_1_WEEK': {
|
|
51
|
+
de: '1 Woche',
|
|
52
|
+
fr: '1 semaine',
|
|
53
|
+
en: '1 week',
|
|
54
|
+
sw: 'Wiki 1',
|
|
55
|
+
},
|
|
56
|
+
'TimeSeriesChart.RANGE_1_MONTH': {
|
|
57
|
+
de: '1 Monat',
|
|
58
|
+
fr: '1 mois',
|
|
59
|
+
en: '1 month',
|
|
60
|
+
sw: 'Mwezi 1',
|
|
61
|
+
},
|
|
62
|
+
'TimeSeriesChart.RANGE_1_YEAR': {
|
|
63
|
+
de: '1 Jahr',
|
|
64
|
+
fr: '1 an',
|
|
65
|
+
en: '1 year',
|
|
66
|
+
sw: 'Mwaka 1',
|
|
67
|
+
},
|
|
68
|
+
'TimeSeriesChart.SERIES_LABEL': {
|
|
69
|
+
de: 'Serien',
|
|
70
|
+
fr: 'Séries',
|
|
71
|
+
en: 'Series',
|
|
72
|
+
sw: 'Mfululizo',
|
|
73
|
+
},
|
|
38
74
|
};
|
package/src/index.js
CHANGED
|
@@ -44,6 +44,7 @@ export { QrSignupManager } from './components/QrSignupManager';
|
|
|
44
44
|
export { ChartFrame } from './components/charts/ChartFrame';
|
|
45
45
|
export { BarChart } from './components/charts/BarChart';
|
|
46
46
|
export { LineChart } from './components/charts/LineChart';
|
|
47
|
+
export { TimeSeriesChart } from './components/charts/TimeSeriesChart';
|
|
47
48
|
export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
|
|
48
49
|
export {
|
|
49
50
|
formatPercentage,
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import i18next from 'i18next';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
6
|
+
import { I18nextProvider } from 'react-i18next';
|
|
7
|
+
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
8
|
+
|
|
9
|
+
const chartSpy = vi.hoisted(() => vi.fn((props) => <div data-testid="mui-bar-chart" data-props={JSON.stringify(props)} />));
|
|
10
|
+
vi.mock('@mui/x-charts/BarChart', () => ({ BarChart: chartSpy }));
|
|
11
|
+
|
|
12
|
+
import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
|
|
13
|
+
|
|
14
|
+
const resources = {
|
|
15
|
+
en: {
|
|
16
|
+
translation: {
|
|
17
|
+
'TimeSeriesChart.RANGE_LABEL': 'Range',
|
|
18
|
+
'TimeSeriesChart.RANGE_1_DAY': '1 day',
|
|
19
|
+
'TimeSeriesChart.RANGE_1_WEEK': '1 week',
|
|
20
|
+
'TimeSeriesChart.RANGE_1_MONTH': '1 month',
|
|
21
|
+
'TimeSeriesChart.RANGE_1_YEAR': '1 year',
|
|
22
|
+
'TimeSeriesChart.SERIES_LABEL': 'Series',
|
|
23
|
+
'ChartFrame.EMPTY_DEFAULT': 'No data available.',
|
|
24
|
+
'ChartFrame.LOADING': 'Loading chart.',
|
|
25
|
+
'ChartFrame.ERROR_DEFAULT': 'The chart could not be loaded.',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SAMPLE_DATA = {
|
|
31
|
+
xLabels: ['Mon', 'Tue', 'Wed'],
|
|
32
|
+
series: [
|
|
33
|
+
{ key: 'users', label: 'Users', data: [1, 2, 3] },
|
|
34
|
+
{ key: 'presence', label: 'Presence hours', data: [4, 5, 6] },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function renderChart(props = {}) {
|
|
39
|
+
const i18n = i18next.createInstance();
|
|
40
|
+
i18n.init({ lng: 'en', resources, interpolation: { escapeValue: false } });
|
|
41
|
+
return render(
|
|
42
|
+
<ThemeProvider theme={createTheme()}>
|
|
43
|
+
<I18nextProvider i18n={i18n}>
|
|
44
|
+
<TimeSeriesChart
|
|
45
|
+
title="Activity"
|
|
46
|
+
xAxisLabel="Day"
|
|
47
|
+
yAxisLabel="Count"
|
|
48
|
+
data={SAMPLE_DATA}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
</I18nextProvider>
|
|
52
|
+
</ThemeProvider>,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('TimeSeriesChart', () => {
|
|
57
|
+
afterEach(cleanup);
|
|
58
|
+
|
|
59
|
+
// Required test 1: selecting a range emits both the range AND the mapped
|
|
60
|
+
// granularity — asserts the emitted payload, not merely that the callback fired.
|
|
61
|
+
it('emits both the range and its mapped granularity when a preset is selected', () => {
|
|
62
|
+
const onRangeChange = vi.fn();
|
|
63
|
+
renderChart({ onRangeChange });
|
|
64
|
+
|
|
65
|
+
fireEvent.click(screen.getByRole('radio', { name: '1 day' }));
|
|
66
|
+
|
|
67
|
+
expect(onRangeChange).toHaveBeenCalledWith('1d', 'hour');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it.each([
|
|
71
|
+
['1 week', '1w', '4hour'],
|
|
72
|
+
['1 month', '1m', 'day'],
|
|
73
|
+
['1 year', '1y', 'month'],
|
|
74
|
+
])('maps %s to range=%s granularity=%s', (label, rangeKey, granularity) => {
|
|
75
|
+
const onRangeChange = vi.fn();
|
|
76
|
+
// defaultRange is '1w' — start from a different preset so every clicked
|
|
77
|
+
// option (including '1 week' itself) represents a real selection change.
|
|
78
|
+
renderChart({ onRangeChange, defaultRange: '1d' });
|
|
79
|
+
|
|
80
|
+
fireEvent.click(screen.getByRole('radio', { name: label }));
|
|
81
|
+
|
|
82
|
+
expect(onRangeChange).toHaveBeenCalledWith(rangeKey, granularity);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Required test 2: toggling a series off removes it, leaves the others.
|
|
86
|
+
it('toggling a series off removes it from the rendered chart and leaves the other', () => {
|
|
87
|
+
renderChart();
|
|
88
|
+
|
|
89
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Users' }));
|
|
90
|
+
|
|
91
|
+
const props = chartSpy.mock.calls.at(-1)[0];
|
|
92
|
+
expect(props.series).toHaveLength(1);
|
|
93
|
+
expect(props.series[0].label).toBe('Presence hours');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('preserves the remaining series color when the other is toggled off', () => {
|
|
97
|
+
renderChart();
|
|
98
|
+
const initialProps = chartSpy.mock.calls.at(-1)[0];
|
|
99
|
+
const presenceColorBefore = initialProps.colors[1];
|
|
100
|
+
|
|
101
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Users' }));
|
|
102
|
+
|
|
103
|
+
const afterProps = chartSpy.mock.calls.at(-1)[0];
|
|
104
|
+
expect(afterProps.colors[0]).toBe(presenceColorBefore);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Required test 3: toggling all series off renders a stable empty chart.
|
|
108
|
+
it('toggling all series off renders a stable empty chart, no crash, no spinner', () => {
|
|
109
|
+
renderChart();
|
|
110
|
+
|
|
111
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Users' }));
|
|
112
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Presence hours' }));
|
|
113
|
+
|
|
114
|
+
expect(screen.getByText('No data available.')).toBeTruthy();
|
|
115
|
+
expect(screen.queryByLabelText('Loading chart.')).toBeNull();
|
|
116
|
+
expect(screen.queryByTestId('mui-bar-chart')).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Required test 4: renders ChartFrame's empty state for empty data, exactly once.
|
|
120
|
+
it("renders ChartFrame's empty state for empty data, exactly once", () => {
|
|
121
|
+
renderChart({ data: { xLabels: [], series: [] } });
|
|
122
|
+
|
|
123
|
+
expect(screen.getAllByText('No data available.')).toHaveLength(1);
|
|
124
|
+
expect(screen.queryByTestId('mui-bar-chart')).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('range picker and series toggles pass through loading and error to ChartFrame', () => {
|
|
128
|
+
renderChart({ loading: true });
|
|
129
|
+
expect(screen.getByLabelText('Loading chart.')).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# WORK ORDER CHART-2 (ui-core-micha) — a time-series chart preset with range picker and series toggles
|
|
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
|
+
|
|
10
|
+
Feature-level WO (Expertenchat envelope). Extends CHART-1. Operator request 2026-08-04 from jg's event
|
|
11
|
+
activity chart; the same controls are wanted wherever a time series is charted, and `django-core-micha`
|
|
12
|
+
ACT-1 makes activity data available to every app.
|
|
13
|
+
|
|
14
|
+
## TIER
|
|
15
|
+
Tier 2 — shared-core UI consumed by every app. Independent `reviewer` + `ui_reviewer` mandatory.
|
|
16
|
+
|
|
17
|
+
## WHY
|
|
18
|
+
|
|
19
|
+
CHART-1 already ships `ChartFrame` (Paper + title + **toolbar slot** + loading / error / empty +
|
|
20
|
+
responsive container + SVG/PNG export + a11y), a themed `BarChart`, `getNeutralChartPalette` and
|
|
21
|
+
`chartsTranslations`. The frame's toolbar slot exists for exactly this and is currently unused by any
|
|
22
|
+
consumer.
|
|
23
|
+
|
|
24
|
+
jg's `ActivitySection.jsx` bypasses the kit entirely (`import { BarChart } from '@mui/x-charts/BarChart'`
|
|
25
|
+
at `:9`) and hand-rolls its own `Paper`, loading, error and empty states. Rather than jg building a
|
|
26
|
+
range picker and series checkboxes locally, they belong in the kit — every app charting a time series
|
|
27
|
+
wants both.
|
|
28
|
+
|
|
29
|
+
## SCOPE
|
|
30
|
+
|
|
31
|
+
**A. A range picker for the toolbar slot.** Presets **1 day / 1 week / 1 month / 1 year** (operator,
|
|
32
|
+
2026-08-04), emitting the selected range to the host. The host owns fetching; the picker owns the choice.
|
|
33
|
+
|
|
34
|
+
The range must also carry the **granularity** the consumer should request — dcm ACT-1's query endpoint
|
|
35
|
+
aggregates server-side, and a year of 4-hour buckets is ~2190 points, which no chart can render
|
|
36
|
+
usefully. A picker that changes the window but not the resolution reproduces exactly the
|
|
37
|
+
unreadable-density problem this WO exists to fix.
|
|
38
|
+
|
|
39
|
+
**The range-to-resolution mapping (operator, 2026-08-04) — implement exactly this:**
|
|
40
|
+
|
|
41
|
+
| Range | Resolution | Points |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| 1 day | 1 hour | 24 |
|
|
44
|
+
| 1 week | 4 hours | ~42 |
|
|
45
|
+
| 1 month | 1 day | ~30 |
|
|
46
|
+
| 1 year | 1 month | 12 |
|
|
47
|
+
|
|
48
|
+
Every preset lands between a dozen and fifty points, which is what keeps the chart readable at any
|
|
49
|
+
range. **The resolution follows the window; it is not an independent control** and must not be exposed
|
|
50
|
+
as one.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
**B. Series toggles for the toolbar slot.** Check/uncheck each series independently.
|
|
54
|
+
|
|
55
|
+
**These are not cosmetic.** In jg's chart two series share one y-axis with different units — user counts
|
|
56
|
+
reach 28 while presence-hours sit near zero, so the second series is invisible. Toggling one off
|
|
57
|
+
rescales the axis and makes the other readable. Whether to offer a second y-axis instead is a real
|
|
58
|
+
alternative: **if you do not add one, say so deliberately** rather than leaving it unconsidered.
|
|
59
|
+
|
|
60
|
+
Deselecting every series must leave a stable empty chart — not a crash, not a permanent spinner.
|
|
61
|
+
|
|
62
|
+
**C. Compose them into a preset** on `ChartFrame` + the existing `BarChart`, so a consumer gets frame,
|
|
63
|
+
chart, picker and toggles from one component and passes data plus callbacks.
|
|
64
|
+
|
|
65
|
+
**Presentational only — the host fetches.** Take `data`, the current range and series selection, and
|
|
66
|
+
callbacks; do not call any endpoint. If all four hosts later write the identical fetch against ACT-1's
|
|
67
|
+
endpoint, *that* is the signal to add an adapter — and note the inverse trap from this estate's recent
|
|
68
|
+
history: building the shared thing before two identical consumers exist is how you generalise against a
|
|
69
|
+
single case and get it wrong.
|
|
70
|
+
|
|
71
|
+
## CONTEXT PACKAGE — verified current state (Orchestrator, Implementation map)
|
|
72
|
+
|
|
73
|
+
Work from this package; do not explore broadly from scratch — open only the named files to verify.
|
|
74
|
+
|
|
75
|
+
**New file: `src/components/charts/TimeSeriesChart.jsx`** (generic name — non-goal bars anything
|
|
76
|
+
app-specific; this is not called `ActivityChart`). Export it in `src/index.js` right after the
|
|
77
|
+
existing `LineChart` export (`:46`): `export { TimeSeriesChart } from './components/charts/TimeSeriesChart';`.
|
|
78
|
+
|
|
79
|
+
**`ChartFrame`'s toolbar slot (`src/components/charts/ChartFrame.jsx`)** — the prop is named
|
|
80
|
+
**`toolbar`** (not `toolbarSlot`), destructured `:20`, rendered `:74` inside a `Box` next to the
|
|
81
|
+
title/subtitle, `justifyContent: 'space-between'`. Full prop list `:17-34`:
|
|
82
|
+
`title, subtitle, toolbar, loading, error, isEmpty, emptyMessage, minHeight, aspect, exportOptions,
|
|
83
|
+
onExportSvg, onExportPng, ariaLabel, children, variant, sx`. State precedence `:58-65`:
|
|
84
|
+
`loading → error → isEmpty → children` — `TimeSeriesChart` passes its own `loading`/`error`/`isEmpty`
|
|
85
|
+
straight through, it does not reimplement these.
|
|
86
|
+
|
|
87
|
+
**`BarChart` (`src/components/charts/BarChart.jsx`, 44 lines)** — required props `xAxisLabel`/
|
|
88
|
+
`yAxisLabel` (throws if absent, `:27-29`). `series` is a bare array of MUI X-Charts series objects,
|
|
89
|
+
e.g. `{ data: [...], label, ... }` — no ucm wrapping. Colors: `palette || neutralPalette.categorical`
|
|
90
|
+
(from `useNeutralChartPalette()`), assigned **positionally by index** — series `i` gets
|
|
91
|
+
`categorical[i]`. `hideLegend={series.length <= 1}`.
|
|
92
|
+
|
|
93
|
+
**Data contract for `TimeSeriesChart`** (design decision, state it): the host has already fetched
|
|
94
|
+
and shaped the series (dcm ACT-1's query endpoint returns rolled-up buckets; this component knows
|
|
95
|
+
nothing about that origin). Prop shape:
|
|
96
|
+
```jsx
|
|
97
|
+
<TimeSeriesChart
|
|
98
|
+
title
|
|
99
|
+
xAxisLabel
|
|
100
|
+
yAxisLabel
|
|
101
|
+
data={{ xLabels: [...], series: [{ key: 'users', label: 'Users', data: [...] }, { key: 'presence', label: 'Presence', data: [...] }] }}
|
|
102
|
+
loading
|
|
103
|
+
error
|
|
104
|
+
onRangeChange={(rangeKey, granularity) => {...}} // rangeKey: '1d'|'1w'|'1m'|'1y'
|
|
105
|
+
/>
|
|
106
|
+
```
|
|
107
|
+
Range selection is **self-managed internal state** (default a sensible preset, e.g. `'1w'`), emitting
|
|
108
|
+
`onRangeChange(rangeKey, granularity)` on every change so the host can refetch `data` for the new
|
|
109
|
+
window — the host owns fetching (non-goal), this component owns only which range is currently
|
|
110
|
+
selected and telling the host what changed. Series-toggle visibility is **also self-managed internal
|
|
111
|
+
state** (`Set` of visible series keys, all visible by default) — deselecting is a pure client-side
|
|
112
|
+
filter over already-fetched `data`, no host round-trip needed, so no `onSeriesToggle` callback is
|
|
113
|
+
required (optional to add if a reviewer disagrees, but the WO's own framing — "these are not
|
|
114
|
+
cosmetic... toggling rescales the axis" — describes a display-only operation).
|
|
115
|
+
|
|
116
|
+
**Range-to-granularity mapping — implement exactly, no per-app override:**
|
|
117
|
+
`'1d' → 'hour'`, `'1w' → '4hour'`, `'1m' → 'day'`, `'1y' → 'month'` (match whatever granularity
|
|
118
|
+
vocabulary dcm ACT-1's query endpoint expects once it publishes — since CHART-2 does not fetch, the
|
|
119
|
+
exact string values are this component's own contract, name them clearly, e.g. `'hour' | '4hour' |
|
|
120
|
+
'day' | 'month'`, and state the chosen vocabulary in the completion note so ACT-1/ACT-3 can align).
|
|
121
|
+
|
|
122
|
+
**Range picker widget — decide and state (per WO's own "if you consider X, say so"):** this repo has
|
|
123
|
+
**zero existing `ToggleButtonGroup` usage** (grepped, confirmed) but an established, deliberate
|
|
124
|
+
`RadioGroup` + `FormControlLabel` convention for single-choice controls
|
|
125
|
+
(`src/messaging/PollCard.jsx:1-140`, code comment there explains why: native arrow-key nav + grouped
|
|
126
|
+
screen-reader announcement — "a bare ungrouped `Radio` per option... loses both"). Prefer
|
|
127
|
+
`RadioGroup`/`FormControlLabel` to match this repo's convention unless a segmented-button visual is
|
|
128
|
+
judged clearly better for a 4-preset picker — either is acceptable, but state which and why.
|
|
129
|
+
|
|
130
|
+
**Series toggles — `Checkbox` + `FormControlLabel` per series**, matching this repo's established
|
|
131
|
+
multi-choice pattern (`PollCard.jsx`'s multi-choice mode; also `Composer.jsx:181`,
|
|
132
|
+
`ProfileComponent.jsx:232,244`). **Accessibility requirement, verified as a real gap**: colors from
|
|
133
|
+
`getNeutralChartPalette` are assigned positionally with no name-to-color map and no shape
|
|
134
|
+
differentiation (`src/components/charts/palette.js`, full file) — so each checkbox's `label` MUST be
|
|
135
|
+
the series' text name (`FormControlLabel` gives this for free), not a bare colored swatch. Optionally
|
|
136
|
+
also render a small colored swatch next to the label (matching the series' `BarChart` color at the
|
|
137
|
+
same index) as a secondary, non-exclusive cue.
|
|
138
|
+
|
|
139
|
+
**Second y-axis — decision required by the WO, make it explicitly:** `BarChart` already supports a
|
|
140
|
+
dual-axis shape (`yAxisKey: 'secondary'` per series, paired with `yAxis={[{id:'primary'},
|
|
141
|
+
{id:'secondary'}]}`), so it is technically available — but **do not use it here**. Scope B's series
|
|
142
|
+
toggles are the WO's chosen, required fix for the shared-axis-different-units problem (jg's user
|
|
143
|
+
counts vs. presence-hours); a second y-axis is the *alternative* the WO explicitly did not choose.
|
|
144
|
+
State this decision plainly in the completion note — do not leave it to be inferred from absence.
|
|
145
|
+
|
|
146
|
+
**Empty state — all series deselected:** `ChartFrame`'s `isEmpty` prop drives its own empty-state
|
|
147
|
+
`Alert` (`:58-65`). When every series is toggled off, `TimeSeriesChart` must NOT pass an empty
|
|
148
|
+
`series` array to `BarChart` (which would likely throw or render a blank axis) — instead set
|
|
149
|
+
`isEmpty` truthy on `ChartFrame` and skip rendering `BarChart` entirely for that render, matching
|
|
150
|
+
required test 3 ("stable empty chart: no crash, no spinner").
|
|
151
|
+
|
|
152
|
+
**i18n — extend `src/i18n/chartsTranslations.ts` in place** (single `chartsTranslations` object,
|
|
153
|
+
`<ComponentName>.<CONSTANT_KEY>` convention, all four `de/fr/en/sw`, `:1-38` for the existing shape
|
|
154
|
+
to match). New keys needed: `TimeSeriesChart.RANGE_1_DAY`, `RANGE_1_WEEK`, `RANGE_1_MONTH`,
|
|
155
|
+
`RANGE_1_YEAR`, and a label for the series-toggle group (e.g. `TimeSeriesChart.SERIES_LABEL`).
|
|
156
|
+
|
|
157
|
+
**Tests — new `tests/TimeSeriesChart.test.jsx`**, matching `tests/ChartFrame.test.jsx`/
|
|
158
|
+
`tests/BarChart.test.jsx` conventions (both fully precedent): `// @vitest-environment jsdom` header,
|
|
159
|
+
vitest + `@testing-library/react`, `afterEach(cleanup)`, a minimal per-file `i18next.createInstance()`
|
|
160
|
+
+ `I18nextProvider` (not the app's real i18n config), wrapped in `ThemeProvider theme={createTheme()}`.
|
|
161
|
+
For test 1 (range emits granularity) and the series-filter tests, mock `@mui/x-charts/BarChart` itself
|
|
162
|
+
via `vi.hoisted` + `vi.mock` to inspect exact props passed through (`BarChart.test.jsx`'s own pattern,
|
|
163
|
+
`chartSpy.mock.calls[0][0]`) — do not mock ucm's own `BarChart`/`ChartFrame`, the point is testing the
|
|
164
|
+
real composition.
|
|
165
|
+
|
|
166
|
+
## NON-GOALS / DO NOT TOUCH
|
|
167
|
+
- Do not fetch data, and do not import anything app-specific.
|
|
168
|
+
- Do not change `ChartFrame`, `BarChart`, `LineChart` or the palette beyond what composing requires. If
|
|
169
|
+
`ChartFrame` lacks something, **extend it deliberately and say so** — do not work around it in the
|
|
170
|
+
preset.
|
|
171
|
+
- Do not add a charting dependency; MUI X-Charts is already the kit's basis.
|
|
172
|
+
- No persistence of the picker or toggle state — that is the host's decision.
|
|
173
|
+
|
|
174
|
+
## RISKS
|
|
175
|
+
- The toolbar slot has no consumer today, so this is its first real use. Expect to find gaps in it;
|
|
176
|
+
fixing them is in scope, working around them is not.
|
|
177
|
+
- Accessibility: the picker and checkboxes are controls, not decoration — labels, keyboard operation and
|
|
178
|
+
state must be right. Series colour alone must not be the only way to tell which toggle maps to which
|
|
179
|
+
series. `ui_reviewer` should check this specifically.
|
|
180
|
+
- Every host inherits the preset; a visible behaviour change beyond jg.
|
|
181
|
+
|
|
182
|
+
## REQUIRED TESTS TO WRITE
|
|
183
|
+
Narrow and behavioural. Do NOT run the full suite.
|
|
184
|
+
|
|
185
|
+
1. Selecting a range emits both the range **and** the mapped granularity to the callback — assert the
|
|
186
|
+
granularity, which is the half most likely to be dropped.
|
|
187
|
+
2. Toggling a series off removes it from the rendered chart and leaves the others.
|
|
188
|
+
3. Toggling **all** series off renders a stable empty chart: no crash, no spinner.
|
|
189
|
+
4. The preset renders `ChartFrame`'s empty state for empty data, exactly once.
|
|
190
|
+
|
|
191
|
+
**Non-vacuity:** test 1 must fail if only the range is emitted. Assert the emitted payload, not that a
|
|
192
|
+
callback fired.
|
|
193
|
+
|
|
194
|
+
## TEST SCOPE FOR THE GATE (orchestrator)
|
|
195
|
+
The chart component tests. Not the full suite.
|
|
196
|
+
|
|
197
|
+
## TARGET REPO
|
|
198
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main` (or `develop` if present). Publish per
|
|
199
|
+
the repo's release flow; consumer pin bumps are separate.
|
|
200
|
+
|
|
201
|
+
## MINI-HANDOVER (pastable)
|
|
202
|
+
|
|
203
|
+
> Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`, or `develop` if present).
|
|
204
|
+
> Work order: `work-orders/CHART-2.md` — read it fully, then follow the `orchestrate-codex` skill.
|
|
205
|
+
>
|
|
206
|
+
> Extends CHART-1, whose `ChartFrame` already has an unused **toolbar slot** — that is where the range
|
|
207
|
+
> picker and series toggles go. **Presentational only: the host fetches, this component takes data and
|
|
208
|
+
> callbacks.**
|
|
209
|
+
>
|
|
210
|
+
> The one thing most likely to be half-done: the range picker must emit a **granularity** alongside the
|
|
211
|
+
> window. dcm ACT-1 aggregates server-side, and a 1-year range at 4-hour buckets is ~2190 points — a
|
|
212
|
+
> picker that widens the window without coarsening the resolution reproduces the unreadable chart this
|
|
213
|
+
> WO exists to fix. Test 1 asserts the granularity for that reason.
|
|
214
|
+
>
|
|
215
|
+
> The series toggles are a real fix, not a preference: jg's two series share one y-axis with different
|
|
216
|
+
> units, which is why one of them is invisible today. If you consider a second y-axis instead, decide
|
|
217
|
+
> deliberately and say so.
|
|
218
|
+
|
|
219
|
+
## PROGRESS CONTRACT
|
|
220
|
+
Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
|
|
221
|
+
relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
|
|
222
|
+
stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.
|