@micha.bigler/ui-core-micha 2.26.4 → 2.28.2

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.
@@ -38,19 +38,15 @@ const SAMPLE_DATA = {
38
38
  function renderChart(props = {}) {
39
39
  const i18n = i18next.createInstance();
40
40
  i18n.init({ lng: 'en', resources, interpolation: { escapeValue: false } });
41
- return render(
41
+ const wrap = (chartProps) => (
42
42
  <ThemeProvider theme={createTheme()}>
43
43
  <I18nextProvider i18n={i18n}>
44
- <TimeSeriesChart
45
- title="Activity"
46
- xAxisLabel="Day"
47
- yAxisLabel="Count"
48
- data={SAMPLE_DATA}
49
- {...props}
50
- />
44
+ <TimeSeriesChart title="Activity" xAxisLabel="Day" yAxisLabel="Count" data={SAMPLE_DATA} {...chartProps} />
51
45
  </I18nextProvider>
52
- </ThemeProvider>,
46
+ </ThemeProvider>
53
47
  );
48
+ const view = render(wrap(props));
49
+ return { ...view, rerenderWith: (nextProps) => view.rerender(wrap(nextProps)) };
54
50
  }
55
51
 
56
52
  describe('TimeSeriesChart', () => {
@@ -128,4 +124,80 @@ describe('TimeSeriesChart', () => {
128
124
  renderChart({ loading: true });
129
125
  expect(screen.getByLabelText('Loading chart.')).toBeTruthy();
130
126
  });
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
+
145
+ // CHART-3 regression: visibleKeys must not be captured only at first mount.
146
+ // Reproduces the live bug — host mounts with empty data, fetches
147
+ // asynchronously, then rerenders with real data. Before the fix this stayed
148
+ // on ChartFrame's empty state forever even with real series present.
149
+ it('CHART-3: renders the chart once data arrives after an empty-data mount', () => {
150
+ const { rerenderWith } = renderChart({ data: { xLabels: [], series: [] } });
151
+ expect(screen.getByText('No data available.')).toBeTruthy();
152
+ expect(screen.queryByTestId('mui-bar-chart')).toBeNull();
153
+
154
+ rerenderWith({ data: SAMPLE_DATA });
155
+
156
+ expect(screen.queryByText('No data available.')).toBeNull();
157
+ const props = chartSpy.mock.calls.at(-1)[0];
158
+ expect(props.series).toHaveLength(2);
159
+ });
160
+
161
+ it('CHART-3: preserves a manual toggle-off across a data update that keeps the same key', () => {
162
+ const { rerenderWith } = renderChart({ data: SAMPLE_DATA });
163
+
164
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Users' }));
165
+ let props = chartSpy.mock.calls.at(-1)[0];
166
+ expect(props.series.map((series) => series.label)).toEqual(['Presence hours']);
167
+
168
+ rerenderWith({
169
+ data: {
170
+ xLabels: ['Mon', 'Tue', 'Wed', 'Thu'],
171
+ series: [
172
+ { key: 'users', label: 'Users', data: [1, 2, 3, 4] },
173
+ { key: 'presence', label: 'Presence hours', data: [4, 5, 6, 7] },
174
+ ],
175
+ },
176
+ });
177
+
178
+ props = chartSpy.mock.calls.at(-1)[0];
179
+ expect(props.series.map((series) => series.label)).toEqual(['Presence hours']);
180
+ });
181
+
182
+ it('CHART-3: a series key appearing later defaults to visible while an earlier toggle-off survives', () => {
183
+ const { rerenderWith } = renderChart({
184
+ data: { xLabels: ['Mon'], series: [{ key: 'users', label: 'Users', data: [1] }] },
185
+ });
186
+
187
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Users' }));
188
+ expect(screen.getByText('No data available.')).toBeTruthy();
189
+
190
+ rerenderWith({
191
+ data: {
192
+ xLabels: ['Mon'],
193
+ series: [
194
+ { key: 'users', label: 'Users', data: [1] },
195
+ { key: 'presence', label: 'Presence hours', data: [2] },
196
+ ],
197
+ },
198
+ });
199
+
200
+ const props = chartSpy.mock.calls.at(-1)[0];
201
+ expect(props.series.map((series) => series.label)).toEqual(['Presence hours']);
202
+ });
131
203
  });
@@ -0,0 +1,77 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { I18nextProvider } from 'react-i18next';
6
+ import i18next from 'i18next';
7
+
8
+ const authApi = vi.hoisted(() => ({
9
+ changePassword: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../src/auth/authApi', () => authApi);
13
+ vi.mock('../src/utils/authService', () => ({ startSocialLogin: vi.fn() }));
14
+
15
+ import { authTranslations } from '../src/i18n/authTranslations';
16
+ import { AuthContext } from '../src/auth/AuthContext';
17
+ import { SecurityComponent } from '../src/components/SecurityComponent';
18
+
19
+ const translations = Object.fromEntries(
20
+ Object.entries(authTranslations).map(([key, value]) => [key, value.en]),
21
+ );
22
+
23
+ const i18n = i18next.createInstance();
24
+ i18n.init({
25
+ lng: 'en',
26
+ fallbackLng: 'en',
27
+ resources: { en: { translation: translations } },
28
+ keySeparator: false,
29
+ nsSeparator: false,
30
+ interpolation: { escapeValue: false },
31
+ });
32
+
33
+ function renderSecurity() {
34
+ return render(
35
+ <I18nextProvider i18n={i18n}>
36
+ <AuthContext.Provider value={{ authMethods: { password_change: true } }}>
37
+ <SecurityComponent />
38
+ </AuthContext.Provider>
39
+ </I18nextProvider>,
40
+ );
41
+ }
42
+
43
+ function submitPasswordChange() {
44
+ fireEvent.change(screen.getByLabelText(/Current password/), { target: { value: 'currentpw' } });
45
+ fireEvent.change(screen.getByLabelText(/New password/), { target: { value: 'a-fine-new-password' } });
46
+ fireEvent.click(screen.getByRole('button', { name: 'Change password' }));
47
+ }
48
+
49
+ afterEach(() => {
50
+ cleanup();
51
+ authApi.changePassword.mockReset();
52
+ });
53
+
54
+ describe('SecurityComponent — backend auth error code rendering', () => {
55
+ it('shows translated human text, not the raw code, for a mapped error', async () => {
56
+ authApi.changePassword.mockRejectedValue({ code: 'enter_current_password' });
57
+ renderSecurity();
58
+
59
+ submitPasswordChange();
60
+
61
+ await waitFor(() => {
62
+ expect(screen.getByText('The current password is incorrect.')).toBeTruthy();
63
+ });
64
+ expect(screen.queryByText('enter_current_password')).toBeNull();
65
+ });
66
+
67
+ it('falls back to the generic failure message when the backend sends no code at all', async () => {
68
+ authApi.changePassword.mockRejectedValue(new Error('network failure'));
69
+ renderSecurity();
70
+
71
+ submitPasswordChange();
72
+
73
+ await waitFor(() => {
74
+ expect(screen.getByText('The password could not be changed.')).toBeTruthy();
75
+ });
76
+ });
77
+ });
@@ -0,0 +1,52 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest';
3
+ import { authTranslations } from '../src/i18n/authTranslations';
4
+
5
+ const REQUIRED_LOCALES = ['de', 'fr', 'en'];
6
+
7
+ const AUTH1_KEYS = [
8
+ 'Auth.PASSWORD_RULES_HINT',
9
+ 'Auth.PASSWORD_TOO_SHORT_LOCAL',
10
+ 'Auth.PASSWORD_NUMERIC_LOCAL',
11
+ ];
12
+
13
+ // Raw backend error codes (allauth-headless + Django password validators), keyed
14
+ // verbatim so the existing `t(err.code)` call sites resolve as a drop-in. See AUTH-2.
15
+ const AUTH2_CODE_KEYS = [
16
+ 'email_password_mismatch',
17
+ 'username_password_mismatch',
18
+ 'phone_password_mismatch',
19
+ 'too_many_login_attempts',
20
+ 'invalid_login',
21
+ 'account_inactive',
22
+ 'enter_current_password',
23
+ 'password_too_short',
24
+ 'password_too_common',
25
+ 'password_entirely_numeric',
26
+ 'password_too_similar',
27
+ 'incorrect_code',
28
+ ];
29
+
30
+ describe('authTranslations — AUTH-1 password-rules keys', () => {
31
+ it.each(AUTH1_KEYS)('%s has de/fr/en entries', (key) => {
32
+ expect(authTranslations[key]).toBeDefined();
33
+ for (const locale of REQUIRED_LOCALES) {
34
+ expect(authTranslations[key][locale]).toBeTruthy();
35
+ }
36
+ });
37
+ });
38
+
39
+ describe('authTranslations — AUTH-2 raw backend error codes', () => {
40
+ it.each(AUTH2_CODE_KEYS)('%s has de/fr/en entries', (code) => {
41
+ expect(authTranslations[code]).toBeDefined();
42
+ for (const locale of REQUIRED_LOCALES) {
43
+ expect(authTranslations[code][locale]).toBeTruthy();
44
+ }
45
+ });
46
+
47
+ it('does not shadow the existing generic Auth.*_FAILED fallbacks', () => {
48
+ expect(authTranslations['Auth.PASSWORD_CHANGE_FAILED']).toBeDefined();
49
+ expect(authTranslations['Auth.LOGIN_FAILED']).toBeDefined();
50
+ expect(authTranslations['Auth.MFA_AUTHENTICATE_FAILED']).toBeDefined();
51
+ });
52
+ });
@@ -0,0 +1,74 @@
1
+ # WORK ORDER AUTH-1 — Password forms: state the rules (proactive helperText + inline validator)
2
+
3
+ Tier 2 (touches `ui-core-micha` = shared-core). ucm-only, **zero per-app work** (consumers get it
4
+ on their next ucm pin-bump). Origin: Spesix field report 2026-08-06 — a first-time admin setting a
5
+ password from an invitation link could not tell what the password had to satisfy; the only way to
6
+ learn the rules was to submit and read the rejection.
7
+
8
+ ## A. Envelope (authoritative WHAT / WHY)
9
+
10
+ ### Goal
11
+ Tell the user the password rules **before** they submit, and give **instant frontend feedback** on
12
+ the two most common failures — so the invitation/first-contact screen no longer rejects people
13
+ without ever saying what it wants.
14
+
15
+ ### Scope — exactly the two forms that take a NEW password
16
+ - `src/components/PasswordSetForm.jsx` (invitation set + reset-confirm).
17
+ - `src/components/PasswordChangeForm.jsx`.
18
+ - **NOT** `PasswordResetRequestForm.jsx` — verified it takes **email only** (`type="email"`,
19
+ `onSubmit(email)`), no password field. The Spesix report listed it by mistake.
20
+
21
+ ### Deliverable 1 — proactive helperText (the rules, stated)
22
+ - Render `helperText` on the **new-password** `TextField` in both forms, default =
23
+ `t('Auth.PASSWORD_RULES_HINT')`.
24
+ - Add `Auth.PASSWORD_RULES_HINT` to `src/i18n/authTranslations.ts` in **de/fr/en**, stating the
25
+ baseline the template ships with. The baseline is authoritative from dcm
26
+ `settings_base.py:493-498` (the 4 Django defaults): **min 8 chars · not entirely numeric · not a
27
+ common password · not too similar to personal info.** Suggested de: „Mindestens 8 Zeichen, nicht
28
+ nur Zahlen, kein gängiges Passwort." (keep it concise; the similarity rule is niche — translator
29
+ finalises).
30
+ - Add an **optional prop** `passwordRulesHint` (string) to both forms — overrides the default so an
31
+ app that configures custom validators can pass matching text. This is the "Better" variant: cheap
32
+ effort, removes the only real objection (divergence from a customised backend).
33
+
34
+ ### Deliverable 2 — inline validator (signal before the backend rejects)
35
+ Before calling `onSubmit(...)`, validate the two rules that are cheap and faithful to replicate,
36
+ reusing the existing local-error mechanism (`localErrorKey` — the forms already do this for
37
+ empty/mismatch):
38
+ - **min length 8** → `Auth.PASSWORD_TOO_SHORT_LOCAL`,
39
+ - **not entirely numeric** → `Auth.PASSWORD_NUMERIC_LOCAL`.
40
+ Add both keys (de/fr/en). **Deliberately NOT** replicating CommonPassword (needs the ~20k list in
41
+ the browser) or UserAttributeSimilarity (needs user attributes the set-from-invite form doesn't
42
+ have) — those stay backend-enforced; the helperText already warns about them, and AUTH-2 will
43
+ translate their backend rejection. This is a **best-effort UX layer, not a security boundary** —
44
+ the backend stays authoritative (frontend permissive, backend decides).
45
+
46
+ ### Non-goals
47
+ - Don't change the backend or the validators. Don't touch `PasswordResetRequestForm`. Don't touch
48
+ the `onSubmit` contract or add API-error rendering (that's the deferred reactive error-prop, out
49
+ of scope). No new screen → **no prototype** (trivial addition per `frontend-design`).
50
+
51
+ ### Tier / rollout
52
+ Tier 2 (shared-core). ucm has **no staging** → publish-from-main: land on `main` → **minor version
53
+ bump** (2.26.4 → 2.27.0) → consumers adopt on their own pin-bump. **Spesix is pinned at 2.5.0**
54
+ (far behind) — it won't receive this until it does a separate, larger ucm pin-bump; that's an
55
+ app-side decision, not part of this WO.
56
+
57
+ ### Required tests
58
+ ucm component tests: helperText renders on both forms (and the `passwordRulesHint` prop overrides);
59
+ the inline validator blocks submit + shows the right message for a <8-char and an all-numeric
60
+ password; de/fr/en keys present for all 3 new strings.
61
+
62
+ ## B. Implementation map (seed)
63
+ - `src/components/PasswordSetForm.jsx` (new-password `TextField` ~L48) + `PasswordChangeForm.jsx` —
64
+ add `helperText` + the pre-`onSubmit` length/numeric check via `localErrorKey`; add the
65
+ `passwordRulesHint` prop.
66
+ - `src/i18n/authTranslations.ts` — 3 new keys × de/fr/en: `PASSWORD_RULES_HINT`,
67
+ `PASSWORD_TOO_SHORT_LOCAL`, `PASSWORD_NUMERIC_LOCAL`.
68
+ - `package.json` — minor bump at finalize (Orchestrator, on green).
69
+
70
+ **Review:** Tier 2 → independent `reviewer` + **`ui_reviewer`** (frontend diff, i18n coverage).
71
+ **Directive:** implementer leaves the diff; Orchestrator reviews + bumps the version + commits on
72
+ green. Codex out until 2026-08-06 → direct Claude.
73
+
74
+ **Companion:** AUTH-2 (translate the backend auth error codes — the *reactive* half, broader).
@@ -0,0 +1,74 @@
1
+ # WORK ORDER AUTH-2 — Translate backend auth error codes (stop leaking raw codes to users)
2
+
3
+ Tier 2 (touches `ui-core-micha` = shared-core). ucm-only, **zero per-app work** for the core (the
4
+ components already call `t(err.code)` — they just lack the translations). Found alongside the
5
+ AUTH-1 password report: ucm surfaces raw backend error **codes**, not human text.
6
+
7
+ ## A. Envelope (authoritative WHAT / WHY)
8
+
9
+ ### Problem (verified)
10
+ `src/auth/apiClient.jsx:107-124` extracts `err.code` (a machine code) from the backend/allauth
11
+ response. Components then render it as an i18n key: `setErrorKey(err.code || 'Auth.X_FAILED')` →
12
+ `t(errorKey)` (e.g. `SecurityComponent.jsx:60/71`, `ProfileComponent.jsx:143`,
13
+ `MfaLoginComponent.jsx:53`), or `t(err?.code || 'Auth.X_FAILED', 'fallback')`. **The i18n
14
+ catalogue has no entries for these codes** (grep for `email_password_mismatch` / `password_too_*`
15
+ = empty), so i18next returns the key unchanged and the user sees the literal
16
+ **`email_password_mismatch`** (or a generic fallback that hides the specific reason). This affects
17
+ **all** rendering auth screens — login, change-password, MFA, profile — not just passwords.
18
+
19
+ ### Goal
20
+ Render translated, human text for the known backend auth error codes, so the specific reason
21
+ (including the password rules AUTH-1's frontend validator can't replicate — common-password,
22
+ similarity) shows wherever a ucm component already renders an auth error. Because the components
23
+ already `t(err.code)`, this is achieved by **adding the translations** — no component change, no
24
+ app change.
25
+
26
+ ### Deliverable
27
+ 1. **Enumerate the backend auth error codes** actually emitted — from allauth-headless + Django
28
+ password-validator codes, confirmed against real responses (don't guess the full set). Known
29
+ starters: `email_password_mismatch`, `password_too_short`, `password_too_common`,
30
+ `password_entirely_numeric` (verify exact spelling), `password_too_similar`, and the current-
31
+ password / incorrect-password codes surfaced by the change-password flow.
32
+ 2. Add i18n entries for each, **de/fr/en**, in `src/i18n/authTranslations.ts`, **keyed by the raw
33
+ code string** (so the existing `t(err.code)` resolves as a drop-in — no component edit).
34
+ 3. Keep the generic `Auth.X_FAILED` fallback for unknown/unmapped codes (no regression).
35
+
36
+ ### Design note — keying (decision to confirm in planning)
37
+ Raw-code keys (`"password_too_short": {...}`) are the **drop-in** fix matching the existing
38
+ `t(err.code)` calls — pure ucm, no backend change. Cleaner but two-repo alternative: have dcm's
39
+ exception handler set a namespaced `i18nKey` per error (apiClient already reads `data.i18nKey`,
40
+ `apiClient.jsx:107`) → ucm keys become `Auth.ERROR.*`. Prefer the drop-in unless the operator wants
41
+ the namespaced contract.
42
+
43
+ ### Scope boundary (honest)
44
+ This fixes screens that **render** the error (login, change-password via `SecurityComponent`, MFA,
45
+ profile). It does **NOT** reach the invitation **`PasswordSetForm`** — that form **delegates**
46
+ `onSubmit` and renders no API error at all; its proactive coverage is AUTH-1 (helperText +
47
+ validator), and its full reactive display would need the deferred per-app error-prop.
48
+
49
+ ### Non-goals
50
+ No component/contract change (translations only, unless the namespaced-`i18nKey` option is chosen →
51
+ then a small dcm touch). No new error surfaces. Don't remove the generic fallbacks.
52
+
53
+ ### Tier / rollout
54
+ Tier 2 (shared-core). Publish-from-main → minor bump → consumers adopt on pin-bump. Zero per-app
55
+ for the core.
56
+
57
+ ### Required tests
58
+ `t(<code>)` resolves to translated text for each mapped code (de/fr/en); an unmapped code still
59
+ falls back to the generic `Auth.*_FAILED`; a representative render path (e.g. `SecurityComponent`
60
+ change-password error) shows human text, not the raw code.
61
+
62
+ ## B. Implementation map (seed)
63
+ - `src/i18n/authTranslations.ts` — add the enumerated code→text entries (de/fr/en).
64
+ - (verification only) `src/auth/apiClient.jsx:107-124` + the `setErrorKey(err.code…)` /
65
+ `t(err.code…)` call sites — confirm they resolve against the new entries; no edit expected.
66
+ - `package.json` — minor bump at finalize.
67
+ - (optional, if namespaced-`i18nKey` chosen) `django-core-micha` auth exception handler — set
68
+ `i18nKey` per error.
69
+
70
+ **Review:** Tier 2 → `reviewer` + **`ui_reviewer`** (i18n/translation coverage — the whole point).
71
+ **Directive:** implementer leaves the diff; Orchestrator reviews + bumps version + commits on
72
+ green. Codex out until 2026-08-06 → direct Claude.
73
+
74
+ **Companion:** AUTH-1 (the proactive helperText + inline validator — the other half).
@@ -0,0 +1,163 @@
1
+ # WORK ORDER CHART-3 (ui-core-micha) — fix TimeSeriesChart rendering nothing on async data load
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
+ Bug-fix WO on the landed CHART-2. Found live in jg-ferien's "Aktivität" screen (ACT-3 consumer): the
11
+ toolbar (range picker + series checkboxes) renders correctly, but the chart body never appears —
12
+ `ChartFrame`'s empty-state `Alert` shows permanently, even once real data has loaded.
13
+
14
+ ## TIER
15
+ Tier 2 — shared-core UI consumed by every app that adopts `TimeSeriesChart`.
16
+ Independent `reviewer` + `ui_reviewer` mandatory.
17
+
18
+ ## WHY
19
+
20
+ `TimeSeriesChart` initializes `visibleKeys` via a **lazy `useState` initializer** that reads
21
+ `seriesConfig` (derived from the `data` prop) only once, at first mount:
22
+
23
+ ```js
24
+ const [visibleKeys, setVisibleKeys] = useState(
25
+ () => new Set(seriesConfig.map((series) => series.key)),
26
+ );
27
+ ```
28
+
29
+ jg-ferien's `ActivitySection.jsx` mounts `TimeSeriesChart` with `data = { xLabels: [], series: [] }`
30
+ (its own initial `useState`), then fetches asynchronously in a `useEffect` and updates `data` only
31
+ once the API call resolves — the standard "mount empty, fill later" pattern. Because the lazy
32
+ initializer never re-runs on prop updates, `visibleKeys` stays a permanently empty `Set` even after
33
+ real series arrive. Downstream:
34
+
35
+ ```js
36
+ const visibleSeries = seriesConfig.filter((series) => visibleKeys.has(series.key)); // always []
37
+ const isDataEmpty = seriesConfig.length === 0 || visibleSeries.length === 0; // always true
38
+ ```
39
+
40
+ `isDataEmpty` is permanently `true`, so `ChartFrame` renders its empty-state `Alert` forever, no
41
+ matter how much data loads. The toolbar renders fine because the range picker and the series
42
+ checkboxes are driven directly off the live `seriesConfig`, not off `visibleKeys` — only the chart
43
+ body is gated on the stale set. This reproduces on every host that fetches asynchronously after
44
+ mount (the overwhelmingly common case), not just jg-ferien.
45
+
46
+ ## SCOPE
47
+
48
+ **Fix `visibleKeys` to track newly-appeared series without discarding a user's manual toggle.**
49
+
50
+ Required behaviour:
51
+ 1. When `data`/`seriesConfig` changes and introduces a series key not seen before, that key must
52
+ become visible by default (matches current first-mount behaviour, just not mount-only).
53
+ 2. A series key the user has manually toggled off must **stay off** across a range change that
54
+ still includes that key (e.g. don't reset all toggles to "all visible" on every `data` update —
55
+ that would silently undo the user's own toggle, a second bug of the opposite kind).
56
+ 3. A key that disappears from `seriesConfig` (e.g. a range change that drops a series) and later
57
+ reappears is treated as new for step 1 (simplest correct behaviour; do not attempt to remember
58
+ toggle state for absent keys unless it falls out naturally from the fix).
59
+
60
+ This is a "seen this key before, default it visible; otherwise respect the user's own toggle" sync,
61
+ not a full rewrite of the toggle model — `toggleSeries`, the checkbox rendering, and the empty-state
62
+ logic in SCOPE stay as they are; only the initialization/sync of `visibleKeys` changes.
63
+
64
+ ## NON-GOALS / DO NOT TOUCH
65
+ - Do not change `ChartFrame`, `BarChart`, `LineChart`, or the palette.
66
+ - Do not change the range-picker or the range-to-granularity mapping (`RANGE_OPTIONS`).
67
+ - Do not add an `onSeriesToggle` callback or any other new prop/API surface — this is a bug fix
68
+ within the existing component contract, not a scope extension.
69
+ - Do not touch `chartsTranslations.ts` — this WO is unrelated to i18n (a separate, already-diagnosed
70
+ issue in a different repo is being fixed independently).
71
+
72
+ ## RISKS
73
+ - The fix must not reintroduce the mount-time bug in a different form (e.g. resetting on every
74
+ render, or resetting on every `data` change including in-place mutations of the same series set —
75
+ that would silently discard the user's manual toggles on every range change, which is the
76
+ behaviour test 2 below exists to catch).
77
+ - Every host that has adopted `TimeSeriesChart` since CHART-2 shipped is affected by this fix
78
+ (currently only jg-ferien via ACT-3); this changes rendering behaviour for all of them, in the
79
+ correct direction (chart now actually renders).
80
+
81
+ ## REQUIRED TESTS TO WRITE
82
+ Narrow and behavioural, extending `tests/TimeSeriesChart.test.jsx`. Do NOT run the full suite.
83
+
84
+ 1. **Regression test for this exact bug**: mount `TimeSeriesChart` with empty `data`
85
+ (`{ xLabels: [], series: [] }`), then rerender with populated `data` (real `xLabels`/`series`) —
86
+ assert the chart body renders (not `ChartFrame`'s empty state), i.e. `isEmpty` becomes `false`
87
+ and `BarChart` receives the new series. This must fail against the current code and pass after
88
+ the fix — it is the non-vacuity check for this WO.
89
+ 2. **Toggle persistence across a data update**: toggle one series off, then rerender with updated
90
+ `data` that still contains that series key (e.g. a value change, same keys) — assert that series
91
+ is still filtered out (the user's manual toggle was not reset by the data update).
92
+ 3. **New series key appearing later defaults to visible**: mount with one series, rerender with a
93
+ second series key added — assert the new key is visible by default while any prior manual toggle
94
+ on the first key is preserved.
95
+
96
+ **Non-vacuity:** test 1 must fail against the current `visibleKeys` lazy-initializer code (verify
97
+ this before considering the fix complete) — it is reproducing the exact live bug from jg-ferien.
98
+
99
+ ## CONTEXT PACKAGE — verified current state (Orchestrator, Implementation map)
100
+
101
+ Work from this package; do not explore broadly from scratch — open only the named files to verify.
102
+
103
+ **File to change: `src/components/charts/TimeSeriesChart.jsx`.**
104
+
105
+ Current state (verified, full relevant slice):
106
+ ```js
107
+ const seriesConfig = data?.series || [];
108
+
109
+ const [range, setRange] = useState(defaultRange);
110
+ // Series identity (keys) is assumed stable for a given chart instance — the
111
+ // toggle set is initialized once from the first data the chart receives.
112
+ const [visibleKeys, setVisibleKeys] = useState(
113
+ () => new Set(seriesConfig.map((series) => series.key)),
114
+ );
115
+ ```
116
+ (`TimeSeriesChart.jsx:52-59`) — the comment above `visibleKeys` states the now-disproven assumption
117
+ ("series identity is stable for a given chart instance") that this WO corrects; update or remove it
118
+ to match the new behaviour.
119
+
120
+ `toggleSeries` (`:68-75`), `visibleSeries`/`visiblePalette`/`isDataEmpty` derivation (`:77-84`), and
121
+ the checkbox rendering (`:103-123`) are all downstream of `visibleKeys` and should not need changes
122
+ — only how `visibleKeys` is initialized/kept in sync with `seriesConfig` changes.
123
+
124
+ **Suggested shape (not prescriptive — implement whatever satisfies SCOPE correctly):** replace the
125
+ lazy `useState` with a `useEffect` keyed on the set of series keys in `seriesConfig`, that adds any
126
+ newly-seen key to `visibleKeys` (defaulting it visible) without touching keys already present in the
127
+ set — e.g. track previously-seen keys in a ref to detect "new" vs "toggled off", since a key
128
+ present in both old and new `seriesConfig` but absent from `visibleKeys` must stay absent (it was a
129
+ deliberate user toggle-off, not a fresh key). Keep `visibleKeys`'s existing `Set`-of-keys shape,
130
+ `toggleSeries`'s update pattern, and the checkbox/empty-state code untouched.
131
+
132
+ **Test file to extend: `tests/TimeSeriesChart.test.jsx`** — matches `tests/ChartFrame.test.jsx`/
133
+ `tests/BarChart.test.jsx` conventions: `// @vitest-environment jsdom` header, vitest +
134
+ `@testing-library/react`, `afterEach(cleanup)`, a minimal per-file `i18next.createInstance()` +
135
+ `I18nextProvider`, wrapped in `ThemeProvider theme={createTheme()}`. Use `rerender()` from
136
+ `@testing-library/react`'s `render()` return value to simulate the host updating `data` after mount
137
+ (this is the exact scenario CHART-2's own tests never covered — they only mounted once with
138
+ synchronous populated data).
139
+
140
+ ## TARGET REPO
141
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`. Branch `main`. Publish per the repo's release
142
+ flow (patch bump — bug fix, no interface change); consumer pin bumps are separate, out of scope.
143
+
144
+ ## TEST SCOPE FOR THE GATE (orchestrator)
145
+ `tests/TimeSeriesChart.test.jsx` only. Not the full suite.
146
+
147
+ ## MINI-HANDOVER (pastable)
148
+
149
+ > Repo: `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`).
150
+ > Work order: `work-orders/CHART-3.md` — read it fully, then follow the `orchestrate-codex` skill.
151
+ >
152
+ > Bug fix on CHART-2's `TimeSeriesChart`: `visibleKeys` is initialized once via a lazy `useState`
153
+ > from whatever `data` is at first mount, so any host that fetches asynchronously after mount (the
154
+ > normal case) ends up with a permanently-empty `visibleKeys` and the chart body never renders —
155
+ > `ChartFrame`'s empty state shows forever even once real data arrives. Fix the sync so newly-seen
156
+ > series keys default to visible while a user's manual toggle-off on an already-seen key survives a
157
+ > data update. Regression test 1 is the non-vacuity check — verify it fails against current code
158
+ > first.
159
+
160
+ ## PROGRESS CONTRACT
161
+ Emit `PLAN: <steps>` up front, then a single-line `PROGRESS: [<n>/<total>] <action>` before every
162
+ relevant action and `PROGRESS: [<n>/<total>] done` on completion, spaced so no gap exceeds ~2 min,
163
+ stdout unbuffered, and exactly one final `RESULT: DONE|BLOCKED <reason>`.