Tell the user when vacation days move between periods

reallocate_vacation_by_value swaps vacation onto higher-value dates 1-for-1
with parental-leave days. Add an Easter period with no vacation left and the
planner quietly takes vacation off the summer period, puts foraldrapenning
there, and spends it on the Easter klamdagar. Nothing said so.

sync_plan already emitted the evidence -- paired 'retyped' changes -- but
PlanChange.kind's 'retyped' existed only as a type declaration, rendered
nowhere, with no copy in either locale.

findVacationSwaps pairs a vacation -> FP retype with an FP -> vacation retype
for the same parent, aggregating multiple pairs into one group and ignoring
any retype that is not a genuine two-sided swap. summarizeVacationSwaps names
the periods via the existing resolveWishName, so a preset wish is
re-translated rather than shown raw, and falls back to dates when no single
wish resolves -- never a uuid. It joins the existing Calendar sync toast
rather than adding a second toast system.

Frontend 1356 tests, coverage 98.22 / 94.95 / 92.31 / 98.22, above all gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm
This commit is contained in:
2026-09-21 07:22:05 +02:00
parent 014503e89b
commit 9c67558877
6 changed files with 454 additions and 2 deletions
+116
View File
@@ -1477,6 +1477,122 @@ describe('Calendar', () => {
})
})
describe('§vacation-swap-visibility — reallocation toast', () => {
// `reallocate_vacation_by_value` (backend) swaps vacation onto better
// dates 1-for-1 with föräldrapenning after funding — a pair of
// 'retyped' changes for the same parent (vacation -> FP on one date,
// FP -> vacation on another). This must surface as its own toast
// fragment, naming the periods involved, not just get folded into the
// generic "N days changed" count.
const summerWish = makeLeaveWish({ id: 'lw-summer', name: 'Sommar', nameMessageKey: 'wishEditor.seasonSommar' })
const easterWish = makeLeaveWish({ id: 'lw-easter', name: 'Påsk', nameMessageKey: null })
const swapChanges = [
makePlanChange({
kind: 'retyped',
date: '2028-07-10',
parentPersonalDataUuid: 'person-1',
oldDayType: 'vacation',
newDayType: 'sjukpenning',
oldWishId: 'lw-summer',
newWishId: 'lw-summer',
}),
makePlanChange({
kind: 'retyped',
date: '2028-04-14',
parentPersonalDataUuid: 'person-1',
oldDayType: 'sjukpenning',
newDayType: 'vacation',
oldWishId: 'lw-easter',
newWishId: 'lw-easter',
}),
]
it('names both periods when the planner moves vacation between them (Swedish)', async () => {
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: swapChanges }))
vi.mocked(useLeaveWishes).mockReturnValue(
makeLeaveWishesResult({ add, data: [created, summerWish, easterWish] }),
)
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent('Sommar')
expect(toast).toHaveTextContent('Påsk')
expect(toast).toHaveTextContent('föräldrapenning')
})
it('names both periods when the planner moves vacation between them (English)', async () => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
})
try {
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse({ changes: swapChanges }))
vi.mocked(useLeaveWishes).mockReturnValue(
makeLeaveWishesResult({ add, data: [created, summerWish, easterWish] }),
)
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(
<LanguageProvider>
<Calendar />
</LanguageProvider>,
)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
// The preset's name is re-translated (English catalogue), the
// free-typed "Påsk" stays literal — same rule as everywhere else
// `resolveWishName` is used.
expect(toast).toHaveTextContent('Summer')
expect(toast).toHaveTextContent('Påsk')
expect(toast).toHaveTextContent('parental leave')
} finally {
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
vi.unstubAllGlobals()
}
})
it('does not show the swap toast for an unrelated retype', async () => {
const created = makeLeaveWish({ id: 'lw-preset', name: 'Sommar' })
const add = vi.fn().mockResolvedValue(created)
const sync = vi.fn().mockResolvedValue(
makePlanSyncResponse({
changes: [
makePlanChange({
kind: 'retyped',
parentPersonalDataUuid: 'person-1',
oldDayType: 'sjukpenning',
newDayType: 'lagsta',
oldWishId: 'lw-summer',
newWishId: 'lw-summer',
}),
],
}),
)
vi.mocked(useLeaveWishes).mockReturnValue(
makeLeaveWishesResult({ add, data: [created, summerWish] }),
)
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
expect(toast).not.toHaveTextContent('föräldrapenning')
expect(toast).not.toHaveTextContent('Påsk')
})
})
describe('§Task7 auto-sync moved to PlanSyncProvider', () => {
it('performs no sync of its own on mount — that is now PlanSyncProvider, mounted globally inside WorkspaceProvider', () => {
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())