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>
This commit is contained in:
2026-09-21 07:41:44 +02:00
parent fc90cf9a5f
commit 1a69221040
9 changed files with 384 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."""
@@ -1250,6 +1260,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
+4
View File
@@ -163,6 +163,10 @@ export const calendar = {
'Remove {date} for {parent}? The day stays removed until you restore it.',
'calendar.toastDayExcluded': '{date} removed for {parent}',
'calendar.toastDayRestored': '{date} restored for {parent}',
// Clicking a planned day's band (`handleBandClick`) — explains why that
// day got the type it got, read off the freshest plan proposal held in
// memory (see `frontend/src/utils/planReasons.ts`).
'calendar.toastDayReason': '{date}, {parent}: {reason}',
'calendar.periodDeleteConfirmYear': 'Are you sure you want to remove this period (this year only)?',
'calendar.periodDeleteConfirmAllYears': 'Are you sure you want to remove the period — every year?',
'calendar.wantsDubbeldagLabel': 'Allow double days (dubbeldagar)',
+28
View File
@@ -6,6 +6,34 @@
// (`SurplusAdviceTip.tsx`) and `frontend/src/utils/rejectionMessages.ts`
// still render.
export const plan = {
// `planReasons.*` — the allocator's per-day `reason` code
// (`ProposedDay.reason`, `backend/app/services/allocation_service.py`'s
// REASON_* constants, §3.5) explained in the calendar's day-click toast
// (`Calendar.tsx`'s `handleBandClick`, via `frontend/src/utils/
// planReasons.ts`). Wording is derived from what each constant's producer
// site actually does (the constants block plus `_fund_solo`, `_fund_swap`,
// `_fund_both`), not from the code name alone — see that file's
// docstring. `planReasons.pinned` is the one non-code entry: a day the
// user locked by hand, where the allocator's reason doesn't apply.
'planReasons.dubbeldag':
'Double day — both parents are home the same day, which draws one day from each parent\'s balance.',
'planReasons.fp_interior':
'An ordinary day in the period, funded with a parental-benefit day at sjukpenningnivå (income-based rate).',
'planReasons.fp_lagsta_stretch':
'A parental-benefit day at lägstanivå (minimum rate) was used here to make the period stretch further.',
'planReasons.fp_red_day':
'A day that would otherwise have been free (weekend or red day) was taken as parental leave instead, because the "Take parental leave on red days" setting is turned on.',
'planReasons.free_red_day': 'Red day — no day was used.',
'planReasons.free_weekend': 'Weekend — no day was used.',
'planReasons.pinned': 'You locked this day yourself — the planner doesn\'t choose it for you.',
'planReasons.swap_first_half':
'The first part of the period was placed with you, as part of how the leave was split between you two.',
'planReasons.swap_second_half':
'The second part of the period was placed with you, as part of how the leave was split between you two.',
'planReasons.vacation_wrap_red_day':
'A vacation day was placed next to a red day so the whole stretch became free, without spending more parental-benefit days.',
'planReasons.vacation_wrap_weekend':
'A vacation day was used for this day — the combination that made the best use of your vacation and parental-benefit days together.',
'planResults.remedyBodyCarryVacationForward': '{spare_total} {spare_total|vacation day|vacation days} from earlier years can be carried forward',
'planResults.remedyBodyLowerPriorityWish': 'Lowering {wish_name} from {from_weeks} to {to_weeks} {to_weeks|week|weeks} frees {days_freed} {days_freed|day|days}',
'planResults.remedyBodyReducePreschoolReserve': 'Reducing the preschool planning-day reserve ({reserved} reserved) frees {days_freed} {days_freed|day|days}',
+4
View File
@@ -164,6 +164,10 @@ export const calendar = {
'Ta bort {date} för {parent}? Dagen stannar borta tills du återställer den.',
'calendar.toastDayExcluded': '{date} togs bort för {parent}',
'calendar.toastDayRestored': '{date} lades tillbaka för {parent}',
// Klick på en planerad dags band (`handleBandClick`) — förklarar varför
// just den dagen fick den typ den fick, läst från den senaste
// planförslaget i minnet (se `frontend/src/utils/planReasons.ts`).
'calendar.toastDayReason': '{date}, {parent}: {reason}',
'calendar.periodDeleteConfirmYear': 'Är du säker på att du vill ta bort den här perioden (bara det här året)?',
'calendar.periodDeleteConfirmAllYears': 'Är du säker på att du vill ta bort perioden — alla år?',
'calendar.wantsDubbeldagLabel': 'Tillåt dubbeldagar',
+28
View File
@@ -6,6 +6,34 @@
// (`SurplusAdviceTip.tsx`) and `frontend/src/utils/rejectionMessages.ts`
// still render.
export const plan = {
// `planReasons.*` — the allocator's per-day `reason` code
// (`ProposedDay.reason`, `backend/app/services/allocation_service.py`'s
// REASON_* constants, §3.5) explained in the calendar's day-click toast
// (`Calendar.tsx`'s `handleBandClick`, via `frontend/src/utils/
// planReasons.ts`). Wording is derived from what each constant's producer
// site actually does (the constants block plus `_fund_solo`, `_fund_swap`,
// `_fund_both`), not from the code name alone — see that file's
// docstring. `planReasons.pinned` is the one non-code entry: a day the
// user locked by hand, where the allocator's reason doesn't apply.
'planReasons.dubbeldag':
'Dubbeldag — båda föräldrarna är hemma samma dag, vilket drar en dag ur vardera förälders saldo.',
'planReasons.fp_interior':
'En vanlig dag i perioden, finansierad med en föräldrapenningdag på sjukpenningnivå.',
'planReasons.fp_lagsta_stretch':
'En föräldrapenningdag på lägstanivå användes här för att få perioden att räcka längre.',
'planReasons.fp_red_day':
'En dag som annars hade varit ledig (helg eller röd dag) togs ut som föräldrapenning i stället, eftersom inställningen "Ta ut föräldrapenning på röda dagar" är påslagen.',
'planReasons.free_red_day': 'Röd dag — ingen dag användes.',
'planReasons.free_weekend': 'Helg — ingen dag användes.',
'planReasons.pinned': 'Du har låst den här dagen själv — planeraren väljer den inte åt dig.',
'planReasons.swap_first_half':
'Periodens första del lades hos dig, som en del av hur ledigheten delades mellan er två.',
'planReasons.swap_second_half':
'Periodens andra del lades hos dig, som en del av hur ledigheten delades mellan er två.',
'planReasons.vacation_wrap_red_day':
'En semesterdag lades intill en röd dag så att hela sträckan blev ledig, utan att fler föräldrapenningdagar behövde användas.',
'planReasons.vacation_wrap_weekend':
'En semesterdag användes den här dagen — det gjorde mest nytta av semester- och föräldrapenningdagarna tillsammans.',
'planResults.remedyBodyCarryVacationForward': '{spare_total} {spare_total|semesterdag|semesterdagar} från tidigare år kan sparas framåt',
'planResults.remedyBodyLowerPriorityWish': 'Sänk {wish_name} från {from_weeks} till {to_weeks} {to_weeks|vecka|veckor} frigör {days_freed} {days_freed|dag|dagar}',
'planResults.remedyBodyReducePreschoolReserve': 'Minska förskolans planeringsdagar ({reserved} reserverade) frigör {days_freed} {days_freed|dag|dagar}',
+141
View File
@@ -14,6 +14,7 @@ import {
useWorkspaceMembers,
usePlanSync,
usePlannedDayExclusions,
usePlanProposal,
} from '@/hooks'
import { LanguageProvider } from '@/i18n'
import type { PlannedDayModalProps } from '@/components/modals/PlannedDayModal'
@@ -35,10 +36,12 @@ import {
makeWindowOutcome,
makeRejection,
makePlanProposal,
makeProposedDay,
makePlanApplyResponse,
makePlannedDayExclusionsResult,
makePlannedDayExclusion,
makeWorkspace,
makePlanProposalResult,
} from '../../tests/fixtures'
import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar'
@@ -66,6 +69,7 @@ vi.mock('@/hooks', () => ({
useWorkspaceMembers: vi.fn(),
usePlanSync: vi.fn(),
usePlannedDayExclusions: vi.fn(),
usePlanProposal: vi.fn(),
}))
vi.mock('@/components/layout/Layout', () => ({
@@ -293,6 +297,7 @@ describe('Calendar', () => {
vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers)
vi.mocked(usePlanSync).mockReturnValue(mockSync)
vi.mocked(usePlannedDayExclusions).mockReturnValue(mockExclusions)
vi.mocked(usePlanProposal).mockReturnValue(makePlanProposalResult())
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams])
@@ -1015,6 +1020,142 @@ describe('Calendar', () => {
})
})
describe('band click also shows the day reason toast', () => {
it('shows the reason toast for a day present in the freshest proposal', () => {
const today = todayDateString()
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
}),
)
vi.mocked(usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
days: [makeProposedDay({ date: today, parentPersonalDataUuid: 'person-1', reason: 'fp_interior' })],
}),
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
const toast = screen.getByTestId('calendar-toast')
expect(toast).toHaveTextContent('En vanlig dag i perioden')
})
it('shows the pinned copy instead, for a day the user locked by hand', () => {
const today = todayDateString()
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({
id: 'pd-1',
date: today,
wishId: null,
parentPersonalDataUuid: 'person-1',
isPinned: true,
}),
],
}),
)
// Even though the proposal has a real reason for this exact day, a
// pinned day was chosen by the user, not the planner — pinned copy
// must win.
vi.mocked(usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
days: [makeProposedDay({ date: today, parentPersonalDataUuid: 'person-1', reason: 'fp_interior' })],
}),
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
const toast = screen.getByTestId('calendar-toast')
expect(toast).toHaveTextContent('Du har låst den här dagen själv')
expect(toast).not.toHaveTextContent('En vanlig dag i perioden')
})
it('shows no toast when no proposal has landed yet', () => {
const today = todayDateString()
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
}),
)
// Default `usePlanProposal` mock (`data: null`) — no sync has
// completed for this session yet.
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
})
it('shows no toast for an unrecognised reason code', () => {
const today = todayDateString()
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
}),
)
vi.mocked(usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
days: [
makeProposedDay({
date: today,
parentPersonalDataUuid: 'person-1',
reason: 'some_future_reason_not_yet_mapped',
}),
],
}),
}),
)
render(<Calendar />)
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
})
it('shows the clicked parent\'s own reason on a both-parents date, not the partner\'s', () => {
const today = todayDateString()
vi.mocked(usePlannedDays).mockReturnValue(
makePlannedDaysResult({
data: [
makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1', dayType: 'sjukpenning' }),
makePlannedLeaveDay({ id: 'pd-2', date: today, wishId: null, parentPersonalDataUuid: 'pd-b', dayType: 'vacation' }),
],
}),
)
vi.mocked(usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
days: [
makeProposedDay({ date: today, parentPersonalDataUuid: 'person-1', reason: 'fp_interior' }),
makeProposedDay({ date: today, parentPersonalDataUuid: 'pd-b', reason: 'vacation_wrap_weekend' }),
],
}),
}),
)
render(<Calendar />)
const bands = screen.getAllByTestId(/^calendar-band-/)
expect(bands).toHaveLength(2)
// The self band's aria-label starts with the logged-in parent's own
// name (`resolveParentName` falls back to `parentName` for
// `personalDataUuid`, "Förälder A" here — see `makeWorkspaceContextValue`).
const selfBand = bands.find((b) => b.getAttribute('aria-label')?.startsWith('Förälder A'))!
fireEvent.click(selfBand)
const toast = screen.getByTestId('calendar-toast')
expect(toast).toHaveTextContent('En vanlig dag i perioden')
expect(toast).not.toHaveTextContent('gjorde mest nytta av semester')
})
})
describe('§Task7 preset rail', () => {
it('creates the selected preset as a flexible, yearly wish', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
+73
View File
@@ -11,6 +11,7 @@ import {
useWorkspaceMembers,
usePlanSync,
usePlannedDayExclusions,
usePlanProposal,
} from '@/hooks'
import type {
PlannedLeaveDay,
@@ -21,6 +22,7 @@ import type {
LeaveWishUpdateRequest,
PersonalData,
PlanChange,
PlanProposal,
PlanSyncResponse,
WindowOutcome,
} from '@/types'
@@ -35,6 +37,7 @@ import { Icon } from '@/components/common/Icon'
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, parseInputDate } from '@/utils/dates'
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
import { reasonMessageKey } from '@/utils/planReasons'
import { useTranslation } from '@/i18n'
import './calendar.css'
@@ -315,6 +318,20 @@ export function Calendar(): ReactNode {
const [flashWishId, setFlashWishId] = useState<string | null>(null)
const [toast, setToast] = useState<CalendarToast | null>(null)
const [isUndoing, setIsUndoing] = useState(false)
// The freshest `PlanProposal` seen so far, from whichever of two sources
// last produced one — last-writer-wins, no ordering assumed between them:
// - `runSync` below sets it straight from its own `PlanSyncResponse`.
// - `usePlanProposal`'s `data` (which already prefers `PlanSyncProvider`'s
// `latestProposal` once a sync has landed, see that hook's doc comment)
// is watched in an effect further down and overwrites it too.
// Calendar runs its OWN `usePlanSync` (`syncHook` above), which is not the
// provider's, so `PlanSyncProvider.latestProposal` can lag this page's own
// sync and vice versa; this is what `handleBandClick`'s reason toast reads
// — see `reasonByDay` below. `null` until the first sync of the session
// completes, in which case the toast shows nothing rather than a
// placeholder — the reason is deliberately NOT persisted (no column on
// `planned_leave_day`), so a live proposal is the only source there is.
const [reasonProposal, setReasonProposal] = useState<PlanProposal | null>(null)
const isLoading = wsLoading || plannedDaysHook.isLoading
@@ -334,6 +351,10 @@ export function Calendar(): ReactNode {
// The days the user removed by hand. Read for the calendar's badges, and
// written by the popover's "this day" remove — see `excludeDay`.
const exclusionsHook = usePlannedDayExclusions(workspace?.id ?? null)
// Not used for its own `propose()` here — Calendar never calls it — only
// watched for `PlanSyncProvider`'s freshest proposal, one of the two
// sources `reasonProposal` merges (see that state's doc comment above).
const proposalHook = usePlanProposal(workspace?.id ?? null)
const calendarDaysByDate = useMemo(() => {
const map = new Map<string, CalendarDayEntry>()
@@ -351,6 +372,26 @@ export function Calendar(): ReactNode {
[exclusionsHook.data],
)
// `${date}|${parentPersonalDataUuid}` -> the allocator's reason for that
// day — the same two-part key `getExistingDay` uses below, because a
// both-parents day has two `ProposedDay` rows (one per parent) with
// different reasons. Read by `handleBandClick`'s toast.
const reasonByDay = useMemo(() => {
const map = new Map<string, string>()
for (const day of reasonProposal?.days ?? []) {
map.set(`${day.date}|${day.parentPersonalDataUuid}`, day.reason)
}
return map
}, [reasonProposal])
// Whichever of the two `reasonProposal` sources (`runSync`'s own response,
// `proposalHook.data`) produced a proposal LAST wins — see that state's
// doc comment. Keyed on `proposalHook.data`'s identity so this only fires
// on an actual new proposal, not every render.
useEffect(() => {
if (proposalHook.data) setReasonProposal(proposalHook.data)
}, [proposalHook.data])
/** The display name for a band's owner. Used wherever the UI has to say
* WHOSE day something is — above all the "this day" remove, which takes one
* parent's day and leaves the partner's. Falls back the same way
@@ -400,6 +441,7 @@ export function Calendar(): ReactNode {
): Promise<PlanSyncResponse | undefined> => {
try {
const result = await syncHook.sync(workspaceId)
setReasonProposal(result.proposal)
await Promise.all([plannedDaysHook.refresh(), calendarHook.refetch()])
if (!options?.silent) {
const message = buildSyncToastMessage(
@@ -740,10 +782,41 @@ export function Calendar(): ReactNode {
}
}
/** The period popover behaviour is unchanged below — this only adds the
* "why this day" toast alongside it. Resolution order:
* 1. The clicked (date, parent) has a `PlannedLeaveDay` with `isPinned`
* — the planner didn't choose this day, the user did; say so.
* 2. Else look the reason up in `reasonByDay` (from the freshest
* in-memory proposal) and translate it via `reasonMessageKey`.
* 3. Else — no proposal yet, or an unrecognised code — no toast at all.
* Never a raw code, never a generic "unknown".
* Prefixed with the date and parent the same way `excludeDay` does, so a
* both-parents day's toast says whose day it's explaining. */
const handleBandClick = (info: BandClickInfo): void => {
setActiveBand(info)
setIsPeriodOpen(true)
setPeriodError(null)
const existingDay = getExistingDay(info.clickedDate, info.parentPersonalDataUuid)
let reasonText: string | null = null
if (existingDay?.isPinned) {
reasonText = t('planReasons.pinned')
} else {
const reason = reasonByDay.get(`${info.clickedDate}|${info.parentPersonalDataUuid}`)
const key = reason ? reasonMessageKey(reason) : null
if (key) reasonText = t(key)
}
if (reasonText) {
showToast(
t('calendar.toastDayReason', {
date: formatDateForDisplay(info.clickedDate, locale),
parent: resolveParentName(info.parentPersonalDataUuid),
reason: reasonText,
}),
null,
)
}
}
const closePeriodPopover = () => {
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { reasonMessageKey } from './planReasons'
import { translate } from '@/i18n'
const ALL_REASON_CODES = [
'free_weekend',
'free_red_day',
'vacation_wrap_red_day',
'vacation_wrap_weekend',
'fp_interior',
'fp_lagsta_stretch',
'swap_first_half',
'swap_second_half',
'dubbeldag',
'fp_red_day',
]
describe('reasonMessageKey', () => {
it('maps every one of the allocator\'s 10 reason codes to a planReasons.* key', () => {
for (const code of ALL_REASON_CODES) {
const key = reasonMessageKey(code)
expect(key).not.toBeNull()
expect(key).toBe(`planReasons.${code}`)
}
})
it('resolves every mapped key to a real, non-empty string in both locales', () => {
for (const code of ALL_REASON_CODES) {
const key = reasonMessageKey(code)
expect(key).not.toBeNull()
if (!key) continue
expect(translate('sv', key)).not.toBe(key)
expect(translate('en', key)).not.toBe(key)
expect(translate('sv', key).length).toBeGreaterThan(0)
expect(translate('en', key).length).toBeGreaterThan(0)
}
})
it('returns null for an unrecognised reason code', () => {
expect(reasonMessageKey('some_future_reason_not_yet_mapped')).toBeNull()
})
it('returns null for an empty reason', () => {
expect(reasonMessageKey('')).toBeNull()
})
})
+36
View File
@@ -0,0 +1,36 @@
/**
* 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
}