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
+4
View File
@@ -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)',
+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
@@ -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',
+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}',
+146
View File
@@ -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(<Calendar />)
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(<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, funded in the same atomic call', async () => {
const created = makeLeaveWish({ id: 'lw-preset' })
+77
View File
@@ -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<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
@@ -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<string, CalendarDayEntry>()
@@ -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<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
@@ -578,6 +619,7 @@ export function Calendar(): ReactNode {
): Promise<PlanSyncResponse | undefined> => {
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<PlanSyncWithWishResponse> => {
// 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 = () => {
+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
}