Merge branch 'worktree-make-planner-explaining-itself'

Clicking a planned day raises the calendar toast with a plain-language
reason (sv + en) taken from the live in-memory proposal; the backend
field-parity walk now covers ProposedDay.

Conflicts in Calendar.tsx / Calendar.test.tsx against the day-assertions
restructure and the two earlier merges; both intents kept:

- usePlannedDayExclusions stays replaced by useDayAssertions; the branch's
  usePlanProposal watch, reasonProposal state, reasonByDay map and the
  handleBandClick reason toast are added alongside main's band logic.
- runSync keeps the security branch's background refresh and also records
  the sync's proposal for the reason toast. main's applyWishSyncResult
  (every wish save on this page) records result.sync.proposal the same way,
  so an edit's fresh reasons reach the toast — the stale-response guards
  in usePlanSync/usePlanProposal only gate hook state, never the returned
  response the reason rides in on.
- One main test ("no success toast when sync-with-wish throws") now
  dismisses the click's own reason toast first; its assertion is unchanged.

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:50:44 +02:00
9 changed files with 393 additions and 1 deletions
+24 -1
View File
@@ -37,7 +37,7 @@ from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParenta
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
from app.services.allocation_service import PlanProposal, WindowOutcome
from app.services.allocation_service import PlanProposal, ProposedDay, WindowOutcome
# Fixed identity for the authenticated caller in every test in this file.
# Each test creates its own workspace + "parent" PersonalData row — that row
@@ -889,6 +889,16 @@ _PLAN_PROPOSAL_ALLOWLIST: dict[str, str] = {}
_WINDOW_OUTCOME_RENAMES: dict[str, tuple[str, ...]] = {}
_WINDOW_OUTCOME_ALLOWLIST: dict[str, str] = {}
# Same idea again for `ProposedDay` -> `ProposedDayResponse` -- added
# specifically because `reason` (the machine-readable code the frontend's
# calendar toast now reads, see `frontend/src/utils/planReasons.ts`) was
# never walked by this mechanism: nothing would have failed here if
# `from_domain()` had silently dropped it, the same class of bug this file's
# `_assert_domain_fields_reach_the_wire` exists to catch for `PlanProposal`/
# `WindowOutcome`. Every field is a straight same-name passthrough today.
_PROPOSED_DAY_RENAMES: dict[str, tuple[str, ...]] = {}
_PROPOSED_DAY_ALLOWLIST: dict[str, str] = {}
def _wire_keys_for(domain_field: str, renames: dict[str, tuple[str, ...]]) -> tuple[str, ...]:
"""The wire key(s) `domain_field` must appear as, absent an explicit rename."""
@@ -1226,6 +1236,19 @@ class TestPlanProposalFieldParityWithDomain:
check_nonempty=False,
)
# `ProposedDay` -> `ProposedDayResponse`: was never walked before
# (see `_PROPOSED_DAY_RENAMES`'s docstring) -- this is what would have
# caught `reason` being silently dropped from the wire.
for day in body["days"]:
_assert_domain_fields_reach_the_wire(
ProposedDay,
_PROPOSED_DAY_RENAMES,
_PROPOSED_DAY_ALLOWLIST,
day,
context="ProposedDay -> ProposedDayResponse",
check_nonempty=False,
)
# `remedies` is a list-of-dataclass field inside WindowOutcome too —
# confirm the shortfall outcome specifically has a non-empty one
# (the generic walk above only guarantees each outcome's own