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:
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user