7.8 KiB
Fix number-input clearing UX + drop the redundant preschool estimate field
Context
Two problems in Settings, both reported by the user:
-
Clearing a number input traps you at
0. The per-year "Planned days" input (Settings.tsx:186-192) keeps its draft as anumber, andNumber('') === 0, so blanking the box writes a literal0to the server and snaps the display to0. Typing5then produces05. The same hazard exists inVacationDaysTableandPeriodPopover.VacationDaysInputhas the mirror bug: it silently rejects a non-numeric value, so the field can't be blanked to retype at all. -
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).
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. onChangefires 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
maxvalues 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
valueprop; do not callonChange. - Sync the draft from the
valueprop 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"withmin/maxattributes and the existingfield/field__controlBEM classes;labelrenders asfield__labelwhen given. No hardcodedid/data-testid— both are props (the bug that makesVacationDaysInputnon-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
VacationDaysInputblock (:562-571) and its import (:21). - Delete the
preschoolPlanningDaysstate (:252), its prefill in thechildDetailseffect (:285), andpreschoolPlanningDaysPerYearfrom theupdateChildDetailscall inhandleSaveFamily(:366). The field is optional inChildUpdate(backend/app/models/child.py:40), so omitting it leaves the stored value untouched. - Pass
defaultPlannedDays={childDetails?.preschoolPlanningDaysPerYear ?? 6}toPreschoolPlanningYearRow(:583) instead of the removed state. - Move the
settings.preschoolHelpparagraph (:563) into the per-year block, under the heading, and drop the now-redundantsettings.preschoolPerYearHelpline 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-testids (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 thevacation-days-fieldprefill 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 fromchildDetails.preschoolPlanningDaysPerYear.frontend/src/pages/Settings.test.tsx:459-500— add a clear-then-retype case onpreschool-planned-{year}asserting the committed value is5, and that clearing alone does not callsetPlanned.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:
# 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:
- 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.
- In a Planned days box: select-all, delete → box goes empty (not
0); type5→ shows5, not05; blur → autosaves5. - Blank a box and click away without typing → the previous value returns, nothing saved.
- Repeat 2-3 on a
VacationDaysTablerow and on the periodlastYearinput. - Check the reworded section in both Swedish and English — the labels must not clip (per the bilingual-CSS rule).
Finish with graphify update ..