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:
@@ -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
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user