diff --git a/backend/tests/unit/test_plan_router.py b/backend/tests/unit/test_plan_router.py
index 762f0d9..b58fcf0 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."""
@@ -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
diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts
index c429eda..e5ad7cf 100644
--- a/frontend/src/i18n/en/calendar.ts
+++ b/frontend/src/i18n/en/calendar.ts
@@ -206,6 +206,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 f0c6b52..90b5097 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 11c3ae0..9e2fd04 100644
--- a/frontend/src/i18n/sv/calendar.ts
+++ b/frontend/src/i18n/sv/calendar.ts
@@ -207,6 +207,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 2ead26b..9b3e59d 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 5de1a7b..dee5d9a 100644
--- a/frontend/src/pages/Calendar.test.tsx
+++ b/frontend/src/pages/Calendar.test.tsx
@@ -14,6 +14,7 @@ import {
useWorkspaceMembers,
usePlanSync,
useDayAssertions,
+ usePlanProposal,
} from '@/hooks'
import { LanguageProvider } from '@/i18n'
import type { PlannedDayModalProps } from '@/components/modals/PlannedDayModal'
@@ -36,10 +37,12 @@ import {
makeWindowOutcome,
makeRejection,
makePlanProposal,
+ makeProposedDay,
makePlanApplyResponse,
makeDayAssertionsResult,
makeDayAssertion,
makeWorkspace,
+ makePlanProposalResult,
} from '../../tests/fixtures'
import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar'
import { ApiRequestError } from '@/services/errors'
@@ -68,6 +71,7 @@ vi.mock('@/hooks', () => ({
useWorkspaceMembers: vi.fn(),
usePlanSync: vi.fn(),
useDayAssertions: vi.fn(),
+ usePlanProposal: vi.fn(),
}))
vi.mock('@/components/layout/Layout', () => ({
@@ -436,6 +440,7 @@ describe('Calendar', () => {
vi.mocked(useWorkspaceMembers).mockReturnValue(mockMembers)
vi.mocked(usePlanSync).mockReturnValue(mockSync)
vi.mocked(useDayAssertions).mockReturnValue(mockAssertions)
+ vi.mocked(usePlanProposal).mockReturnValue(makePlanProposalResult())
vi.mocked(useWorkspace).mockReturnValue(mockWorkspace)
vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays)
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams(), mockSetSearchParams])
@@ -1332,6 +1337,11 @@ describe('Calendar', () => {
render()
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
+ // The click itself raises the day-reason toast (a pinned day: "you
+ // chose this yourself" — see `handleBandClick`); dismiss it so the
+ // assertion below is about the lock action's outcome alone.
+ fireEvent.click(screen.getByTestId('calendar-toast-dismiss'))
+ expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('period-lock-off'))
await waitFor(() => expect(syncWithWish).toHaveBeenCalled())
@@ -1595,6 +1605,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, funded in the same atomic call', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx
index 516c4d3..a2ef0dd 100644
--- a/frontend/src/pages/Calendar.tsx
+++ b/frontend/src/pages/Calendar.tsx
@@ -11,6 +11,7 @@ import {
useWorkspaceMembers,
usePlanSync,
useDayAssertions,
+ usePlanProposal,
} from '@/hooks'
import type {
PlannedLeaveDay,
@@ -21,6 +22,7 @@ import type {
LeaveWishUpdateRequest,
PersonalData,
PlanChange,
+ PlanProposal,
PlanSyncResponse,
PlanSyncWithWishResponse,
WindowOutcome,
@@ -38,6 +40,7 @@ import { datesInRange, formatDateForDisplay, formatDateLocalISO, isValidDate, pa
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
import { isApiError } from '@/services/errors'
import { summarizeVacationSwaps } from '@/utils/vacationSwaps'
+import { reasonMessageKey } from '@/utils/planReasons'
import { useTranslation, type Locale } from '@/i18n'
import './calendar.css'
@@ -461,6 +464,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
@@ -486,6 +503,10 @@ export function Calendar(): ReactNode {
() => assertionsHook.data.filter((a) => a.dayType === 'none'),
[assertionsHook.data],
)
+ // 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()
@@ -529,6 +550,26 @@ export function Calendar(): ReactNode {
)
}, [activeBand, plannedDaysHook.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
@@ -578,6 +619,7 @@ export function Calendar(): ReactNode {
): Promise => {
try {
const result = await syncHook.sync(workspaceId)
+ setReasonProposal(result.proposal)
// §M7 part 1 — a background refresh here so a sync triggered by a
// wish add/edit/delete elsewhere in this file doesn't blank the
// page either; `calendarHook.refetch()` has no such flag and keeps
@@ -638,6 +680,10 @@ export function Calendar(): ReactNode {
// `applyResult.rejections` already covers that case.
fallbackMessage?: string,
): Promise => {
+ // Same last-writer-wins source as `runSync`'s own response — every
+ // wish save on this page comes through here, not `runSync`, so the
+ // reason toast would otherwise only ever see the provider's proposal.
+ setReasonProposal(result.sync.proposal)
await Promise.all([plannedDaysHook.refresh(), calendarHook.refetch(), wishesHook.refresh()])
bumpPlanVersion()
const accepted = result.sync.applyResult.accepted
@@ -1002,10 +1048,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
+}