9.9 KiB
Free days, vacation forfeiture, and auto-replanning
Context
Three feedback points from todo.md (human-owned file — do not edit it; item 1 there, the yearly-repeat bug, is owned by another worker and is out of scope here):
- A full-week selection must not burn days on Sat/Sun/red days. You can never take vacation on a free day; you can take PL on one (more money, fewer days banked) — that must be an opt-in setting defaulting to off.
- Vacation days at risk of being forfeited should push the planner toward spending vacation instead of PL days.
- Every event that changes the days off must trigger a re-plan of the day types, not just a refetch.
Exploration shows the backend already implements most of 1 and 2. The real gaps are: the hand-pin path bypasses the free-day invariant entirely, recalculation only fires from the Calendar page for some events, and vacation forfeiture is invisible in the UI.
What already exists (reuse, do not rebuild)
| Piece | Where | State |
|---|---|---|
take_pl_on_red_days workspace column + migration 003 + PUT /api/workspace/:id |
backend/app/entities/workspace.py, app/services/workspace_service.py |
done, defaults False |
| Settings toggle + sv/en strings | frontend/src/pages/Settings.tsx:496, src/i18n/{sv,en}/settings.ts:108-110 |
done |
Allocator free-day handling (free day ⇒ 'free', or FP via REASON_FP_RED_DAY only when the flag is on; vacation never) |
allocation_service.py _fund_solo:1271, _fund_both:~1518 |
done, tested at tests/unit/test_services/test_allocation_service.py:2616+ |
is_free_day(d, holidays_map, overrides) — the single definition |
allocation_service.py:470 |
reuse everywhere |
vacation_forfeiting bias in _assign_tier3 |
allocation_service.py:1171-1225, fed from AnalyticsService._vacation_year_series at :2935 |
done |
VacationYearBalance.expiring / expiring_age / expiring_over_cap on the wire |
analytics_service.py:47, frontend/src/types/entities.ts:251 |
computed, never rendered |
POST /api/plan/sync (full re-plan + persist + diff) |
planning_service.py:662, usePlanSync, Calendar.tsx runSync:365 |
done |
WorkspaceContext.planVersion / bumpPlanVersion() cross-instance invalidation |
frontend/src/context/WorkspaceContext.tsx:25-36,179 |
done; bumped only by usePlannedDays, useDayOverrides, usePlannedDayExclusions |
Task 1 — Free days must never consume vacation (hand-pin path)
Selecting a range that spans Sat/Sun/red days is fine and stays fine. The planner already skips those days correctly. The hole is the "advanced" day popover: Calendar.tsx handleSavePinnedDay:571 writes one planned_leave_day row per selected date, free days included, and PlanningService.add_planned_day:241 accepts day_type='vacation' on a Saturday without complaint.
Backend (authoritative guard). In backend/app/services/planning_service.py:
- Add optional
workspace_repo+day_override_repoconstructor deps, following the existing optional-dependency pattern of_engine/_leave_wish_repo(docstring the reason; keepscripts/seed_demo.pyand test fixtures working unchanged when unset). - New private helper
_reject_illegal_free_day(workspace_id, date, day_type)used by bothadd_planned_dayandupdate: build the holiday map +DayOverrideService.get_effective_map(workspace_id), callis_free_day(import fromallocation_service, do not re-derive), then raiseValidationErrorwhenday_type == 'vacation'and the day is free (never legal), orday_type in ('sjukpenning','lagsta')and the day is free andworkspace.take_pl_on_red_daysisFalse.
- Skip the check entirely when the deps are unset (
None), so behaviour is byte-identical for callers that never wire them;app/dependencies.pywires the real ones. - Error goes through
app/utils/errors.py, message keyed so the frontend can translate.
Frontend. In Calendar.tsx handleSavePinnedDay, filter the write loop by the day's isFree from calendarDaysByDate (already loaded, CalendarDayEntry.isFree):
vacation→ skip free days always.sjukpenning/lagsta→ skip free days unlessworkspace.takePlOnRedDays.- If every selected date was skipped, show the existing modal error rather than silently doing nothing.
PlannedDayModalshows a one-line note when the selection contains free days that will be left as days off, with a pointer to the setting. New sv/en keys insrc/i18n/{sv,en}/calendar.ts.
Tests. Backend: add_planned_day/update reject vacation on a Saturday and on a red day; reject PL on a free day with the flag off; accept PL on a free day with it on. Frontend: pinning a Mon–Sun range with vacation creates 5 rows, not 7; with takePlOnRedDays: true and sjukpenning, creates 7.
Task 2 — Surface vacation days at risk
The numbers are already on the wire and unused.
frontend/src/components/vacation-days/VacationDaysTable.tsx— add an "at risk" column renderingVacationYearBalance.expiring, with a danger style when> 0and a tooltip/help line splittingexpiringAge(too old to carry) fromexpiringOverCap(above the carry-forward ceiling). FollowAge4Bars.tsx/KeyFigures.tsxfor the existing danger-ink pattern; size text by content per the bilingual CSS rule.frontend/src/components/dashboard/KeyFigures.tsx— a tile mirroring the existingdashboard-figure-expiringfor vacation, so the reason the plan shifted toward vacation days is visible.PlanWarningkind'vacation_expiring'already exists; confirm it renders wheredays_lost_at_age_4does and add the missing sv/en strings if not.- Tests: table shows the column and the danger state; zero-expiring renders the neutral state.
No backend change — _vacation_year_series and the _assign_tier3 bias stay as they are.
Task 3 — Re-plan on every plan-input change (global)
Today runSync is Calendar-only and fires once per workspace on mount plus after wish/preset/exclusion edits. Pinned-day saves and deletes, and every Settings change, do not re-plan.
Move the trigger into the workspace layer so it is route-independent:
- New
frontend/src/context/PlanSyncProvider.tsx(mounted insideWorkspaceProvider, or folded into it — implementer's call, keepWorkspaceContext's surface stable): auseEffecton[workspaceId, planVersion]that callsapi.syncPlan(workspaceId)silently, guarded by an in-flight ref so overlapping bumps coalesce into one trailing run, and by a "skip the very first render if Calendar is about to sync anyway" check is not needed — a duplicate silent sync is idempotent and cheap, but the in-flight guard must still prevent a stampede.- Cycle safety:
bumpPlanVersionis never called from a fetch effect (usePlannedDays.ts:84-93documents this), andsyncPlandoes not bump. Verified no loop. - On an
eviction_previewcoming back (nothing written, needs a user choice), the silent path must not discard it — surface it through context so Calendar can prompt on next visit, or at minimum leave the plan untouched and log throughsrc/services/errors.ts. Never swallow it into an empty catch.
- Cycle safety:
- Add the missing bumps so every plan input participates.
useVacationDays.upsert,usePreschoolPlanningDays(after its 800 ms debounce settles),useChildSettings.update,api.updateParentSettings' caller inSettings.tsx, andhandleToggleTakePlOnRedDaysall callbumpPlanVersion()on success — they currently do not.usePlannedDays,useDayOverrides,usePlannedDayExclusionsalready do. - Calendar keeps its explicit
runSynccalls for the paths that need a toast + undo (wish create/edit/delete, presets, exclusions). Those write viauseLeaveWishes, which does not bump, so there is no double sync. Replace thedidAutoSyncRefmount-once effect with reliance on the provider, or keep it — it becomes redundant but harmless; prefer removing it to keep one trigger. handleSavePinnedDay/handleDeletePlannedDayneed no new call: they write throughusePlannedDays, which bumps, which now drives the global sync. This is what makes item 2's forfeiture bias actually retype existing unpinned days after a calendar edit.
Known limit to state plainly in the UI, not silently: a pinned day is excluded from re-planning by design (sync_plan passes only_pinned_existing=True). The forfeiture bias can therefore never retype a hand-pinned day. The popover note from Task 1 is the right place to say pinning opts that day out of optimization.
Tests. Frontend: a bumpPlanVersion() from a non-Calendar hook triggers exactly one api.syncPlan; two rapid bumps coalesce to one trailing call; a failing sync does not wedge the in-flight ref.
Verification
Delegate all full-suite runs to a Haiku subagent (per AGENTS.md), serialized — one suite at a time, never parallel:
# backend/
uv run ruff check . && uv run ruff format --check . && uv run pytest
# frontend/
npm run typecheck && npm run lint && npm run coverage
Require raw verbatim output with exact pass/fail counts and coverage percentages; gates are backend --cov-fail-under=80 and frontend lines 96 / functions 91 / branches 92 / statements 96. Never lower a gate.
New migration is not needed — no schema change.
End-to-end, delegated to a Sonnet subagent driving Chrome (never the coordinator), on npm run dev, checked in both Swedish and English:
- Select Mon–Sun in the calendar, "advanced" → vacation. Assert 5 planned days, Sat/Sun untouched.
- Settings → tick "Ta ut föräldrapenning på röda dagar" → return to calendar, repeat with
sjukpenning. Assert 7 days. - Settings → edit a vacation-days row → assert a background
POST /api/plan/syncfires (network panel) without visiting the Calendar page. - Enter a vacation balance that forfeits days → assert the at-risk column shows it and the plan's day mix shifts toward vacation.
Then graphify update ..