From 4e4766d54ebda62b88479ffd76aee74faa6456b4 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 21 Sep 2026 07:30:17 +0200 Subject: [PATCH] Update todo by human --- ...ber-drop-redundant-preschool-field-plan.md | 154 ++++++++++++++++++ todo.md | 4 +- 2 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 new-features/fix-number-drop-redundant-preschool-field-plan.md diff --git a/new-features/fix-number-drop-redundant-preschool-field-plan.md b/new-features/fix-number-drop-redundant-preschool-field-plan.md new file mode 100644 index 0000000..57432f5 --- /dev/null +++ b/new-features/fix-number-drop-redundant-preschool-field-plan.md @@ -0,0 +1,154 @@ +# Fix number-input clearing UX + drop the redundant preschool estimate field + +## Context + +Two problems in Settings, both reported by the user: + +1. **Clearing a number input traps you at `0`.** The per-year "Planned days" input + (`Settings.tsx:186-192`) keeps its draft as a `number`, and `Number('') === 0`, so + blanking the box writes a literal `0` to the server and snaps the display to `0`. + Typing `5` then produces `05`. The same hazard exists in `VacationDaysTable` and + `PeriodPopover`. `VacationDaysInput` has the mirror bug: it silently rejects a + non-numeric value, so the field can't be blanked to retype at all. + +2. **Two preschool controls look duplicated.** "Preschool planning days per year" + (the child-level estimate, `Child.preschoolPlanningDaysPerYear`) sits directly above + the "Planning days per year" heading for the per-year rows. They are not duplicates — + the estimate seeds each year row and is the allocator's fallback for years with no + saved row (`backend/app/services/allocation_service.py:545-573`, `:2985-3001`) — but + the UI gives no hint of that, so it reads as dead weight. + +**Decisions taken:** remove the estimate *input* from the UI while keeping the backend +field (default `6`) as the seed/fallback; extract one shared number input and roll it out +to every numeric field that still has the `Number('') === 0` hazard. + +**Out of scope (flagged, not fixed):** `backend/app/services/tips_service.py:579` passes +the *global* `settings.rules.preschool_planning_days_per_year` into +`preschool_buffer_needed` instead of the child's own value, so a customised per-child +value is ignored by that tip. Separate bug, separate change. + +## 1. New shared component: `frontend/src/components/inputs/NumberField.tsx` + +Lift the working pattern already used by the "Days taken" input (`Settings.tsx:199-217`). + +```tsx +export interface NumberFieldProps { + value: number + onChange: (value: number) => void + label?: string + min?: number + max?: number + id?: string + className?: string + inputClassName?: string + disabled?: boolean + placeholder?: string + 'data-testid'?: string + 'aria-invalid'?: boolean + 'aria-describedby'?: string +} +``` + +Behaviour contract: + +- Holds an internal **string** draft. `''` is a legal transient state. +- `onChange` fires only when the draft parses to a finite number — **never on empty**, so + the previously saved value survives while the box is blank. +- No clamping while typing (clamping mid-keystroke is what makes small `max` values + unusable). Clamp to `[min, max]` on blur only, and commit the clamped value. +- On blur with an empty or unparseable draft: revert the draft to the current `value` + prop; do not call `onChange`. +- Sync the draft from the `value` prop only while the input is **not focused** (a ref + flag set on focus/blur), so a debounced server round-trip can't overwrite live typing. +- Renders `type="number" inputMode="numeric"` with `min`/`max` attributes and the existing + `field` / `field__control` BEM classes; `label` renders as `field__label` when given. + No hardcoded `id`/`data-testid` — both are props (the bug that makes `VacationDaysInput` + non-reuse-safe today). + +Co-located test `NumberField.test.tsx` covering: clear-then-retype yields `5` not `05`; +empty draft fires no `onChange`; blur-empty restores the prior value; blur clamps to +`min`/`max`; external `value` change while focused does not clobber the draft. + +## 2. Remove the child-level estimate input + +`frontend/src/pages/Settings.tsx` + +- Delete the `VacationDaysInput` block (`:562-571`) and its import (`:21`). +- Delete the `preschoolPlanningDays` state (`:252`), its prefill in the `childDetails` + effect (`:285`), and `preschoolPlanningDaysPerYear` from the `updateChildDetails` call in + `handleSaveFamily` (`:366`). The field is optional in `ChildUpdate` + (`backend/app/models/child.py:40`), so omitting it leaves the stored value untouched. +- Pass `defaultPlannedDays={childDetails?.preschoolPlanningDaysPerYear ?? 6}` to + `PreschoolPlanningYearRow` (`:583`) instead of the removed state. +- Move the `settings.preschoolHelp` paragraph (`:563`) into the per-year block, under the + heading, and drop the now-redundant `settings.preschoolPerYearHelp` line or merge the two + into one sentence. +- Retitle the section so it names the subject: `settings.preschoolPerYearTitle` → + `"Preschool planning days"` / `"Förskolans planeringsdagar"`. + +`frontend/src/i18n/{en,sv}/settings.ts` — remove `settings.preschoolPlanningDaysLabel` +(`:83` in both), reword `settings.preschoolHelp` (`:82`) so it reads as the intro to the +year rows, and update `settings.preschoolPerYearTitle` (`:126`). + +Delete `frontend/src/components/inputs/VacationDaysInput.tsx` and +`VacationDaysInput.test.tsx` — `Settings.tsx:564` was the only usage. + +## 3. Roll `NumberField` out + +| File | Inputs | +|---|---| +| `frontend/src/pages/Settings.tsx:181-195` | per-year **Planned days** (the reported bug) | +| `frontend/src/pages/Settings.tsx:199-217` | per-year **Days taken** — keep its `null` = "not recorded" sentinel; it needs `value: number \| null` handling, so either give `NumberField` an optional `nullable` mode or leave this one as-is and note why | +| `frontend/src/components/vacation-days/VacationDaysTable.tsx:174-183, 188-197` | `totalDays`, `savedDaysIn` | +| `frontend/src/components/calendar/PeriodPopover.tsx:458-470` | `lastYear` (min 2000 / max 2200 — blanking currently shows `0`) | + +`frontend/src/components/modals/ParentBalanceForm.tsx` already handles empty correctly via +`parseField` (`:134`, `value={x ?? ''}`) — **leave it alone**, or migrate it only if +`NumberField` gains the nullable mode above. Do not regress its `null` semantics. + +Existing `data-testid`s (`preschool-planned-{year}`, `vacation-total-days-{year}`, +`period-last-year-input`, …) must be preserved so current tests keep targeting the same +elements. + +## 4. Tests to update + +- `frontend/src/pages/Settings.test.tsx:416-437` — drop the `vacation-days-field` prefill + and the "change to 10 → `updateChildDetails({ preschoolPlanningDaysPerYear: 10 })`" + assertions; assert instead that the family save no longer sends that key and that + year rows still prefill from `childDetails.preschoolPlanningDaysPerYear`. +- `frontend/src/pages/Settings.test.tsx:459-500` — add a clear-then-retype case on + `preschool-planned-{year}` asserting the committed value is `5`, and that clearing alone + does not call `setPlanned`. +- `frontend/src/hooks/useChildSettings.test.ts:100-113` — unchanged (the hook still + supports the field); no edit expected, verify it still passes. +- Backend tests unchanged: no model, service, entity or migration touched. + +Mock only with the typed factories in `frontend/tests/fixtures/` and `vi.mocked(fn)` — +no `as any`, no casts (CLAUDE.md anti-patterns). + +## Verification + +Delegate the full suites to a **Haiku** subagent, one at a time (never in parallel), with +raw verbatim output required: + +```bash +# from frontend/ +npm run typecheck +npm run lint # zero warnings +npm run coverage # gates: lines 96 | functions 91 | branches 92 | statements 96 +``` + +Backend is untouched, so a backend run is only needed if something unexpected changes there. + +Then a **Sonnet** subagent drives Chrome (never the coordinator) against `npm run dev`: + +1. Settings → Family panel: confirm the standalone "Preschool planning days per year" + input is gone and the year rows still show the seeded value with the "assumed" badge. +2. In a **Planned days** box: select-all, delete → box goes empty (not `0`); type `5` → + shows `5`, not `05`; blur → autosaves `5`. +3. Blank a box and click away without typing → the previous value returns, nothing saved. +4. Repeat 2-3 on a `VacationDaysTable` row and on the period `lastYear` input. +5. Check the reworded section in **both Swedish and English** — the labels must not clip + (per the bilingual-CSS rule). + +Finish with `graphify update .`. diff --git a/todo.md b/todo.md index 101fd8e..d563291 100644 --- a/todo.md +++ b/todo.md @@ -11,7 +11,7 @@ Following three have a plan (free-days-and-auto-planning-plan.md): - On every event that updates the days off (i.e. the calendar is making some changes), that should trigger the planner to recalculate the best kinds of time-off for all days. - -> In progress + -> In progress, super long time..... - Problem with the yearly timelines in general tab @@ -30,7 +30,7 @@ Following three have a plan (free-days-and-auto-planning-plan.md): ## medium prio - Update all deprecated things in the backend code. --> fix-deprecations-backend-plan.md -> in progress +-> fix-deprecations-backend-plan.md -> DONE ## lowest prio