Three changes that together unblock treating a calendar period as one
editable object.
Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL)
so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a
day the user hand-edited, and `leave_wish.target_days` so a period drawn on a
calendar need not be a whole number of weeks. Apply-time identity still keys on
day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update
rather than a collision.
The age-4 savable ceiling was applying the per-child figure of 96 to each
parent's pool, modelling a two-parent family as able to save 192 days past the
child's fourth birthday. `resolve_age4_savable_cap` is now the single place
that decides 48 (couple with two parents) versus 96 (everyone else), and both
AnalyticsService and the allocator read it.
Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`.
The allocator's tier-3 policy collapses to one rule; `salary_type` still
branches, since that reflects what a vacation day actually costs a parent
rather than a user preference.
PlanResults, Plan and usePlanProposal still hand-rolled proposal objects
inline -- roughly 29 nested literals in PlanResults alone. Hand-writing a
deep shape is the pressure that produced 219 `as any` casts across this
suite, so the factories existing but going unused left the trap armed.
Values an assertion depends on stay inline as overrides; the factories
supply only the scaffolding a reader does not need to see. A type gaining
a field now breaks one factory instead of silently passing thirty tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
ESLint turned `no-explicit-any` off for test files, and 219 casts had
accumulated across 12 of them. Two shapes, very different risks: most
were `(useWorkspace as any).mockReturnValue(...)`, mere laziness that
`vi.mocked()` does properly; the rest cast partial fixtures onto API
types, which erases the contract exactly where a test should enforce it.
That second kind matters here specifically. This project's recurring bug
is a field silently dropped between backend and frontend -- the
hand-written mapping in models/plan.py has done it twice, and apiFetch
mishandling structured errors was the same shape. A test that casts an
incomplete fixture to `any` keeps passing through precisely that change.
A typed fixture layer already existed at tests/fixtures/mockData.ts and
was imported by exactly zero tests: the right tool was there and
invisible, so everyone reached for a cast. It is now a set of
`make*(overrides: Partial<T>) => T` factories returning complete, valid
objects, split by domain with a barrel. When a type gains a field, one
factory fails to compile instead of thirty tests silently passing.
With the count at zero the ESLint exemption is deleted, so this cannot
regrow. Removing it immediately caught two `}: any` prop stubs that a
grep for "as any" had missed; those mocks now use the real components'
exported prop types.
`as unknown as T` survives in five places, each a test deliberately
feeding invalid input (an undefined breakdown, missing tierData, a bogus
leave type). CLAUDE.md now documents that as the only accepted escape,
and points at the factories.
interface PollResponse in useScraperFetch.ts is exported so its shape can
be derived rather than duplicated -- pollScraperSession declares
Promise<unknown>, so there was no other honest way to type it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
Both screens showed an "expiring at age 4" number under near-identical
labels, and they disagreed -- 143 on the Dashboard versus 100 on the Plan
tab for the same parent. Both were correct: the Dashboard reflects the
saved plan, the Plan tab a proposal that has not been applied and which
simulates future recurring wishes.
The numbers are left untouched; forcing them to agree would make one of
them wrong. Instead each label now states its own basis ("om nuvarande
plan följs" / "om detta förslag tillämpas"), and the Plan tab's intro
says outright that the saved plan may differ, and why.
Tests assert the distinguishing copy on both screens so the two cannot
silently drift back to indistinguishable labels.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
Branches were the weakest metric and had the least headroom above the
85% floor. These are tests for decision paths that were genuinely
unexercised, not padding.
The largest gaps were systemic rather than per-file: the
`err instanceof Error ? ... : fallback` branch was untested across eight
data hooks, useVacationDays had no dedicated test file at all, and
Calendar.tsx's missing-workspace/child/personalData fallbacks were never
hit.
Left deliberately uncovered, with reasons: `?? fallback` guards whose
fallback cannot occur given how the value is constructed upstream, and
guards behind buttons that are disabled whenever the guard would matter.
Reaching those means calling handlers directly, which tests the test
rather than the app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
useLeaveWishes.fetch() runs again after every add/update/remove to
refresh the list, and it flipped the same isLoading flag the initial load
uses. Plan.tsx swaps the whole page for a spinner on that flag, so every
mutation unmounted and remounted WishEditor -- wiping which rows were
expanded and any save status.
This predates auto-save (the old manual Save button did it too), but a
debounce that fires on every edit turned an occasional flicker into the
dominant experience, collapsing the row the user was editing.
A background refetch now toggles a separate isRefreshing flag; isLoading
stays true only for the initial load. Plan.tsx needed no change once the
hook was right.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
First real browser walkthrough of the app, against seeded demo data. All 1140
tests were passing; none of these four were visible to them.
Age-4 entitlement (high severity, domain bug). The no-snapshot fallback gave a
single parent rules.sjukpenning_days + rules.lagsta_niva_days = 390 + 90 = 480
days, but those config values are the whole CHILD's combined tier totals across
both parents, not one parent's share. The correct fallback is days_per_parent
(240 = 195 + 45), which only AnalyticsService used. So a parent without an FK
snapshot was credited with roughly double their real entitlement, and the
Dashboard and Plan tab disagreed about expiring_at_b4 for the same committed
state. The wrong default existed in three places; allocation_service.py and
planning_service.py are corrected, and analytics_service.py now calls the shared
pure days_left_at_age_4_projection instead of re-deriving the arithmetic, so
there is one source of truth. A test that had encoded the buggy 390/90 default
as expected behaviour is corrected too.
The Dashboard stat is workspace-wide while the Plan tab breaks down per parent,
which is legitimate but was unlabelled and contributed to the confusion; the
dashboard label now says it is a combined all-parents total.
Nonsensical remedy text. lower_priority_wish computed
max(other.target_weeks // 2, 1), which for a 1-week wish clamped back to 1 and
rendered "Lowering Var from 1 to 1 weeks frees up 32 days". target_weeks has a
domain floor of 1 and the planner works in whole days, so there is no smaller
whole-week suggestion to make: the remedy is now suppressed for 1-week wishes.
Wishes of 2+ weeks still get a genuine, strictly smaller suggestion.
Day override invisible until reload. Calendar refetched the overrides list but
not GET /api/calendar/days, which backs the grid's shading and badges, so a
just-applied override did not render. useCalendarDays now exposes refetch and
Calendar calls it after a successful upsert or remove.
Dashboard showed the previous user's data after a login swap. WorkspaceContext
sits above the router and outlives login/logout, and its refetch was gated on
!workspaceId || children.length === 0 - so precisely because the old session's
data was still present, the guard blocked the refetch. It now keys off an
explicit isAuthenticated transition and clears state on the logout edge itself,
including the persisted localStorage workspaceId, so one account's figures can
never be briefly visible under another's session. useAnalytics, useParentalLeave
and usePlannedDays clear cached data on identity loss as defence in depth.
Backend 435 -> 439 tests, frontend 705 -> 710.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.
Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)
Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides
i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
following the tips_service pattern; old string fields replaced, not kept
Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering
Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry
Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.
Not yet verified in a browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
Implemented 4 page stubs with hooks and components:
- Dashboard: displays overview metrics, planned days breakdown, progress chart, break periods
- Calendar: month-based calendar grid with event badges and navigation
- Tips: list of optimization tips with dismiss functionality
- Settings: workspace configuration with child info, parent profile, vacation days, break periods
Added utility exports:
- src/context/index.ts: re-exports useAuth, useWorkspace hooks
- src/hooks/index.ts: re-exports all domain hooks
All pages:
- Use Layout wrapper with header configuration
- Load data via hooks with loading/error states
- Render presentational components (no business logic)
- Pass callbacks for user actions
- Type-safe with full @types coverage
Frontend test coverage: 167 tests, all passing
TypeScript: zero errors, lint: zero errors
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>