Commit Graph

55 Commits

Author SHA1 Message Date
jacobh2 0fc71101ae Cover the balance entry path and three unguarded rules
Entering a parent's Försäkringskassan balances had no tests at all, despite
being how every number in the planner originates and despite it becoming the
first thing a new user does. The scraper endpoints are pinned per test rather
than read from the ambient configuration, because a local .env sets the flag
opposite to the shipped default and a test that trusts it would pass here and
fail in CI.

Also covered: the hard expiry at twelve, whose rejection path nothing reached
and whose boundary turns out to be inclusive; and the dubbeldagar count cap,
which now drives past the limit and asserts the days beyond it fall back to a
single parent. Two weak assertions were strengthened, including one that
derived its expectation from the same response it was checking and so could
not have failed on a shared error.
2026-09-17 19:46:03 +02:00
jacobh2 9d548da7d6 Guard against schema drift and fields dropped on the wire
Two mistakes have recurred in this codebase: the hand-maintained schema in
db/connection.py drifting from the Alembic chain, and a field quietly going
missing from the hand-written mapping onto the plan response. Tests existed
for individual instances of the second, written after each one was found.

Both are now checked structurally. One test builds a database from the
migrations and another from the hand-written schema and compares their real
shape, ignoring cosmetic column ordering but failing on a missing column,
wrong nullability, absent foreign key or index. The other walks the proposal
dataclass and asserts every field reaches the response, so a field added
later is covered without anyone remembering to extend the test. Anything
deliberately kept off the wire goes in an explicit allow-list.

Both were confirmed by breaking what they guard and watching them name it.
2026-09-17 19:45:50 +02:00
jacobh2 0aeec9c820 Track the frontend lockfile so npm ci can work
The ignore rule for package-lock.json had no leading slash, so it matched at
any depth and kept frontend/package-lock.json out of the repository. The
frontend CI job meanwhile installs with npm ci and caches against that exact
path, neither of which can work when the file is absent from a clean checkout.
The rule is now anchored to the repository root and the lockfile is tracked.
2026-09-17 19:45:37 +02:00
jacobh2 fd39bd9748 Map the tips payload onto the shape the client declares
Tips rendered as blank cards with unresolved placeholder text in their
accessibility labels. getTips was the one entity fetch in the client with no
translation from the wire's snake_case, so every tip arrived without the key
that selects its title and body, and the lookup silently missed.

The mapper now matches its siblings in that file. A test drives a card through
all eleven message keys the tips engine emits, so a twelfth added later fails
loudly instead of rendering an empty card, and two older tests that stubbed the
tips response as an empty object rather than an empty list have been corrected.
2026-09-17 19:24:26 +02:00
jacobh2 b5321c5e8e Correct the coverage gates the guide advertises
The documented gates had drifted well below the real ones: the backend gate
reads 80 in the workflow, not 50, and the frontend thresholds are 96 lines
and statements, 91 functions, 92 branches, not the 80/85 pair recorded here.
Anyone trusting the old numbers would think they had headroom that CI does
not grant. The frontend type-check command is also named correctly now, since
invoking tsc through npx picks up a newer compiler and fails spuriously.
2026-09-17 19:24:11 +02:00
jacobh2 c368042c7e Require a planned day to carry its wish and pinned state
Both fields were made optional to avoid editing test files that were out of
scope at the time, but the API populates them on every planned day, so the
optionality described nothing real and quietly loosened the contract between
the wire format and the client — which is where this codebase has historically
gone wrong.

The inline objects that forced the compromise now go through the typed test
factory instead, so the next field added to a planned day is a one-line change
rather than another round of this.
2026-09-17 18:33:30 +02:00
jacobh2 6a7c0ef255 Draw a leave period as one band, and make red days look red
A six-week summer used to render as a row of disconnected bars, because bars
were derived per week row and grouped by parent and day type. They carried no
identity and ignored pointer events, so there was nothing to click. Bands now
group by the wish that produced them, bridge the weekends and red days inside
a period, and round only the ends that are genuinely the first and last day.
Each day still shows what pays for it, and a hand-edited day is marked as one
the planner will leave alone.

Sundays and public holidays now render red and Saturdays a neutral grey,
following the printed almanacka people compare against, and a holiday's name
is visible in the cell rather than hidden in a tooltip. This also explains the
report that months looked unshaded: weekends had no styling at all, since the
day component handled only red days and klämdagar.

The year selector follows the range the calendar API actually serves instead
of a hardcoded span.
2026-09-17 18:28:16 +02:00
jacobh2 d06c8c8449 Check vacation days against the entitlement before persisting them
Applied batches were validated against the remaining föräldrapenning tiers
but never against semester. That held only while the allocator was the sole
writer, since its ledger spends vacation against a per-year budget. The
calendar is about to let a user force any day to semester and pin it, which
writes straight past that ledger, so nothing would have stopped forty pinned
vacation days against a twenty-five day entitlement.

The check mirrors the allocator's own derivation rather than restating it:
available days are total plus days saved in, and a year with no entered row
falls back to the statutory entitlement instead of reading as zero. The
semesterlagen floor stays out of it, being a bound on what may be carried
forward rather than a cap on what may be spent. Vacation is budgeted per
calendar year, so a period crossing New Year is charged to both.
2026-09-17 18:22:55 +02:00
jacobh2 c1aa4ae0b0 Credit days freed by the same sync before checking the balance
A sync deleted the rows a re-plan had dropped only after apply_proposed_days
had already validated, so capacity being freed in the very same call was
never counted. Shortening one period and lengthening another while near the
pool cap read as pure net-new demand and was refused outright, writing
nothing and losing the edit silently.

apply_proposed_days now takes the keys it should delete, credits those rows
back to their tier and to the dubbeldagar quota during the up-front checks,
and performs the deletions in its own write phase. Validation still runs
fully before any write, which is what SQLite's lack of a cross-request
transaction requires.
2026-09-17 18:12:31 +02:00
jacobh2 6cd60887d5 Add a plan sync endpoint that recomputes, persists and reports the delta
The calendar is becoming the plan: a user edits a period and the schedule
updates immediately, with no separate propose and apply steps. POST
/api/plan/sync does both and returns a per-date diff against a snapshot taken
before any write, so the interface can say what actually moved rather than
guessing from coarse counters.

Pinned days now mean something. A day the user hand-edited keeps its type and
its parent: the loader hides unpinned wish-attributed rows from the allocator
so a re-plan is free to move them, while pinned rows stay visible and the
existing occupancy check refuses to touch them. Their cost is debited off the
pool before any window is funded, following the precedent already set for
preschool planning days.

Propose and apply are untouched. The allocator stays pure.
2026-09-17 17:58:47 +02:00
jacobh2 0e14b4dac8 Flag the superseded sections of the design docs
ARCHITECTURE.md and PLANNING_ENGINE_SPEC.md still described the planning
goal, the per-wish goal override, and a per-parent age-4 ceiling of 96 days
as current. Rather than rewrite two long documents that remain useful as
history, each stale section now carries a SUPERSEDED banner naming what is
true instead. The banners sit above the code fences they annotate, not
inside them.
2026-09-15 21:18:38 +02:00
jacobh2 cc3ced905e Link planned days to their wish, fix the age-4 cap, drop the planning goal
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.
2026-09-15 20:28:06 +02:00
jacobh2 7f516f2cab Retire the NextSteps handoff notes and pin the model tiers
The three NextSteps*.md files were per-session handoff notes that had gone
stale; the plan they described is superseded. CLAUDE.md gains a MODEL TIERS
section recording who is allowed to run as what: Opus coordinates, Sonnet
implements, Haiku verifies, and no subagent runs on Opus.
2026-09-15 20:27:48 +02:00
jacobh2 5d9ac5024a Replace the shared MOCK_* fixtures with factories
A frozen shared constant forces a test needing one different field to
either mutate shared state, leaking into every other test, or spread it
by hand. The make*(overrides) factories cover both cases and were already
doing the same job, so the constants were redundant.

MOCK_PARENT_A and MOCK_PARENT_B were distinct identities that partner
tests tell apart, so their uuid and name are now passed explicitly at
each call site rather than left to a factory default -- the values those
tests depend on stay visible where they are used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-15 07:47:34 +02:00
jacobh2 46e89a4359 Build plan-shape test data with the typed factories
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
2026-09-15 07:47:25 +02:00
jacobh2 fe120ee222 Remove every as any from the test suite and enforce it
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
2026-09-15 04:17:22 +02:00
jacobh2 da7ac84aca Make button labels visible on non-green buttons
The global `button` rule pairs a green background with white text. Any
variant that overrode the background without also setting `color` kept
the white text and rendered invisible against a light background.

Four controls were affected, all measured white-on-white or
white-on-light-grey: the calendar header's prev/next arrows, the
calendar's override-mode toggle, the red-days list's year navigation,
and the modal close button.

Each now declares its own `color: var(--c-text)`. Using the variable
rather than a literal means the high-contrast media query in
responsive.css, which redefines --c-text to #000, still applies.

Also adds the hover and focus-visible states the Settings and
vacation-days Save buttons were missing entirely -- they were the only
green buttons in the app that did not react to the pointer. Hover is
scoped with :not(:disabled) so a button disabled mid-save stays inert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-15 03:51:18 +02:00
jacobh2 31a60a296f Let the calendar jump to any month or year, in both directions
Clicking the month or year label only ever stepped forward
(`month + 1`, `year + 1`), so the only way back was the prev-month arrow,
one month at a time. Jumping back a year was impossible.

Both labels are now selects, so any month or year is one click away in
either direction. Selects rather than +/- steppers because the app
already uses them elsewhere, and because stepping in a second direction
would still not give the "quickly jump" part of the original intent.

Year options are bounded to 2026-2055, the calendar cache range: outside
it the backend raises UncachedYearError and returns 422, so offering
those years would hand the user an error instead of a calendar.

The prev/next month arrows are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-15 03:31:33 +02:00
jacobh2 20f45d6c7b Translate wish names in plan proposal output
Preset wish names follow the UI language in the wish editor, but the plan
proposal still rendered outcome.wishName straight from the stored
entity.name, so a proposal could show "Höstlov" with the UI in English.

The proposal now carries wish_name_message_key alongside each wish name,
and the frontend resolves it with the same helper the editor uses --
falling back to the stored literal when the key is absent or unknown.
Nothing is translated on the backend; text crosses the wire as a key.

Four surfaces carried a name and all four are covered: WindowOutcome
itself, the explanation summary's interpolated data, and two remedies --
including REMEDY_LOWER_PRIORITY_WISH, which names a *different* sibling
wish whose key can differ from the outcome's own.

The proposal response mapping is hand-written and has dropped new fields
twice before, so this is covered by an API-level test asserting the field
reaches the response body, not only a service test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 22:51:15 +02:00
jacobh2 e22007dc50 Detect wish save failures directly instead of inferring them
Plan.tsx's handlers swallowed their rejections and onUpdate was typed to
return void, so WishEditor could not observe a failed save. It inferred
the outcome instead -- comparing the draft it sent against the next
wishes prop, and reading "isSaving went false without a match" as
failure. That coupled save status to refetch timing and could not tell a
network error from a slow refresh.

The handlers now return their promise, onUpdate accepts
`void | Promise<void>` so void-returning callers still typecheck, and
WishEditor awaits and catches the real outcome. The inference machinery
(sentDraftRef, prevIsSavingRef and the comparison effect) is removed
rather than left alongside, since two sources of truth can disagree.

The save/refetch/save loop guard is untouched: it lives in the draft
reseed effect, which only fills a row that has no draft yet, and saves
only ever originate from an explicit onChange. Added a comment saying so.

A failed row now keeps its edit and offers an explicit retry, rather than
requiring another edit to retrigger the debounce. Per-row failures report
inline only -- reporting them in the page banner too would double-report
the same error.

Also removes wishEditor.saveButton/savingButton, dead since the per-row
Save button was replaced by auto-save.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 22:51:15 +02:00
jacobh2 8c2e5dee1b Distinguish the Dashboard and Plan age-4 figures
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
2026-09-14 22:32:54 +02:00
jacobh2 dc4c0a5edd Show preset wish names in the current UI language
Preset wishes were created with `name: t(preset.labelKey)`, so the
translated string was persisted as the wish's name. A wish created while
the UI was Swedish stayed "Jul" in English forever -- the Swedish text
was the data, so no display-layer change could fix it.

Wish names are user-editable free text, so `name` cannot simply be
translated at render time. Migration 007 adds a nullable
`leave_wish.name_message_key` recording where a name came from: preset
wishes store the i18n key alongside the translated literal, and the UI
renders the key when present.

The key is cleared server-side the moment `name` actually changes,
compared against the stored value rather than trusting the caller, so no
client can forget to clear it and silently revert a user's own name to a
preset label.

An unknown key (a stale row, a renamed key) falls back to the stored
literal via an isTranslationKey guard, so a bad key can never crash the
page or leak a raw key to the user. Rows predating this column keep
displaying their stored name.

_FULL_SCHEMA in db/connection.py is updated in step with the migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 22:32:54 +02:00
jacobh2 b03462066e Ratchet coverage floors up to match measured coverage
Frontend floors move to 96/96/91/92 (measured 97.27/97.27/92.87/93.78)
and the backend gate to 80 (measured 83.47). Kept 1-2pp below measured so
real regressions fail without false positives on incidental drift.

These are a ratchet: raised as coverage grows, never lowered to make a
gate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 18:54:56 +02:00
jacobh2 c5481a05f0 Raise frontend branch coverage from 86.84% to 93.78%
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
2026-09-14 18:54:56 +02:00
jacobh2 8869ea8e55 Stop the Plan page unmounting on every wish mutation
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
2026-09-14 18:54:50 +02:00
jacobh2 cf378288f0 Break the Dashboard's age-4 stat down per parent
The Dashboard summed both parents' expiring days into one figure while
the Plan tab already showed them separately. Each parent's own count is
now shown alongside the combined total, which stays and is relabelled as
a sum.

The per-parent numbers were already on the wire as
parentProjections[].expiringAtB4 -- no backend change, and no arithmetic
here. Both figures are rendered exactly as the backend computed them,
since re-deriving them is what previously doubled a parent's entitlement
(87b3eb5).

A single-parent workspace shows only the combined figure, where a
breakdown would just repeat it.

nameResolver moves out of PlanResults.tsx into src/utils/parentNames.ts
so both screens resolve uuid -> name the same way. PlanResults.test.tsx
passes untouched, which is the evidence the extraction preserved
behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 18:54:50 +02:00
jacobh2 f685b739f8 Add a custom bounding range for flexible wishes, and auto-save edits
Flexible wishes could only be bounded by one of four season presets, a
deliberate v1 scope cut. They can now take any month/day range, including
one that wraps the year end the way the jul preset does. The range stays
a search bound -- the engine still chooses the placement inside it at
propose time.

February is capped at 28 days rather than 29: the backend builds a
concrete date(year, month, day) per anchor year, so a Feb 29 bound would
raise in a non-leap year.

Per-row Save buttons are replaced by a debounced auto-save (800ms), which
is what prompted this: clicking save on each individual field was the
reported annoyance. A draft that has not actually changed never saves, so
a refetch cannot trigger a further save -- the save/refetch/save loop is
guarded explicitly and has a regression test. An invalid draft (bounding
range shorter than the wish) holds the edit locally and shows why rather
than saving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 18:54:43 +02:00
jacobh2 8e14ac6576 Show and manage the linked partner in Settings
Settings never called GET /workspace/{id}/members -- confirmed by network
capture, not inference. There was no member list, no loading state and no
swallowed error; the code was simply absent. A workspace with a partner
therefore looked identical to a solo one, and nothing stopped a second
partner being added.

Settings now lists both members, marks the logged-in user, and offers
rename on either row and remove on the partner. The add-form appears only
when no partner exists. Removal asks for confirmation inline rather than
via window.confirm, which this codebase does not use anywhere and which
blocks the browser tooling used to verify it.

Own-name rename is offered too: the backend documents this route as the
only place a name can be corrected, so hiding it would leave that
capability dead.

Also fixes apiFetch's error parsing, which handled only string and array
detail bodies. A structured {message_key, data} object fell through and
was assigned to error.message, rendering as [object Object] or leaking
the raw key -- latent until this session's backend work started
returning exactly that shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 18:54:43 +02:00
jacobh2 68d982bb94 Make repository list() reject unrecognised filter names
Repository.list(**filters) let each implementation pick out the filter
names it understood and silently ignore the rest, so a typo or an
unsupported filter returned every row in the table instead of an error.

This was hit for real: LeaveWishRepository.list(parent_personal_data_uuid
=...) returns every wish in the database, because that implementation
only recognises workspace_id and child_id. A guard written on top of it
would have passed while checking nothing.

All ten implementations now validate against their supported set and
raise ValueError naming the offending key. ValueError rather than a
domain error because passing a filter that does not exist is a
programming bug to fix, not a runtime condition to handle -- it must not
become an HTTP 4xx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 18:54:34 +02:00
jacobh2 8ebfcb9462 Add rename and remove for a workspace's placeholder partner
Settings is gaining a partner display, which hides the add-form once a
partner exists. Without these routes a typo'd partner name would be
permanently unfixable, since no endpoint could change or drop one.

PATCH /workspace/{id}/parents/{uuid} renames; DELETE soft-deletes.
Soft rather than hard delete because PersonalData holds PII and the
repository is a SoftDeleteRepository specifically so erasure stays
auditable.

Removal refuses (422) when the member still has leave wishes, planned
leave, parental-leave rows or vacation days, naming the blockers, so a
removal can never orphan plan data. It also refuses for the caller's own
profile, and for a member who has their own login -- the invite/redeem
flow re-points a real user's personal_data into the inviter's workspace,
so soft-deleting that row would silently break their login.

Errors carry message_key + data rather than prose, per the no-Swedish-on
-the-wire rule; NotFoundError/ValidationError gained the same optional
shape DubbeldagError already had, defaulting empty so existing call
sites are unaffected.

Also fixes seed_demo.py constructing WorkspaceService with a stale
3-arg signature, which would have crashed at runtime -- invisible to
pytest since it is a standalone script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 18:54:34 +02:00
jacobh2 bd996245ba Split i18n catalogues into per-domain modules
sv.ts/en.ts were single 602-line files that every feature had to edit,
making concurrent work on unrelated screens collide constantly. Split
both into per-domain modules (common, auth, calendar, dashboard,
settings, plan, wishEditor, tips) composed by an index that preserves
the existing `import { sv } from './sv'` specifier, so no consumer
changes.

Typing still catches drift in both directions: each en module is checked
against TranslationKey, and the composed catalogue must satisfy
Record<TranslationKey, string>.

All 433 keys and their values are byte-identical to the originals,
verified by diffing the parsed catalogues against the pre-split files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 18:54:27 +02:00
jacobh2 7f031f2087 Add NextSteps260915.md handover in wave/brief format
Restructures the handover so each task is a self-contained brief that can be
lifted straight into a subagent prompt. The previous narrative format meant every
dispatched agent read the whole document to use one paragraph of it.

Shared preamble is read once by the orchestrator and never forwarded. Tasks are
grouped into waves by file ownership so a wave can be dispatched in parallel
without two agents touching the same file. Each brief carries its own goal, owned
files, constraints and traps repeated verbatim, done criteria with numeric
baselines, and any product decision that must be made before starting.

Trap duplication across briefs is deliberate: an agent that has not been told
about a trap reintroduces the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 08:24:35 +02:00
jacobh2 87b3eb5d0c Fix four bugs found by browser verification
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
2026-09-14 08:21:18 +02:00
jacobh2 249e277696 Fix flexible-wish double-booking, extend calendar cache to 2055, add journey tests
Second real bug found by driving the running app while the suites were green.

Flexible wishes were funded twice across propose -> apply -> re-propose. Because
planned_leave_day carries no wish_id, the engine could not tell that applied days
were that wish's own prior occurrence; it treated them as occupied space, routed
the candidate search around them, and funded the year again on shifted dates while
reporting weeks_funded 1.0. Applying twice turned "one week each spring" into 18
paid days instead of 9. Fixed wishes were unaffected and stay unaffected.

Fix (allocation_service.py), per the approved approach of counting already-applied
days inside the bounding range rather than adding a wish_id column:
- _Ledger._persisted_lookup snapshots existing days BEFORE this run folds in its
  own freshly-computed windows, so a fixed wish's new placement is never mistaken
  for a flexible wish's history
- _reuse_already_funded_window finds this wish's persisted dates within its bound
  and reuses the window containing them, via the ordinary fund_window path
- full funding therefore yields zero new days and partial funding tops up exactly
  the remainder, with no duplication and no false shortfall
- with no persisted dates, or if the reused window is no longer collision-free, it
  falls through to the original search, so an unsatisfied year is never frozen

Calendar cache extended 2026-2035 -> 2026-2055 (39KB -> 116KB) and the allocator
switched to strict, so an uncached year raises UncachedYearError -> ValidationError
-> HTTP 422 instead of silently treating Julafton, Nyarsafton and Midsommarafton as
working days. Also fixed normalize_day dropping the second name when two holidays
share a date (Nationaldagen was being lost). 2049 and 2055 legitimately have 17 red
days, not 18: Pingstdagen coincides with Nationaldagen.

New backend/tests/integration/test_user_journey.py drives the real app over HTTP
across seven journeys: the double-booking guard, the fixed-wish control, apply
idempotency, fixed-never-displaced-by-flexible, flexible wishes tracking Easter
per year, age-4 arithmetic self-consistency, and a recursive check that no Swedish
text reaches the wire now that everything is message_key + data.

New backend/scripts/seed_demo.py seeds a realistic workspace through the real
service layer for manual testing: demo@example.com / DemoPass123!

Backend 414 -> 435 tests. Frontend unchanged at 705.

Still not verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 07:38:07 +02:00
jacobh2 28c632ca36 Fix calendar window overshooting the cached year range
Found by probing the running API; both suites were green and missed it.

CalendarService validated only start_year against the cache, never the window
end. GET /api/calendar/days?start_year=2035&years=4 returned 200 with
end_year=2038, pulling 2036-2038 from the live holidays-library fallback in
app/utils/holidays.py. That fallback does not know the de facto free days, so
Julafton, Nyarsafton and Midsommarafton were reported as ordinary working days
(18 red days per cached year vs 13 per uncached year). Julafton being charged
as a working day was fixed once before; it had reappeared past 2035.

- clamp end_year to max_year; the response now always reports what it returned
- add strict=True to get_swedish_holidays/get_red_days plus UncachedYearError,
  checked before the memo cache, so a strict caller fails loudly rather than
  silently degrading. Non-strict default behaviour is unchanged.
- RedDaysList derived endYear locally as startYear + years - 1, independent of
  the response, so a clamped window rendered phantom empty years. It now takes
  endYear from the backend.

Tests: clamping at and before max_year (service + router), Julafton/Nyarsafton/
Midsommarafton red for every cached year, red-day count consistent across the
served range, and strict-mode raise/pass-through.

Backend 404 -> 414 tests, frontend 704 -> 705.

Known, deliberately not fixed here: AllocationInputLoader builds its holiday map
the same non-strict way over the planning horizon, so it has the same exposure
once the horizon passes 2035. Clamping would silently truncate planning and
raising would break real plans, so it needs a product decision first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:46:47 +02:00
jacobh2 17dadb79cc Ratchet coverage thresholds, refresh handoff, drop finished todo files
Coverage has grown well past the configured gates, so they no longer catch
regressions. Raise them to just under the measured values, per the standing
"regression ratchet, not a target" rule:
- frontend lines/statements 80 -> 95, functions 85 -> 89 (measured 96.52/91.08)
- frontend branches held at 85 (measured 86.78, the weakest metric)
- backend CI --cov-fail-under 50 -> 75 (measured 81)

Add NextSteps260914.md as the handoff for the next session. Delete todo.txt,
todo_260912.txt and todo-backend.txt, all fully done; NextSteps260913.md is
kept as history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:36:47 +02:00
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
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
2026-09-14 02:33:03 +02:00
jacobh2 3ca1418d94 Fix selection in calendar 2026-06-14 22:42:28 +02:00
jacobh2 cb5517a387 Before calendar multi-select css update run 2026-06-14 22:36:43 +02:00
jacobh2 dead5e4897 Update calendar with multi-select 2026-06-14 22:20:32 +02:00
jacobh2 88afa17666 Update todo 2026-06-14 20:06:27 +02:00
jacobh2 d556ce0f6f Bugfixes in dashboard and calendar 2026-06-14 19:02:20 +02:00
jacobh2 566595984f Styling fix and scraper bug fix 2026-06-14 18:39:55 +02:00
jacobh2 6ed2a3ae0c Fix calendar style 2026-06-14 13:05:53 +02:00
jacobh2 4924982a8a test(calendar): grid layout, week ordering, and day-cell coverage
- Add isToday test cases to CalendarDay.test.tsx
  * test --today class when isToday=true
  * test --today class not applied when isToday=false
- Create Calendar.test.tsx with full page coverage
  * Test 7 weekday headers in Monday-first Swedish order
  * Verify correct number of day cells (multiple of 7)
  * Test Sunday-starting month renders correctly (March 2026)
  * Test modal opens on current-month day click
  * Test modal doesn't open on disabled day click
  * Test current month title display
  * Test prev/next month navigation
  * Test loading state display
- Mocks useWorkspace, usePlannedDays, Layout, LoadingSpinner, PlannedDayModal
- All 19 tests pass; no coverage regression

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-14 12:51:58 +02:00
jacobh2 a2300b285d fix(calendar): Monday-first Swedish week, today highlight, default to current month
- Convert week calculation to Monday-first (startOffset = (firstDay.getDay() + 6) % 7)
- Update weekday headers to Swedish labels: [Mån, Tis, Ons, Tor, Fre, Lör, Sön]
- Change default currentMonth from June 2026 to current date (new Date())
- Add isToday prop to CalendarDay component
- Add --today modifier to highlight current date with blue circle background
- Pass computed isToday boolean to CalendarDay when date equals today's date string
- Renders correctly for Sunday-starting months (e.g., March 2026)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-14 12:51:53 +02:00
jacobh2 69ce746082 feat(calendar): add calendar.css grid layout + styled day cells and legend
- Create calendar.css with 7-column grid using display: grid
- Style calendar header with navigation buttons (min 36x36px tap targets)
- Style day cells with borders, hover states, and focus-visible outlines
- Style weekday headers (centered, muted, uppercase)
- Create --disabled modifier for leading/trailing days from adjacent months
- Create --has-events modifier for visual emphasis
- Create event markers as small dots (8x8px circles)
- Style legend with compact horizontal swatch layout
- Import CSS into Calendar.tsx following co-located pattern from Header

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-14 12:51:48 +02:00
jacobh2 772d853e37 MVP frontend+backend 2026-06-14 09:58:30 +02:00
jacobh2 d9539f23cc --fix with ruff 2026-06-01 20:30:17 +02:00
jacobh2 6446d9c760 Document Round 3 completion: frontend build fixed, 9 components built, 4 pages wired
JUNIOR_REPORT_3.md summarizes:
- Phase 1: Fixed 35+ TS errors by adding 8 API functions, fixing hook signatures
- Phase 2: Built 9 presentational components (CalendarDay, CalendarEvent, DaysBreakdown, ProgressChart, TipCard, VacationDaysInput, ParentProfileInput, PlanningGoalSelector, BreakPeriodsInput) with 90%+ test coverage
- Phase 3: Wired 4 page screens (Dashboard, Calendar, Tips, Settings) integrating hooks and components
- Final metrics: 0 TS errors, 0 lint errors, 167 tests passing, 16 total components

Frontend is unblocked and ready for integration or handoff.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 19:00:35 +02:00