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
This commit is contained in:
2026-09-21 07:20:14 +02:00
parent fc90cf9a5f
commit 782415122d
101 changed files with 9542 additions and 3930 deletions
+67 -57
View File
@@ -1024,30 +1024,26 @@ class TestLeaveWishNameMessageKeyRoundTrip:
# ---------------------------------------------------------------------------
# 9) is_pinned / wish_id ROUND-TRIP, AND SURVIVAL THROUGH /api/plan/sync
# (routers/planned_days.py bug — both fields were accepted by the request
# schema and understood by PlanningService, but silently dropped by the
# router before ever reaching the service, so "pin this day" never
# persisted). One shared registration for both scenarios — see
# `TestLeaveWishNameMessageKeyRoundTrip` above for why: `/api/auth/register`
# is rate-limited to 10/minute and this module already registers a user per
# test class.
# 9) DayAssertion round-trip AND survival through /api/plan/sync
# (restructure step 3b — "pinned" moved off `planned_leave_day.is_pinned`,
# now dropped, onto `POST /api/day-assertions`; there is no
# `PUT /api/planned-days/{id}` any more, only reads). One shared
# registration for both scenarios — see `TestLeaveWishNameMessageKeyRoundTrip`
# above for why: `/api/auth/register` is rate-limited to 10/minute and this
# module already registers a user per test class.
# ---------------------------------------------------------------------------
class TestPlannedDayIsPinnedRoundTripAndSyncSurvival:
"""`PlannedLeaveDayCreate`/`Update` both carry `is_pinned`/`wish_id`, and
class TestDayAssertionRoundTripAndSyncSurvival:
"""`POST /api/day-assertions` is the sole way to pin a day now (there is
`PlanningService.add_planned_day`/`.update` both accept them — but the
router never forwarded either field, so a client setting `is_pinned:
true` (the "pin this day so the allocator won't retype it" gesture) had
it silently discarded. Both scenarios (the raw POST/PUT round trip, and
the end-to-end "survives a re-sync" behaviour it exists for) share a
single registration, same reasoning as `TestLeaveWishNameMessageKeyRoundTrip`
above.
no `PUT /api/planned-days/{id}` any more). Both scenarios (the raw
create/clear round trip, and the end-to-end "survives a re-sync"
behaviour pinning exists for) share a single registration, same
reasoning as `TestLeaveWishNameMessageKeyRoundTrip` above.
"""
async def test_is_pinned_round_trips_and_survives_a_subsequent_sync(
async def test_day_assertion_round_trips_and_survives_a_subsequent_sync(
self, client: AsyncClient, connection: aiosqlite.Connection
) -> None:
headers, ws_id, pd_uuid = await _register(client, "pin-roundtrip@example.com", "Alex")
@@ -1074,57 +1070,61 @@ class TestPlannedDayIsPinnedRoundTripAndSyncSurvival:
)
wish_id = wish["id"]
# --- part 1: raw POST/PUT round trip -----------------------------
# --- part 1: raw POST/DELETE round trip --------------------------
probe_date = _next_workday(date.today())
create_resp = await client.post(
"/api/planned-days",
"/api/day-assertions",
headers=headers,
json={
"workspace_id": str(ws_id),
"child_id": str(child_id),
"parent_personal_data_uuid": str(pd_uuid),
# An ad-hoc probe row for the is_pinned round trip below —
# the exact date is irrelevant except that it must be legal
# to plan as `vacation` (never a free day; see
# `PlanningService._reject_illegal_free_day`).
"date": _next_workday(date.today()).isoformat(),
# An ad-hoc probe assertion — the exact date is irrelevant
# except that it must be legal to plan as `vacation` (never
# a free day; see `app.services.day_guards.
# reject_illegal_free_day`).
"date": probe_date.isoformat(),
"day_type": "vacation",
"is_dubbeldag": False,
"wish_id": wish_id,
"is_pinned": True,
},
)
assert create_resp.status_code == 201, create_resp.text
created = create_resp.json()
assert created["is_pinned"] is True, (
"is_pinned must round-trip through POST /api/planned-days, not be silently "
"dropped by the router"
)
assert created["day_type"] == "vacation"
assert created["wish_id"] == wish_id
day_id = created["id"]
# The assertion mirrors immediately into `planned_leave_day` — no
# sync required to see it.
planned = await _list_planned_days(client, headers, child_id)
mirrored = next(d for d in planned if d["date"] == probe_date.isoformat())
assert mirrored["is_pinned"] is True, (
"a concrete DayAssertion must mirror into planned_leave_day immediately, "
"and the row must report is_pinned=true for it"
)
# Flip both fields via PUT — a partial update — and confirm the new
# values, not the create-time ones, come back.
update_resp = await client.put(
f"/api/planned-days/{day_id}",
# Clear it (by natural key — DELETE /api/day-assertions takes no id)
# before part 2 so it can't be mistaken for one of the wish's own
# placed days below.
clear_resp = await client.request(
"DELETE",
"/api/day-assertions",
headers=headers,
json={"is_pinned": False},
params={
"workspace_id": str(ws_id),
"child_id": str(child_id),
"parent_personal_data_uuid": str(pd_uuid),
"date": probe_date.isoformat(),
},
)
assert update_resp.status_code == 200, update_resp.text
updated = update_resp.json()
assert updated["is_pinned"] is False, (
"is_pinned must round-trip through PUT /api/planned-days/{id} too"
assert clear_resp.status_code == 204, clear_resp.text
after_clear = await _list_planned_days(client, headers, child_id)
assert not any(d["date"] == probe_date.isoformat() for d in after_clear), (
"clearing the assertion must also remove the mirrored planned_leave_day row"
)
# wish_id was not mentioned in this PUT — must be left untouched.
assert updated["wish_id"] == wish_id
# Delete the ad-hoc probe row before part 2 so it can't be mistaken
# for one of the wish's own placed days below.
del_resp = await client.delete(f"/api/planned-days/{day_id}", headers=headers)
assert del_resp.status_code == 204, del_resp.text
# --- part 2: a pinned day survives a subsequent /api/plan/sync --
# the end-to-end behaviour users actually rely on `is_pinned` for:
# the end-to-end behaviour users actually rely on pinning for:
# "changing a day's funding type pins it so the allocator won't
# retype it".
@@ -1138,18 +1138,28 @@ class TestPlannedDayIsPinnedRoundTripAndSyncSurvival:
pinned_day = wish_days[0]
original_day_type = pinned_day["day_type"]
# Retype it to whatever it currently is NOT, mirroring "the user
# hand-edited this day's funding type" — then pin it so a re-sync
# must leave it alone.
# hand-edited this day's funding type" — then assert it so a
# re-sync must leave it alone.
new_day_type = "sjukpenning" if original_day_type != "sjukpenning" else "vacation"
pin_resp = await client.put(
f"/api/planned-days/{pinned_day['id']}",
pin_resp = await client.post(
"/api/day-assertions",
headers=headers,
json={"day_type": new_day_type, "is_pinned": True},
json={
"workspace_id": str(ws_id),
"child_id": str(child_id),
"parent_personal_data_uuid": str(pd_uuid),
"date": pinned_day["date"],
"day_type": new_day_type,
"wish_id": wish_id,
},
)
assert pin_resp.status_code == 200, pin_resp.text
assert pin_resp.json()["is_pinned"] is True
assert pin_resp.json()["day_type"] == new_day_type
assert pin_resp.status_code == 201, pin_resp.text
pinned_after_assert = await _list_planned_days(client, headers, child_id)
pinned_row = next(d for d in pinned_after_assert if d["date"] == pinned_day["date"])
assert pinned_row["is_pinned"] is True
assert pinned_row["day_type"] == new_day_type
# Re-sync: a fresh propose would want this date back at
# `original_day_type` (the wish's natural funding choice) — the
@@ -1157,7 +1167,7 @@ class TestPlannedDayIsPinnedRoundTripAndSyncSurvival:
await _sync(client, headers, ws_id)
after = await _list_planned_days(client, headers, child_id)
after_row = next(d for d in after if d["id"] == pinned_day["id"])
after_row = next(d for d in after if d["date"] == pinned_day["date"])
assert after_row["is_pinned"] is True
assert after_row["day_type"] == new_day_type, (
"a pinned day must survive a subsequent /api/plan/sync without being "