Files
F-r-ldraledigplannerare/frontend/src/utils/planReasons.ts
T
jacobh2 1a69221040 Let a planned day say why it was placed
Every funded day the allocator places already carries a machine-readable
`reason` code — the constants block in allocation_service.py even says it
exists "so the UI can explain itself" — but no user has ever seen one.
The calendar showed what was decided, never why, which is the one question
this product exists to answer.

Clicking a planned day now also raises the existing calendar toast with a
plain-Swedish (and English) sentence for that date and parent: a pinned day
says the user chose it, an allocator-placed day explains the placement, and
anything unrecognised — or a session where no proposal has landed yet —
shows nothing rather than a raw code.

The reason is deliberately not persisted: it is stripped at apply and
`planned_leave_day` has no column for it, so the source is the live
in-memory proposal, merged last-writer-wins from Calendar's own sync
response and PlanSyncProvider's latest. Before the first sync of a session
there is simply no explanation to show.

Also extends the backend's field-parity walk to ProposedDay, which it did
not cover — the reason could have been dropped from the wire without a
single test failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 07:41:44 +02:00

37 lines
1.7 KiB
TypeScript

/**
* Maps an allocator `reason` code (`ProposedDay.reason` — the
* `REASON_*` constants in `backend/app/services/allocation_service.py:62-83`,
* §3.5, "machine-readable so the UI can explain itself") to the
* `planReasons.<code>` translation key that explains it in the calendar
* toast (see `Calendar.tsx`'s `handleBandClick`).
*
* Follows `frontend/src/utils/rejectionMessages.ts`'s precedent: an
* exhaustive `Record` from the backend-defined code to a `TranslationKey`,
* so an unrecognised or empty reason returns `null` rather than ever
* putting a raw code on screen. Every one of the allocator's 10 reason
* codes has an entry here even though only the non-free ones are reachable
* in practice (a free day renders no band, so it can never be clicked) —
* kept exhaustive so the map can't quietly fall behind the backend's list.
*/
import type { TranslationKey } from '@/i18n'
const REASON_KEYS: Record<string, TranslationKey> = {
free_weekend: 'planReasons.free_weekend',
free_red_day: 'planReasons.free_red_day',
vacation_wrap_red_day: 'planReasons.vacation_wrap_red_day',
vacation_wrap_weekend: 'planReasons.vacation_wrap_weekend',
fp_interior: 'planReasons.fp_interior',
fp_lagsta_stretch: 'planReasons.fp_lagsta_stretch',
swap_first_half: 'planReasons.swap_first_half',
swap_second_half: 'planReasons.swap_second_half',
dubbeldag: 'planReasons.dubbeldag',
fp_red_day: 'planReasons.fp_red_day',
}
/** Empty or unrecognised `reason` -> `null`; callers (`Calendar.tsx`) render
* nothing rather than a raw code or a generic "unknown" string. */
export function reasonMessageKey(reason: string): TranslationKey | null {
if (!reason) return null
return REASON_KEYS[reason] ?? null
}