`uv run pytest` reported 26 DeprecationWarnings, 20 of them raised by
code we own. Two independent sources, both already renamed upstream.
Starlette renamed HTTP_422_UNPROCESSABLE_ENTITY to
HTTP_422_UNPROCESSABLE_CONTENT, matching the RFC 9110 name for the
status. Rename all 21 call sites across the twelve routers that raise
it. The numeric value is unchanged at 422, so the wire format and the
frontend's error handling are untouched -- this is forward-compat debt
only. The warnings attributed to fastapi/routing.py were these same
handler frames raising through the dependency call, and go with them.
Alembic >=1.16 supersedes `version_path_separator` with
`path_separator`, which governs both `prepend_sys_path` and
`version_locations`; its absence is what triggered the legacy-splitting
warning on every config load. Swap the key in alembic.ini.
Deliberately not fixing slowapi's `asyncio.iscoroutinefunction` warning
(6 of the 26): it is upstream with no released fix, and no
`filterwarnings` entry is added, so it stays visible as a reminder to
drop the pin once one lands.
Verified from backend/: ruff check and format clean, 893 passed with
coverage at 92%, and the warnings summary is now exactly those 6 slowapi
lines. `alembic upgrade head` runs 001 through 008 without the
path_separator warning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Warnings such as "vacation days at risk of being lost" kept showing after
the planner had already resolved them. The planner itself was firing
correctly -- PlanSyncProvider syncs on every plan-input change -- so the
fault was in the warnings, in two independent places.
The vacation-expiry warning was computed before allocation. It read
`vacation_forfeiting`, which the loader derives from already-committed
usage, while `_assign_tier3` deliberately spends at-risk vacation days
first precisely to avoid that forfeiture. The figure therefore counted
days the plan was in the middle of saving. `_vacation_expiry_warnings`
now subtracts the vacation days this run actually placed for that parent
and year, clamped at zero -- sound because the bank is FIFO, so a day
spent in year Y retires one expiring day one-for-one. The full
carry-forward simulation is still not duplicated inside the ledger.
The Dashboard then never refreshed them. Its proposal came from a
one-shot effect guarded by `proposal === null`, with no planVersion
dependency, so the card kept rendering the pre-replan warning until
remount -- while `syncPlan` was already returning a fresh proposal that
both call sites discarded. PlanSyncProvider now exposes that proposal
behind a monotonic counter, and usePlanProposal prefers it once any sync
has completed, keeping `propose()` for the first load.
Also adds a manual recompute button to Settings, which only bumps
planVersion and reports what the resulting sync did, and fixes partner
add/remove never bumping planVersion at all -- that gap meant gaining or
losing a parent never re-ran the allocator, though it changes who days
can be assigned to. Rename stays cosmetic.
Verified in the browser: a 2031 warning of 15 days dropped to 12 after
three vacation days were planned, without a reload.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jPFUcCJ5AKXmPVC1anhXe
Free days (Sat/Sun/red days, subject to DayOverride) can never be
vacation, and can only be parental leave when the workspace opts in via
take_pl_on_red_days. The allocator already honoured this; the hand-pin
path bypassed it entirely.
- PlanningService gains optional workspace_repo/day_override_repo deps
and _reject_illegal_free_day, enforced in add_planned_day and update.
Reuses allocation_service.is_free_day rather than re-deriving it, and
no-ops when the deps are unset so existing callers are unaffected.
- update_planned_day had no ValidationError handler at all, so a
validation failure there 500'd instead of 422'ing. Fixed.
- Calendar filters the pin write loop by the day's isFree; the modal
explains free days stay off, and that pinning opts a day out of
automatic re-planning.
Vacation days at risk of forfeiture were computed and sent over the
wire but never rendered: an at-risk column with the age-out/over-cap
split, and a dashboard key figure.
PlanProposal.warnings was likewise parsed but rendered nowhere, for all
three kinds. Adds PlanWarningCard in a Risks section above the tips,
with parent names resolved and dates formatted.
POST /api/plan/sync now fires from the workspace layer on every
plan-input change rather than only from Calendar, so a Settings edit or
a pinned-day save re-plans day types instead of merely refetching.
Overlapping bumps coalesce into one trailing run; a rejection cannot
wedge the in-flight guard. eviction_preview was silently dropped by the
frontend despite always being on the wire, and is now surfaced through
context.
Verified: backend 890 passed (91.49% coverage), frontend 1297 passed,
ruff/eslint/tsc clean. Browser-checked in Swedish and English.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.
project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.
_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.
_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.
Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.
Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.
Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.
Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.
Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.
Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard stated the loss and stopped there. With Jacob at 48/48 savable
and 24 days over the cap, and Amelie at 3/48 with 45 days of headroom, those
24 days are not lost at all — transferring them before the 4th birthday saves
every one. The app never said so.
`_rule_age4_pooling_loss` already computed exactly that advice, bounded by the
FK-reported `transferable_days_left`, in finished Swedish and English. It never
ran: `age4_pooling_tip_min_child_age_years` held it back until the child turned
2. The calendar IS the plan, so gate on the plan instead — the noise case is a
newborn with nothing planned, not a young child whose parents have planned in
detail. `age4_pooling_tip_min_planned_days` (20) replaces the age gate.
Both variants drop to `info`. A projection four years out should not read as a
verdict on the user's planning; the hero carries the alarm, this card carries
the advice. The copy now leads with "plans change, this is only a projection"
and closes by pointing at keeping balances up to date, since parents will come
back and record what they actually took.
- Suppress `preserve_for_age_4` whenever `age4_pooling_loss` fires for the same
child. The pooling tip states the same loss plus the remedy and the correct
per-parent cap, so emitting both gave two contradictory "you're losing N
days" figures.
- `preserve_for_age_4` now sends `parent_cap` and its copy interpolates it. It
claimed "only 96 days can be saved" — false for a two-parent family, where
the cap is 48 each, as the Age4Bars card two rows below already showed.
- The hero grows a "How to save them" line derived from the tip itself, so the
two can never disagree on the number. Dismissing the tip retires both.
- "Försäkringskassan" in that line links to FK's BankID login carrying a `goto`
to Min föräldrapenning — the e-service showing "Dagar ni kan föra över" —
so the transfer is one click away. `translate()` returns a plain string and
word order differs between locales, so the anchor's position travels inside
the template as `{fk}` and the rendered string is split around it.
Verified in the browser: hero, link target, tip severity and the absence of
`preserve_for_age_4`, in both languages and at 390px. Note the demo seed cannot
reach this shape — it gives the surplus parent no FK snapshot at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
The scraper never read "överlåtbara dagar" from Försäkringskassan. It only
visited ansok-och-planera-foraldrapenning#/valjdagar and derived the
reserved/transferable split in build_snapshot, assuming transferable days
are spent before reserved ones. On real data that gave 49 for the user and
0 for the partner where FK states 129 and 71.
The assumption cannot be repaired: reserved-first, transferable-first,
proportional and cap-based models were all checked against real figures and
none reproduces FK's accounting (reserved-first fits the partner but is 10
days off for the user). FK publishes the real numbers per parent per tier on
the min-foraldrapenning page, which the scraper had never visited.
- Scrape the "Dagar ni kan föra över" panel after the valjdagar extraction,
summing sjukpenningnivå + lägstanivå per parent. The totals are not
literals on the page. Parse only direct-child <p> elements: the first
group embeds a tooltip with decoy "45 dagar"/"90 dagar" text.
- build_snapshot accepts the reported value and derives reserved days from
it; the old arithmetic remains only as a fallback. An implausible FK value
degrades to that fallback rather than failing the fetch.
- Record provenance in transferable_days_reported (migration 006) and badge
derived values as estimates in the dashboard breakdown. Drop the footnote's
claim that at most 150 days are transferable — real data contradicts it as
a per-family figure.
- Select the child's section by name when FK lists several; never guess.
Verified against a live BankID scrape: scraped 240/101/139 with 129
transferable, deduced partner 240/169/71 with 71, both flagged as reported.
Incidental fixes found along the way:
- The fetch router never forwarded the partner's transferable figure to
persist_partner_deduction, so the partner's stored snapshot would have kept
the flawed split even once the parse landed.
- 'deduced' snapshots were labelled "Manually entered" by a two-way ternary
over a three-value union. Replaced with a compiler-checked exhaustive map.
- Alembic resolved its database from alembic.ini alone, ignoring
APP_SQLITE_PATH, so migrations silently targeted the developer's real
database even when pointed elsewhere. env.py now resolves it, preferring
-x sqlalchemy.url, then a real APP_SQLITE_PATH, then the ini value.
- backend/captures/ was not ignored: the root pattern was anchored as
/captures/ despite a comment saying scraper dumps must never be tracked.
- CLAUDE.md quoted test counts and coverage percentages that had gone stale,
which made a correct verification report look suspect. Gate thresholds kept.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
Each parent projection now carries one entry per calendar year from
birth to the fourth birthday: vacation earned, carried in, planned,
left, carried out and expiring under Semesterlagen's 20-day floor and
5-year carry-over, plus parental-leave days used and left at year end
with the age-4 clamp applied in the birthday year. The overview sums
them into per-year totals. Frontend types, mapper and fixtures follow.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
The end-to-end suite had never been run. Its first run failed six ways,
all of them the page sitting on the login screen: every browser page load
re-obtains an access token, the refresh endpoint allows thirty a minute
per address, and a twenty-test run makes about fifty of them from one
address. Half came back 429, and a failed refresh is indistinguishable
from being logged out. Raising the ceiling would only move it and make a
green suite depend on how fast the machine is, so the limiter is switched
off for the test backend alone — it measures a client, and the runner is
not one. It stays on everywhere else.
With that gone, one real failure was left, and it belonged to the commit
that claimed to have fixed it: cell widths were held, but a week whose
cell carries a holiday name still stood taller than its neighbours,
because each week is its own grid and none can see another's height. The
weeks now share one grid whose rows are all a single fraction, so the
tallest sets the rest and no cell can grow its own row.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
Taking one day out of a period the planner placed did nothing that
lasted: the period still asked for a full week, so the next sync put the
day straight back. The option was disabled rather than lying about it.
A removed day is now recorded as its own thing — no leave for this
parent, this child, this date — and the planner honours it on every
later sync. It is kept apart from the working-day overrides, which
answer a different question about the calendar rather than about the
plan.
The day belongs to the parent whose band was clicked, so removing one
parent's day leaves their partner's alone; where both parents were to be
off on one funding arrangement, the partner is simply funded on their
own instead. A removed day shortens what the period is asking for rather
than counting as a day the plan failed to find, so the advice about
closing a shortfall does not start arguing for a day nobody wants.
Nothing is removed without a way back: the toast undoes it, and the day
itself is marked in the calendar and offers to restore.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
A repeating period was a single row that the planner expanded into one
window per year at read time, so no year had anywhere to differ from the
others. Editing one year rewrote them all, and removing a year in the
middle had to split the wish in two. Neither survives contact with the
thing this app is for: Easter moves, and the cheapest week is not the
same week every year.
Each year is now its own row, linked by a series id. Editing one year
touches one row; editing the series re-projects the rest from it.
Removing a year is one delete. The weekday snap is worked out once, when
the rows are made, and stored, so a row now says what it means rather
than being re-derived on every read. Recurrence stops being a stored
field and becomes what it always was in practice — an instruction at
create and update time; whether a period repeats is whether it belongs
to a series.
Migration 004 rebuilds the table and expands the existing rows, keeping
each original row as its first year so the days already planned against
it still point somewhere. A Feb 29 anchor recurring into a common year
used to raise out of the allocator; it clamps now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
A year that cannot afford the whole week was already funded as far as it
goes rather than dropped; there is now a test that says so, across four
years of a repeating wish where only three and a half are affordable.
The advice to move a window onto the red days was looking three days
either side, which can never find Easter. It now searches four weeks
either way — enough for Easter's drift plus the weekday snap — and ranks
placements with the same cost model flexible periods are already placed
with, naming the dates and the days gained.
None of this reached the screen before. The overview now lists each short
window with its gap and the ranked remedies underneath, one line each.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
A season preset in a two-parent household now means both parents, not
either one — being off together is the point of the period.
Within such a period the planner used to pick whoever had the most
parental-benefit days left, date by date, so spending kept moving the
lead and the funding flipped almost daily. Six weeks came out as a
patchwork that costs nothing to hold but a great deal to request. The
payer is now chosen once per half of the window, reusing the same split
the swap strategy already uses: one parent on parental benefit while the
other takes vacation, then they change over. Both are still off every
day, and if one runs out mid-stretch the roles swap for the rest rather
than day by day.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
Asking for a full week off never spent anything on the Saturday, the
Sunday or a holiday, and a test now says so outright rather than leaving
it to be rediscovered. Those days are free because vacation cannot be
taken on a day that is not a working day.
Parental benefit is a payment rather than a schedule, though, so it can
be drawn on a red day to turn an unpaid day off into a paid one — at the
cost of a day the family would otherwise keep. That is now a workspace
setting, off by default, and it never reaches for a vacation day. With it
off the planner behaves exactly as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
A week off is a week off — Monday to Sunday this year means Monday to
Sunday next year, not the same two calendar dates landing mid-week. A
fixed yearly wish now snaps each later year's start to the nearest date
carrying the first year's weekday and holds the span, so 5-11 October
2026 recurs as 4-10 October 2027.
Flexible wishes are untouched: their four date fields are a bounding
range the allocator searches inside, not a placement, so a weekday shift
there would mean nothing. The first year is always returned unshifted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
Manual entry now takes one parent's sjukpenningnivå, lägstanivå and
dubbeldagar left (plus optional reserved days) and derives the rest in
build_snapshot, shared with the scraper. The scraper also reads the
family panel and exposes a partner deduction; with two parents it is
saved as a 'deduced' snapshot (migration 002). Parent count is the single
source of entitlement: one member gets 480 days and 96 savable at age 4,
two get 240 and 48 each. Workspaces are capped at two parents.
Also lands the in-flight squash of migrations 002–011 into the initial
schema and the removal of planning_mode.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Backend: Alpine runtime without Chromium by default, opt-in Debian
scraper target with Playwright. Frontend: Vite build served by
nginx-unprivileged with the API upstream resolved at start.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Security
- Every route now requires a login and refuses ids outside the caller's
workspace with 404: plan, leave-wishes, day-overrides and vacation-days
were fully anonymous; every other router trusted client-supplied
workspace/child/parent ids. Scraper sessions are bound to their owner.
- Invitations can no longer be created for a foreign workspace, and a
redeemer who is already a member (or a full workspace) is refused.
- Production refuses the default JWT secret; API docs are hidden in
production; a failed migration aborts startup instead of serving.
- Login and register no longer reveal whether an email exists; inactive
accounts are rejected; /auth/refresh is rate limited.
- The FK scraper writes debug artefacts only when explicitly enabled,
logs no scraped values or page text, clears the BankID QR as soon as it
is scanned and purges finished sessions after five minutes.
- Real FK page captures are removed from the tree (history rewrite
follows); the parser tests use a synthetic fixture.
Performance and correctness
- Plan apply/sync run inside one locked transaction with batched writes
instead of one commit per row, so a failure mid-batch leaves nothing
half-applied and concurrent syncs no longer corrupt each other.
- SQLite runs in WAL mode with a busy timeout; 14 indexes cover the
filters that were full scans; argon2 hashing runs off the event loop;
responses are gzipped.
- Frontend: a 401 now refreshes the token once and retries, so sessions
no longer die silently after 15 minutes; every data hook discards
stale responses; logout clears state even when the server call fails.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
The per-parent day split was written out twice, once in the allocator's input
loader and once in the persistence layer, each with a comment recalling the
time the two disagreed. They now share one derivation, so they cannot drift
apart again without the shared function moving.
Three constants that were stored and happened to equal arithmetic on their
neighbours — the child's total, the per-parent share, and the per-parent
ceiling at age four — are now computed from them. The figures are unchanged;
what changes is that a future adjustment to a tier total carries into them
instead of quietly leaving them behind.
The shortfall remedies were also split into one method per remedy, matching
the surplus advice alongside them, which had already been written that way.
A hand-rolled month-arithmetic module survived the move to dateutil with no
caller left but its own tests, and two predicates alongside it were never
wired up at all. An error import was also being deferred inside a function
for a circular dependency that does not exist.
There was no README at the root, and the backend's own still pointed at an
onboarding guide that had been deleted. The new one says what the app is, how
to run it, and where the code lives, and states the test gates as they
actually are rather than as an older note claimed.
It records the two things that waste a newcomer's afternoon: the dev server
answers on localhost but not on the loopback address, because Vite binds IPv6
first, and the two design documents are largely superseded and say so inline.
Deleting a period left every day it had funded behind. The link from a day
back to its period was set to null the instant the period was deleted, so the
days became untraceable before anything could clean them up, and the next
recalculation had no way to know they no longer belonged to anything. Undo had
the same fault for the same reason: the period went, the days stayed, and a
reload showed them still there.
The days are now removed with the period, before the link can be broken. A day
pinned by hand goes too — pinning keeps the planner from quietly retyping a
day, not from being deleted when you delete the period it belongs to, which is
already how an unplanned band behaves.
A period shared between two parents was cut at its calendar midpoint and each
half handed over regardless of whether that parent could pay for it. A window
crossing New Year could therefore lose its entire first half to a parent whose
budget for that year was spent, while the other parent's budget for the very
same days went untouched. In the case that prompted this, eight days were
reported as unfundable when none of them were.
The cut now falls where the cost is even rather than where the calendar is,
since weekends and red days are free and spread unevenly through a period.
Whatever one parent still cannot cover is offered to the other before any of
it is written off. Each parent keeps one unbroken stretch at home.
Three faults, all invisible while the tests passed. Advice was built for the
first child only, so a second child got none. The planned days feeding every
count were fetched once per parent and appended to one list, so a couple's
days were counted twice and every intensity and projection derived from them
was wrong. And dismissing a tip forgot the moment the page reloaded.
Tips now carry the date they concern, lifted from figures the rules already
computed, so a view of a month can show only what applies to it. They are
built per child and identified per child, so the same rule firing for two
children is two separate pieces of advice.
A dismissal is stored against a fingerprint of the facts that were dismissed.
When those facts change the advice returns with its new numbers, rather than
one click silencing a warning about a forfeiture that keeps growing.
Only ninety-six days survive a child's fourth birthday, and with two parents
that is forty-eight each rather than a shared pool. A parent sitting on sixty
days while the other has ten therefore forfeits twelve that the family could
have kept, and nothing said so.
The advice leads with taking the days as leave before the birthday, which
turns them into actual time at home, and offers transferring only as the
fallback that merely preserves them. The transferable figure is bounded by
what Försäkringskassan actually reports as transferable, since the reserved
days cannot move, and the copy says plainly that a transfer happens there and
not in this app. Without the partner's figures the tip hedges rather than
inventing a number.
Registering used to land you on a bare form asking for a child, with no hint
what came next. A short wizard now walks through the child, whether you are
planning alone or together, and where the day balances come from: fetched from
Försäkringskassan, entered by hand, or skipped — and if skipped, the figures
carry a badge saying they are estimates until real ones arrive.
Fetching is the first option rather than a hidden one, so the scraper is on by
default and the script that used to force it on no longer needs to.
Planning and Tips lose their tabs. Authoring moved to the calendar and the
figures moved to the overview, so both pages were only a second way to reach
what is already on screen; their routes now redirect rather than dead-end. The
dead translations went with them.
Dates also follow the chosen language now instead of always formatting as
Swedish.
The create and update handlers for a planned day accepted both fields in
their request models and passed neither to the service, so a day hand-edited
in the calendar was never marked as pinned and never kept its link back to
the period that produced it. The allocator would then feel free to retype it
on the next sync, which is precisely what pinning exists to prevent.
Two investigations alongside this found no defect. A period that occurs once
rather than yearly does get placed, and a window that wraps past New Year
keeps the half that falls after today; both are now covered by tests, since
neither case had any. Where those looked broken, the wish had simply run out
of budget for that year, which the interface does not yet explain.
Nine tests each rebuilt the same ten-line create call to vary one argument,
which buried the rule every case was actually protecting. They are now one
parametrised test over a shared base, with an id per case so a failure names
the rule rather than an index.
The count of assertions is unchanged: the month and day cases were already
parametrised and are kept as separate entries rather than compressed, since
losing a case to tidy the file would defeat the point. The tests asserting a
successful create stay separate, because each checks a distinct resulting
value rather than merely that nothing raised.
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.
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.
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.
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.
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.
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.
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
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
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
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
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
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
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
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
New components (all with .tsx + .test.tsx, 90%+ coverage):
- CalendarDay: day cell with event badges
- CalendarEvent: colored event chip with dismiss option
- DaysBreakdown: table of days by type
- ProgressChart: progress bar toward age-4 deadline
- TipCard: optimization tip card with severity badges
- VacationDaysInput: number input with min/max validation
- ParentProfileInput: dual selects for salary type & employment status
- PlanningGoalSelector: radio buttons for planning goal
- BreakPeriodsInput: list manager with inline add/edit form
All components follow existing patterns from Header/Footer:
- Typed props interfaces (exported)
- Semantic HTML with data-testid & ARIA attributes
- No business logic, purely presentational
- Comprehensive test coverage including edge cases
npm test: 22 files, 167 tests passing
npx tsc --noEmit: clean
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>