Files
F-r-ldraledigplannerare/new-features/fix-number-drop-redundant-preschool-field-plan.md
T
2026-09-21 07:30:17 +02:00

7.8 KiB

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).

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-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 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:

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