From 1a692210409326d86d9bed8f5f3a174af20f6454 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 21 Sep 2026 07:41:44 +0200 Subject: [PATCH] Let a planned day say why it was placed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/tests/unit/test_plan_router.py | 25 ++++- frontend/src/i18n/en/calendar.ts | 4 + frontend/src/i18n/en/plan.ts | 28 +++++ frontend/src/i18n/sv/calendar.ts | 4 + frontend/src/i18n/sv/plan.ts | 28 +++++ frontend/src/pages/Calendar.test.tsx | 141 +++++++++++++++++++++++++ frontend/src/pages/Calendar.tsx | 73 +++++++++++++ frontend/src/utils/planReasons.test.ts | 46 ++++++++ frontend/src/utils/planReasons.ts | 36 +++++++ 9 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/planReasons.test.ts create mode 100644 frontend/src/utils/planReasons.ts diff --git a/backend/tests/unit/test_plan_router.py b/backend/tests/unit/test_plan_router.py index ed5334c..18495dd 100644 --- a/backend/tests/unit/test_plan_router.py +++ b/backend/tests/unit/test_plan_router.py @@ -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 diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts index 392d5f4..f9b8010 100644 --- a/frontend/src/i18n/en/calendar.ts +++ b/frontend/src/i18n/en/calendar.ts @@ -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)', diff --git a/frontend/src/i18n/en/plan.ts b/frontend/src/i18n/en/plan.ts index 554eb53..aabef66 100644 --- a/frontend/src/i18n/en/plan.ts +++ b/frontend/src/i18n/en/plan.ts @@ -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}', diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts index b024c29..827e775 100644 --- a/frontend/src/i18n/sv/calendar.ts +++ b/frontend/src/i18n/sv/calendar.ts @@ -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', diff --git a/frontend/src/i18n/sv/plan.ts b/frontend/src/i18n/sv/plan.ts index f2019cf..149ddda 100644 --- a/frontend/src/i18n/sv/plan.ts +++ b/frontend/src/i18n/sv/plan.ts @@ -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}', diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index b7651dd..9028ba2 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -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() + 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() + 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() + 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() + 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() + 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' }) diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index d369942..0d1ca91 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -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(null) const [toast, setToast] = useState(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(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() @@ -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() + 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 => { 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 = () => { diff --git a/frontend/src/utils/planReasons.test.ts b/frontend/src/utils/planReasons.test.ts new file mode 100644 index 0000000..d96a63e --- /dev/null +++ b/frontend/src/utils/planReasons.test.ts @@ -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() + }) +}) diff --git a/frontend/src/utils/planReasons.ts b/frontend/src/utils/planReasons.ts new file mode 100644 index 0000000..20dcb4d --- /dev/null +++ b/frontend/src/utils/planReasons.ts @@ -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.` 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 = { + 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 +}