Files
F-r-ldraledigplannerare/ideas.md
T
2026-09-20 17:16:09 +02:00

18 KiB
Raw Blame History

Ideas

Opinionated proposals from agents while working on the App. Agents are allowed to append ideas that they find.


1. Multi-child is half-built

The tips engine now runs per child. The rest does not really. TipsContext was single-child until a fix two commits ago; the dashboard shows one child at a time via a header selector; the age-4 maths is per (parent, child) but nothing shows two children's deadlines side by side.

A second child is the common case for this product's users, and their windows overlap in ways worth seeing together — days spent on one child are days not spent on the other, and two fourth birthdays mean two cliffs.

Suggestion: decide whether multi-child is a supported scenario. If yes, the timeline on Översikt is the natural place to show both; if no, say so in the UI rather than half-supporting it.

2. The planner cannot explain itself

Every funded day already carries a reason code (vacation_wrap_red_day, fp_interior, swap_first_half, dubbeldag, …) and every window produces a WindowOutcome with a cost breakdown and a strategy. None of it reaches the user. The calendar shows what was decided, not why.

"Why is this a vacation day and not a parental-leave day?" is the question this product exists to answer, and the data is already on the wire.

Suggestion: surface the reason in the day popover — one line, plain Swedish. Cheapest high-value change on this list.

3. Flexible periods can move without warning

A flexible wish is re-placed on every sync, and sync now runs on every edit. A summer period can therefore shift because you edited something in December. The change toast reports it, but a toast is ephemeral and the user may not connect cause to effect.

Suggestion: mark a flexible period visually as "placed by the planner, may move", and offer a one-click pin to fix it where it is. The machinery exists — is_pinned already protects days.

4. Dismissed tips could hide a growing problem

Dismissals expire when the tip's data changes, which is the right instinct. But preserve_for_age_4 is a warning whose numbers change slowly and monotonically — dismiss it in year two and it may not resurface until the figure has moved materially, by which point less can be done.

Suggestion: exempt the age-4 warnings from dismissal entirely, or make them re-surface on a calendar cadence rather than only on data change.

5. Vacation is modelled more thinly than parental leave

Parental-leave days have tiers, reserved portions, transferability, dubbeldagar and an age-4 ceiling. Vacation has total_days and saved_days_in per year, plus a statutory floor on what must be taken. Real Swedish vacation has more shape than that — collectively agreed extra days, employer rules about carry-over beyond the statutory five years, and the distinction between earned and advance days.

Not obviously worth building. Worth knowing that vacation is the weaker half of the model, given the app's premise is optimising the two together.

6. Smaller things

  • ParentFacts.name is dead. Carried through the allocator, never read. Removing it churns fixtures across 580+ tests, which is why it survived a cleanup sweep. Do it opportunistically.
  • isCreatingPeriod has no test. It gates the preset rail's busy state and drag re-entry, and is only incidentally exercised. A merge with syncHook.isSyncing looks right but has no safety net, so it was left alone.
  • ProposedDayApplyItem and makeProposedDayApplyItem are dead on the frontend — leftovers of the removed direct-apply path.
  • E2E is flaky in parallel. All specs share one demo workspace and race on auto-sync. CI pins workers: 1, so this only bites locally. Fixing it properly means per-spec workspaces.
  • Promote the E2E job to blocking once it has a run of green builds. It is deliberately non-blocking today.
  • total_days_per_child is now computed, which forecloses an env override for multiples. The assumption is that Swedish multiples rules add days at the sjukpenning tier, so the derivation still holds — worth confirming before anyone builds twins support.

7. Vacation forfeiture: the planner detects the loss and then does nothing about it (full plan)

The problem, quantified. The seeded demo workspace shows 46 forfeiting vacation days for one parent. This is not a demo artifact — the mechanism that would prevent it structurally cannot fire for most of a typical year, for a reason explained below. PLANNING_ENGINE_SPEC.md §9.2 records that the very first demo run had the same shape of bug: "finishing every year with 8–14 unused semesterdagar" even after §Task 2's bias was designed. Whatever the exact number, the pattern is structural, not incidental.

Root cause: the allocator only ever writes inside a wish's window. Allocator.propose (backend/app/services/allocation_service.py:2773) has exactly two passes: fixed wish windows (_expand_all_windows, line 598) and flexible wish windows (_expand_flexible_wish_years, line 647). There is no third pass, and sync_plan (backend/app/services/planning_service.py:814) calls nothing but PlanningEngineService.propose (line 883), which wraps this same Allocator. Every ProposedDay the engine ever emits traces back to a date inside [wish.window_start, wish.window_end]. A date with no wish covering it is never visited by fund_window, _assign_tier3, or anything else — it simply never enters the loop. My suspicion going in (per the task brief) was that this is the root cause, and having read propose() end to end, it is: there is no code path capable of placing a day the user did not, in some form, ask a wish to cover.

Why §Task 2's bias doesn't save you. The forfeiture-avoidance bias lives in _assign_tier3 (allocation_service.py:1203, decision at 1242-1250): when a parent's (parent, year) pair is forecast to forfeit vacation (AllocationInput.vacation_forfeiting, computed by AllocationInputLoader from AnalyticsService._vacation_year_series's FIFO carry-forward bank — same simulation the Analytics tab and the use_vacation_before_expiry tip both read, so the figures agree), it reorders which resource pays for a day that is already being funded — vacation first instead of FP first, even for an hourly parent who'd normally spend FP first. That is all it does. It is a payment-method tiebreak for goal-dependent interior days (§3.3 tier 3) inside a window a wish already created. It cannot manufacture a leave day where no wish asked for one. A parent whose wishes cover 6-8 weeks a year (typical: summer + jullov + a few long weekends, per scripts/seed_demo.py) has ~44-46 weeks of workdays every year where the allocator's placement logic literally never runs, and that is exactly where unused vacation lives.

The other half of the existing machinery is purely advisory. WARNING_VACATION_EXPIRING (allocation_service.py:2686, _vacation_expiry_warnings) and the use_vacation_before_expiry tip (backend/app/services/tips_service.py:112) both correctly compute the forecast loss and tell the user a number and a year-end date. Neither places a day. REMEDY_CARRY_VACATION_FORWARD (allocation_service.py:1833, _carry_vacation_forward_remedy) looks adjacent but solves the opposite problem: it tells a shortfall ("this window is short 12 days") that spare vacation from an earlier year could cover it — it's a funding-source suggestion for an under-filled wish, not a forfeiture fix. SURPLUS_USE_LESS_VACATION (line ~2591) is the FP-surplus mirror, also advisory. So today: the system knows the exact number, knows the exact deadline (year-end), and communicates both — a nag, correctly computed, that nobody acts on before the days are gone.

Proposed mechanism: a third, non-wish placement pass that proposes rescue days. After passes 1 and 2 in Allocator.propose(), for every (parent, year) where year == alloc_input.today.year (see below for why not future years) and the post-placement forfeiture is still > 0 — i.e. re-run exactly the subtraction _vacation_expiry_warnings already does (line 2686-2744) rather than a second forecast — generate up to that many candidate workdays between today and Dec 31 of that year that are: not already a free day (is_free_day), not already occupied (_existing_lookup/this-run's own placements), not in excluded_days. Rank candidates by adjacency to a red-day cluster (_red_day_cluster_dates, line 510 — reuse it, don't reinvent it) so a rescue day preferentially extends an existing weekend/holiday into a real day off instead of landing as an isolated Tuesday with no value; among ties, prefer later dates (closer to the deadline leaves more runway for the user's own wishes to claim the day first on a later sync). Emit these as ProposedDays with a new reason code (e.g. REASON_VACATION_RESCUE) and wish_id = None — do not attach them to a synthetic wish, and do not silently fold them into an existing WindowOutcome. They need to be visually and semantically distinct from a wish-funded day: the user didn't ask for this date off, the planner is telling them "spend it here or lose it."

Do not auto-commit these to the plan. Treat them exactly like surplus_advice today: a proposal the user acts on, not a fact the sync silently writes. This matches how _use_less_vacation_advice and _carry_vacation_forward_remedy already work (data, not ProposedDays) — but rescue days are concrete dates, not just a count, so they belong closer to ProposedDay/WindowOutcome shape than to SurplusAdvice's abstract days_involved. Suggest a new top-level PlanProposal.vacation_rescue: list[ProposedDay] field, kept separate from days (line 460), so nothing downstream that iterates days (balance netting, calendar rendering, exports) picks them up by accident before the user accepts. Accepting one (or "accept all") writes it through the same ad-hoc write path a user manually clicking a calendar day already uses today (wish_id is None, noted at planning_service.py:53 and the restructure notes' "promoting an ad-hoc band" at line 1050) — no new persistence mechanism needed for acceptance, only new generation logic.

Why only today.year, not the whole horizon. vacation_forfeiting is populated for every year from today.year through horizon_end.year (allocation_service.py:313-325), and it would be easy to "fix" all of them at once. Don't. A future year's forecast is provisional — it assumes the household adds no more wishes between now and then, which is false by construction (that's the whole point of sync_plan re-running on every edit). Suggesting concrete October-2028 rescue dates in September 2026 is manufacturing false precision about a number that will legitimately keep changing. Only the current year has a real, non-negotiable deadline (Dec 31 of this year) with no more wish-adding time left after it passes. Keep WARNING_VACATION_EXPIRING as the informational signal for future years — it already does that job correctly — and reserve the new mechanism for the year where the deadline is actually imminent.

Interaction with the wish model. This deliberately does not touch how wishes work. A wish is "I want to be home for N weeks in this window" — a positive intent. A rescue day is the opposite shape: "you have a day you're about to lose, spend it or don't." Bolting it onto the wish model (a synthetic auto-generated wish) would force it through LeaveWishService's materialisation, priority ordering, and repeat-year machinery for something that is a one-shot, current-year-only, non-negotiable nudge — that's a lot of borrowed complexity for a suggestion the user might reject outright. Keeping it as its own small pass in the allocator, emitting its own output shape, is smaller and doesn't risk _expand_all_windows/_expand_flexible_wish_years needing to know about "wishes nobody made."

Interaction with restructure step 3 (day_assertion). Genuinely easier after step 3, not harder, but land it after, not concurrently. The accept-a-rescue-day write is, in both worlds, "insert an ad-hoc leave commitment for this date" — today that's an ad-hoc planned_leave_day row (wish_id NULL); after step 3 it's a day_assertion row with wish_id NULL, which is explicitly called out as a first-class case in the restructure notes ("ad-hoc = assertion with wish_id NULL"). The generation logic (the new pass inside Allocator.propose()) never touches persistence at all — it's pure, like the rest of the allocator — so it is not coupled to step 3's schema change and doesn't need to be redone once step 3 lands. The only coupling is the acceptance endpoint, which is a few lines either way. Given the restructure's own rule — "Run STRICTLY SERIALLY — all steps touch Calendar.tsx/planning_service.py/allocation_service.py" — and that this feature touches exactly those same three files, sequence it after step 3 (or at minimum, not overlapping any in-flight step) purely to avoid conflicting edits to allocation_service.py, not because of any deeper technical dependency.

What's genuinely uncertain — needs a product-owner call, not a guess:

  • Auto-apply vs. suggest-only. Restructure step 2 turns eviction into "informational warning + Undo" rather than a blocking choice. The same UX pattern — place the rescue days automatically and let the user undo — is arguably more consistent with that direction than a separate accept/reject UI, and would actually solve "vacation gets lost" instead of adding a fourth nag next to three existing ones. But it also means the app puts dates on someone's calendar they didn't request, which is a bigger behavioral commitment than anything else the planner does today (every other auto-placement traces back to a wish the user created). I'd want this decided explicitly, not inferred.
  • Ranking heuristic. "Prefer extending a red-day cluster" is my judgment call, not a rule found in the repo. A reasonable alternative is "spread evenly so no month gets clustered" or "prefer Fridays to build long weekends without touching a holiday cluster at all." Not a legal question, a taste question — flagging rather than deciding unilaterally.
  • No new Swedish-law figure is needed. statutory_vacation_days_per_year (25), min_vacation_days_per_year floor (20), and vacation_carry_forward_years (5) — all in backend/app/config.py:60-73, all already cited to semesterlagen in the surrounding comments — are exactly the inputs this mechanism needs and none are new. The one caveat already on record (ideas.md §5): collectively-bargained extra days and non-statutory carry-over rules aren't modelled at all, so a rescue suggestion inherits whatever fidelity gap already exists there. Not something this plan should try to fix in passing.

Staged implementation, smallest-useful-first:

  1. Nothing in the allocator yet. Improve the existing use_vacation_before_expiry tip and WARNING_VACATION_EXPIRING to name the actual last-safe-to-act date (year-end) more prominently in the UI copy than they do today — cheap, ships this week, and makes the existing correct forecast easier to act on manually. Almost certainly insufficient alone (it's the status quo's mechanism, and it hasn't worked), but it's free and worth doing regardless of what follows.
  2. Add the rescue-day generation pass to the pure Allocator, gated behind a feature flag/config default-off, current year only, emitted on PlanProposal.vacation_rescue, not written anywhere. Fully testable in isolation (same pure-function guarantee as the rest of the engine) before any UI exists to show it — this is most of the actual engineering risk and can be validated with unit tests against the existing §Task 2 test fixtures before touching the frontend at all.
  3. Surface it in the calendar/dashboard as a distinct visual affordance (not a normal band — the user didn't ask for it) with one-click accept-one / accept-all / dismiss. Dismiss should behave like the tip-dismissal system already does (§4 above) — expire when the underlying number changes, not forever.
  4. Only after 1-3 are live and observed for a season: revisit whether default behaviour should shift toward auto-apply-with-undo, per the open question above. Do not decide that up front from a description of the problem alone.

What I would NOT do:

  • Would not make the existing _assign_tier3 bias more aggressive (e.g. spending vacation on free-standing days by loosening what counts as "interior"). It's already correctly scoped to "inside a window a wish created" — the bug isn't that the bias is weak, it's that most of the calendar year never reaches it. Turning up a dial on a mechanism that structurally can't reach the problem wastes effort and risks unrelated regressions in wish-funded placement, which today is working correctly.
  • Would not model employer-approval friction for a rescue day (whether the household could actually get that specific date off work). Out of scope by the same abstraction line CLAUDE.md already draws around money/SGI — this app doesn't model employer scheduling constraints anywhere else either, and a rescue suggestion the user still has to act on doesn't need to.
  • Would not extend this to future horizon years (see above) or to FP-surplus-at-age-4 (already has its own, better-suited mirror mechanism in SurplusAdvice — no need to unify the two; they differ in urgency shape: age-4 surplus is years away and gradual, vacation forfeiture is a hard Dec 31 cliff).

Deliberately not proposed

  • Replacing the hand-rolled fetch hooks with a generic factory or a query library. The ~14 hooks look repetitive but differ in load-bearing ways (background-refresh flags, window parameters, differing refetch semantics). A generic version would need enough escape hatches to cost more than it saves.
  • Unifying the two band-building functions (CalendarWeek.computeBandSegments, YearTimeline.buildBands). Same word, different problems: one groups by wish identity with gap-bridging and lane assignment at week granularity, the other by day-type majority at year granularity.
  • Collapsing /propose and /apply into /sync. They are the pure, independently testable primitives underneath it. Keeping them is what makes the allocator exercisable without a database.