From 3a1b5d5661a3ab920390f59a01f4fec247aceaf7 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 20 Sep 2026 11:31:48 +0200 Subject: [PATCH] New plan for new feature --- .../free-days-and-auto-planning-plan.md | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 new-features/free-days-and-auto-planning-plan.md diff --git a/new-features/free-days-and-auto-planning-plan.md b/new-features/free-days-and-auto-planning-plan.md new file mode 100644 index 0000000..5686d59 --- /dev/null +++ b/new-features/free-days-and-auto-planning-plan.md @@ -0,0 +1,102 @@ +# 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): + +1. **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. +2. **Vacation days at risk of being forfeited should push the planner toward spending vacation** instead of PL days. +3. **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_repo` constructor deps, following the existing optional-dependency pattern of `_engine` / `_leave_wish_repo` (docstring the reason; keep `scripts/seed_demo.py` and test fixtures working unchanged when unset). +- New private helper `_reject_illegal_free_day(workspace_id, date, day_type)` used by both `add_planned_day` and `update`: build the holiday map + `DayOverrideService.get_effective_map(workspace_id)`, call `is_free_day` (import from `allocation_service`, do not re-derive), then raise `ValidationError` when + - `day_type == 'vacation'` and the day is free (never legal), or + - `day_type in ('sjukpenning','lagsta')` and the day is free and `workspace.take_pl_on_red_days` is `False`. +- Skip the check entirely when the deps are unset (`None`), so behaviour is byte-identical for callers that never wire them; `app/dependencies.py` wires 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 unless `workspace.takePlOnRedDays`. +- If every selected date was skipped, show the existing modal error rather than silently doing nothing. +- `PlannedDayModal` shows 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 in `src/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 rendering `VacationYearBalance.expiring`, with a danger style when `> 0` and a tooltip/help line splitting `expiringAge` (too old to carry) from `expiringOverCap` (above the carry-forward ceiling). Follow `Age4Bars.tsx` / `KeyFigures.tsx` for the existing danger-ink pattern; size text by content per the bilingual CSS rule. +- `frontend/src/components/dashboard/KeyFigures.tsx` — a tile mirroring the existing `dashboard-figure-expiring` for vacation, so the reason the plan shifted toward vacation days is visible. +- `PlanWarning` kind `'vacation_expiring'` already exists; confirm it renders where `days_lost_at_age_4` does 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 inside `WorkspaceProvider`, or folded into it — implementer's call, keep `WorkspaceContext`'s surface stable): a `useEffect` on `[workspaceId, planVersion]` that calls `api.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: `bumpPlanVersion` is never called from a fetch effect (`usePlannedDays.ts:84-93` documents this), and `syncPlan` does not bump. Verified no loop. + - On an `eviction_preview` coming 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 through `src/services/errors.ts`. Never swallow it into an empty catch. +- **Add the missing bumps** so every plan input participates. `useVacationDays.upsert`, `usePreschoolPlanningDays` (after its 800 ms debounce settles), `useChildSettings.update`, `api.updateParentSettings`' caller in `Settings.tsx`, and `handleToggleTakePlOnRedDays` all call `bumpPlanVersion()` on success — they currently do not. `usePlannedDays`, `useDayOverrides`, `usePlannedDayExclusions` already do. +- **Calendar keeps its explicit `runSync` calls** for the paths that need a toast + undo (wish create/edit/delete, presets, exclusions). Those write via `useLeaveWishes`, which does *not* bump, so there is no double sync. Replace the `didAutoSyncRef` mount-once effect with reliance on the provider, or keep it — it becomes redundant but harmless; prefer removing it to keep one trigger. +- `handleSavePinnedDay` / `handleDeletePlannedDay` need no new call: they write through `usePlannedDays`, 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: + +```bash +# 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: +1. Select Mon–Sun in the calendar, "advanced" → vacation. Assert 5 planned days, Sat/Sun untouched. +2. Settings → tick "Ta ut föräldrapenning på röda dagar" → return to calendar, repeat with `sjukpenning`. Assert 7 days. +3. Settings → edit a vacation-days row → assert a background `POST /api/plan/sync` fires (network panel) without visiting the Calendar page. +4. 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 .`.