256 lines
18 KiB
Markdown
256 lines
18 KiB
Markdown
# 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
|
||
`ProposedDay`s 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 `ProposedDay`s) — 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.
|