A lower-priority 'both' wish (e.g. a yearly autumn week) quietly fell back
to one parent per day once summer/Jul had used both parents' vacation, and
still counted as funded, so nothing warned.
- Allocator rescue pass: frees a vacation day by switching one parent's
vacation to FP on another date (interior v+v, then Mon/Fri bridge days
without creating an FP weekend wrap, then solo days) and spends it on the
one-parent date as co-leave. Pinned days count as present.
- WindowOutcome.unmet_both_dates + new both_parents_unmet plan warning.
- Frontend maps the new fields; Dashboard warning card, year-row flag and a
Calendar save-toast line list the affected years (sv + en).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vacation rescue/wrap in allocation_service, new plan models/endpoints,
VacationRescueTip, useRescueDayActions, vacationSwaps util, sv+en i18n,
Dashboard/Calendar wiring.
Conflicts came from main's "day assertions" restructure and the earlier
merges; both intents kept:
- plan.py: both import sets; main's sync-with-wish handler kept next to the
branch's new /rescue-days/dismiss endpoint; HTTP_422_UNPROCESSABLE_CONTENT
applied to the branch's code (and a survivor in auth.py).
- Accepting a rescue day now goes through POST /api/day-assertions (an
ad-hoc 'vacation' assertion, no wish) instead of /plan/apply with an
`is_pinned` flag that no longer exists: on main a pin IS a day assertion
(see PlanningService.add_planned_day's docstring), and an assertion is
what keeps a later sync from retyping the day. useRescueDayActions,
its tests, VacationRescueTip's tests and the backend integration flow
follow that contract; applyPlan stays as a client for the live endpoint
minus the dropped field. The hook also gets the security branch's
useLatestRequest guard with an unconditionally cleared busy flag.
- Calendar.tsx: `locale` threaded into main's applyWishSyncResult call of
buildSyncToastMessage (the branch's new parameter); the branch's
swap-visibility toast tests drive the preset path through syncWithWish,
which is how main saves a wish now.
- types/api.ts: ProposedDayApplyItem kept (used by applyPlan) without
isPinned; the Add/UpdatePlannedDayRequest types stay removed as on main.
- fixtures/plan.ts: ResourceShortage import dropped (type removed on main).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.
Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.
On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.
Bugs found and fixed along the way:
- The plan erased its own history. Fixed windows clip to today, so an elapsed
auto-placed day fell into `stale_keys` and was deleted on the next sync —
and a background sync runs on every workspace load. Past rows are now frozen
and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
(`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
without a transaction; a crash mid-write diverged the tables or half-deleted
a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
edited occurrence's outcome, so a fully unfunded later year rendered as an
empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
have failed the whole suite before its first test.
Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.
Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
The planner knew exactly how many semesterdagar a parent was about to
forfeit and never placed a single day to prevent it. It also had no
weekend-wrap logic at all: REASON_VACATION_WRAP_WEEKEND was stamped on
every tier-3 interior vacation day, including a lone Tuesday in February.
Two changes.
Spend better. A workday adjacent to a free stretch, or in the middle of a
gap of three or fewer fenced by free days, now takes vacation --
_vacation_bridge_reason, consulted for every parent type. This departs
from spec 6.1's "wrapping suppressed for hourly": the FP-preservation
argument is salary-type independent, only the pay argument was hourly-moot.
In a both-parents window, both now take vacation on such a date rather
than one paying with FP. After funding, reallocate_vacation_by_value swaps
vacation onto higher-ranked dates 1-for-1 with FP days, so which dates hold
vacation improves while how many never drops -- greedy first, re-timed
after, never withheld. An invariant test asserts the count can only rise.
Name the losses. A new pass proposes concrete dates for vacation that would
otherwise expire, current year only, ranked red-day bridge then weekend
bridge then interior, later dates first. They ride on
PlanProposal.rescue_days, kept off `days` so nothing downstream commits
them, and surface on the Dashboard with accept-one, accept-all (one
batched /api/plan/apply, not N) and dismiss. Dismissal reuses the
tip_dismissal table, keyed on (parent, year) identity rather than the
group's dates -- those are recomputed from the whole plan's residual on
every propose, so date-hashing made dismissal self-defeating. Dismiss does
not bump the plan version; it changes no data and must not trigger a write.
Measured on the seeded demo: 12 forfeiting days for one parent now yield 12
proposed dates, all Fridays or Mondays. Total vacation placed is unchanged
at 203 days, confirming the reallocation only re-times.
Three defects here were found by driving a browser, while 2273 unit tests
passed -- all three lived in the seam between layers that unit tests mock
away. Adds API-level tests over the real request sequence. Rate limiting is
now off in tests (one file consumed the whole registration budget) with the
suite's first real 429 assertion.
PROPOSE_VS_SYNC_BUG.md documents a pre-existing, unfixed issue found along
the way: Dashboard figures swing on the first sync with no user action.
Backend 946 tests, frontend 1341, coverage above all gates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm
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
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>
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
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