# 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 .`.