Files
F-r-ldraledigplannerare/ideas.md
T
jacobh2 782415122d Separate user intent from planner output via day assertions
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
2026-09-21 07:20:14 +02:00

93 lines
4.9 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 --> in progress
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.
---
## 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.