diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts index 392d5f4..76976b2 100644 --- a/frontend/src/i18n/en/calendar.ts +++ b/frontend/src/i18n/en/calendar.ts @@ -88,6 +88,12 @@ export const calendar = { '{name} ({start}–{end}): only {daysFunded} of {daysWished} days funded — {shortfallDays} {shortfallDays|day|days} short', 'calendar.toastRedDayShift': '{name}: moved to {start} – {end} to catch {days} extra free {days|day|days}', + // Reallocation pass — `reallocate_vacation_by_value` swaps vacation onto + // better dates 1-for-1 with parental leave. `{from}`/`{to}` already carry + // the period name plus its dates (e.g. "Summer (Jun 20, Jun 21)"), or the + // dates alone when no single period can be named — see vacationSwaps.ts. + 'calendar.toastVacationSwap': + '{count} {count|vacation day|vacation days} moved from {from} to {to} — replaced with parental leave', 'calendar.undoButton': 'Undo', 'calendar.undoError': 'Failed to undo the change', 'calendar.dismissToastAria': 'Dismiss the message', diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts index b024c29..51bd1cf 100644 --- a/frontend/src/i18n/sv/calendar.ts +++ b/frontend/src/i18n/sv/calendar.ts @@ -88,6 +88,12 @@ export const calendar = { '{name} ({start}–{end}): endast {daysFunded} av {daysWished} dagar finansierades — {shortfallDays} {shortfallDays|dag|dagar} saknas', 'calendar.toastRedDayShift': '{name}: flyttad till {start} – {end} för att fånga {days} extra {days|ledig dag|lediga dagar}', + // Reallocation pass — `reallocate_vacation_by_value` swaps vacation onto + // better dates 1-for-1 with föräldrapenning. `{from}`/`{to}` already carry + // the period name plus its dates (e.g. "Sommar (20 jun, 21 jun)"), or the + // dates alone when no single period can be named — see vacationSwaps.ts. + 'calendar.toastVacationSwap': + '{count} {count|semesterdag|semesterdagar} flyttades från {from} till {to} — ersattes med föräldrapenning', 'calendar.undoButton': 'Ångra', 'calendar.undoError': 'Det gick inte att ångra ändringen', 'calendar.dismissToastAria': 'Stäng meddelandet', diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index b7651dd..4efda43 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -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() + 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( + + + , + ) + 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() + 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()) diff --git a/frontend/src/pages/Calendar.tsx b/frontend/src/pages/Calendar.tsx index d369942..a6e1710 100644 --- a/frontend/src/pages/Calendar.tsx +++ b/frontend/src/pages/Calendar.tsx @@ -35,7 +35,8 @@ 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 { useTranslation } from '@/i18n' +import { summarizeVacationSwaps } from '@/utils/vacationSwaps' +import { useTranslation, type Locale } from '@/i18n' import './calendar.css' const WEEKDAY_KEYS = [ @@ -234,6 +235,7 @@ function buildSyncToastMessage( members: PersonalData[], watchWishId: string | undefined, t: Translate, + locale: Locale, watchWish?: LeaveWish, ): string | null { const parts: string[] = [] @@ -241,6 +243,12 @@ function buildSyncToastMessage( const changesMessage = summarizeChanges(result.changes, wishes, t) if (changesMessage) parts.push(changesMessage) + // §vacation-swap-visibility — `reallocate_vacation_by_value` (backend) + // silently swaps vacation onto better dates 1-for-1 with föräldrapenning; + // say so explicitly rather than leaving it buried in the generic "N dagar + // ändrades" count above. + parts.push(...summarizeVacationSwaps(result.changes, wishes, t, locale)) + if (watchWishId) { const outcome = result.proposal.outcomes.find((o) => o.wishId === watchWishId) if (outcome && outcome.shortfallDays > 0) { @@ -408,6 +416,7 @@ export function Calendar(): ReactNode { membersHook.data, options?.watchWishId, t, + locale, options?.watchWish, ) if (message) showToast(message, options?.undo ?? null) @@ -418,7 +427,7 @@ export function Calendar(): ReactNode { return undefined } }, - [syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, showToast], + [syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, locale, showToast], ) // The one-time-per-workspace auto-sync that used to live here (so wishes diff --git a/frontend/src/utils/vacationSwaps.test.ts b/frontend/src/utils/vacationSwaps.test.ts new file mode 100644 index 0000000..ea107c7 --- /dev/null +++ b/frontend/src/utils/vacationSwaps.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest' +import { findVacationSwaps, summarizeVacationSwaps } from './vacationSwaps' +import { translate } from '@/i18n' +import { makeLeaveWish, makePlanChange } from '../../tests/fixtures' + +const sv = (key: Parameters[1], params?: Parameters[2]) => + translate('sv', key, params) +const en = (key: Parameters[1], params?: Parameters[2]) => + translate('en', key, params) + +describe('findVacationSwaps', () => { + it('pairs an away (vacation -> FP) change with a gained (FP -> vacation) change for the same parent', () => { + const changes = [ + makePlanChange({ + kind: 'retyped', + date: '2028-07-10', + parentPersonalDataUuid: 'pd-a', + oldDayType: 'vacation', + newDayType: 'sjukpenning', + oldWishId: 'lw-summer', + newWishId: 'lw-summer', + }), + makePlanChange({ + kind: 'retyped', + date: '2028-04-14', + parentPersonalDataUuid: 'pd-a', + oldDayType: 'sjukpenning', + newDayType: 'vacation', + oldWishId: 'lw-easter', + newWishId: 'lw-easter', + }), + ] + + const swaps = findVacationSwaps(changes) + + expect(swaps).toEqual([ + { + parentPersonalDataUuid: 'pd-a', + count: 1, + awayDates: ['2028-07-10'], + gainedDates: ['2028-04-14'], + fromWishId: 'lw-summer', + toWishId: 'lw-easter', + }, + ]) + }) + + it('aggregates multiple swap pairs for the same parent into one group', () => { + const changes = [ + makePlanChange({ kind: 'retyped', date: '2028-07-10', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'sjukpenning', oldWishId: 'lw-summer', newWishId: 'lw-summer' }), + makePlanChange({ kind: 'retyped', date: '2028-07-11', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'lagsta', oldWishId: 'lw-summer', newWishId: 'lw-summer' }), + makePlanChange({ kind: 'retyped', date: '2028-04-14', parentPersonalDataUuid: 'pd-a', oldDayType: 'sjukpenning', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter' }), + makePlanChange({ kind: 'retyped', date: '2028-04-15', parentPersonalDataUuid: 'pd-a', oldDayType: 'lagsta', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter' }), + ] + + const swaps = findVacationSwaps(changes) + + expect(swaps).toHaveLength(1) + expect(swaps[0]?.count).toBe(2) + expect(swaps[0]?.awayDates).toEqual(['2028-07-10', '2028-07-11']) + expect(swaps[0]?.gainedDates).toEqual(['2028-04-14', '2028-04-15']) + }) + + it('ignores an unrelated retype (e.g. a dubbeldag flag flip) — no compensating gain/loss', () => { + const changes = [ + makePlanChange({ + kind: 'retyped', + parentPersonalDataUuid: 'pd-a', + oldDayType: 'sjukpenning', + newDayType: 'sjukpenning', + oldIsDubbeldag: false, + newIsDubbeldag: true, + }), + ] + + expect(findVacationSwaps(changes)).toEqual([]) + }) + + it('does not treat a lone vacation-away retype (nothing gained back) as a swap', () => { + const changes = [ + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'sjukpenning' }), + ] + + expect(findVacationSwaps(changes)).toEqual([]) + }) + + it('returns nothing for an empty change set', () => { + expect(findVacationSwaps([])).toEqual([]) + }) + + it('keeps separate parents in separate groups', () => { + const changes = [ + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-a', date: '2028-07-10', oldDayType: 'vacation', newDayType: 'sjukpenning' }), + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-a', date: '2028-04-14', oldDayType: 'sjukpenning', newDayType: 'vacation' }), + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-b', date: '2028-08-01', oldDayType: 'vacation', newDayType: 'lagsta' }), + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-b', date: '2028-04-15', oldDayType: 'lagsta', newDayType: 'vacation' }), + ] + + const swaps = findVacationSwaps(changes) + expect(swaps.map((s) => s.parentPersonalDataUuid)).toEqual(['pd-a', 'pd-b']) + }) +}) + +describe('summarizeVacationSwaps', () => { + // `summer` carries a real preset message key, so its display name must be + // re-translated per locale (see `resolveWishName`) rather than showing + // whatever language it happened to be created in. `easter` is a + // free-typed name — no season preset for Påsk exists — so it stays + // literal in both locales, exactly as a user's own custom period name + // would. + const summer = makeLeaveWish({ id: 'lw-summer', name: 'Sommar', nameMessageKey: 'wishEditor.seasonSommar' }) + const easter = makeLeaveWish({ id: 'lw-easter', name: 'Påsk', nameMessageKey: null }) + + const swapChanges = [ + makePlanChange({ kind: 'retyped', date: '2028-07-10', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'sjukpenning', oldWishId: 'lw-summer', newWishId: 'lw-summer' }), + makePlanChange({ kind: 'retyped', date: '2028-04-14', parentPersonalDataUuid: 'pd-a', oldDayType: 'sjukpenning', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter' }), + ] + + it('names both periods and reports the count in Swedish', () => { + const [message] = summarizeVacationSwaps(swapChanges, [summer, easter], sv, 'sv') + expect(message).toContain('Sommar') + expect(message).toContain('Påsk') + expect(message).toContain('1') + expect(message).toContain('semesterdag') + expect(message).toContain('föräldrapenning') + }) + + it('names both periods, re-translating the preset name, and reports the count in English', () => { + const [message] = summarizeVacationSwaps(swapChanges, [summer, easter], en, 'en') + expect(message).toContain('Summer') + expect(message).not.toContain('Sommar') + expect(message).toContain('Påsk') + expect(message).toContain('1') + expect(message).toContain('vacation day') + expect(message).toContain('parental leave') + }) + + it('produces nothing for an empty change set', () => { + expect(summarizeVacationSwaps([], [summer, easter], sv, 'sv')).toEqual([]) + }) + + it('produces nothing for an unrelated retype', () => { + const changes = [ + makePlanChange({ kind: 'retyped', parentPersonalDataUuid: 'pd-a', oldDayType: 'sjukpenning', newDayType: 'lagsta' }), + ] + expect(summarizeVacationSwaps(changes, [summer, easter], sv, 'sv')).toEqual([]) + }) + + it('falls back to the dates alone (never a raw uuid) when no wish can be resolved', () => { + const changes = [ + makePlanChange({ kind: 'retyped', date: '2028-07-10', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'sjukpenning', oldWishId: null, newWishId: null }), + makePlanChange({ kind: 'retyped', date: '2028-04-14', parentPersonalDataUuid: 'pd-a', oldDayType: 'sjukpenning', newDayType: 'vacation', oldWishId: null, newWishId: null }), + ] + + const [message] = summarizeVacationSwaps(changes, [summer, easter], sv, 'sv') + expect(message).toBeDefined() + expect(message).not.toContain('lw-summer') + expect(message).not.toContain('lw-easter') + expect(message).not.toContain('null') + // Compact date labels (day + short month), not the raw ISO strings. + expect(message).not.toContain('2028-07-10') + expect(message).not.toContain('2028-04-14') + }) + + it('aggregates multiple swaps for one parent into one message', () => { + const changes = [ + makePlanChange({ kind: 'retyped', date: '2028-07-10', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'sjukpenning', oldWishId: 'lw-summer', newWishId: 'lw-summer' }), + makePlanChange({ kind: 'retyped', date: '2028-07-11', parentPersonalDataUuid: 'pd-a', oldDayType: 'vacation', newDayType: 'lagsta', oldWishId: 'lw-summer', newWishId: 'lw-summer' }), + makePlanChange({ kind: 'retyped', date: '2028-04-14', parentPersonalDataUuid: 'pd-a', oldDayType: 'sjukpenning', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter' }), + makePlanChange({ kind: 'retyped', date: '2028-04-15', parentPersonalDataUuid: 'pd-a', oldDayType: 'lagsta', newDayType: 'vacation', oldWishId: 'lw-easter', newWishId: 'lw-easter' }), + ] + + const messages = summarizeVacationSwaps(changes, [summer, easter], sv, 'sv') + expect(messages).toHaveLength(1) + expect(messages[0]).toContain('2') + expect(messages[0]).toContain('semesterdagar') + }) +}) diff --git a/frontend/src/utils/vacationSwaps.ts b/frontend/src/utils/vacationSwaps.ts new file mode 100644 index 0000000..d5a4b04 --- /dev/null +++ b/frontend/src/utils/vacationSwaps.ts @@ -0,0 +1,137 @@ +/** + * Detects and summarises the vacation<->parental-leave (FP) swaps + * `reallocate_vacation_by_value` (backend/app/services/allocation_service.py) + * performs once, after funding, as part of `POST /api/plan/sync`: it moves + * vacation 1-for-1 onto higher-value dates — a lower-value vacation day is + * retyped to an FP type (`sjukpenning`/`lagsta`), and a better date (a + * weekend bridge or red-day klämdag) is retyped from FP to vacation. The + * day *count* per (parent, year) never changes, only which dates hold it — + * until now this was invisible: it produces `PlanChange.kind === 'retyped'` + * entries indistinguishable from any other retype, with nothing in the UI + * calling it out. + * + * A swap shows up in `PlanSyncResponse.changes` as a PAIR of 'retyped' + * entries for the same parent: one `vacation` -> FP, and one FP -> + * `vacation`. `findVacationSwaps` pairs those up per parent (pure, + * structural — no i18n); `summarizeVacationSwaps` composes the toast copy + * from the result, same house style as `Calendar.tsx`'s other + * `summarize*` helpers — structured data in, composed copy out. + */ +import type { Locale } from '@/i18n' +import type { LeaveWish, PlanChange } from '@/types' +import type { Translate } from './rejectionMessages' +import { resolveWishName } from './wishName' + +function isFpDayType(dayType: PlanChange['oldDayType']): dayType is 'sjukpenning' | 'lagsta' { + return dayType === 'sjukpenning' || dayType === 'lagsta' +} + +/** `null` when `ids` is empty or disagrees (more than one distinct non-null + * id) — nothing single to name in that case, so a caller falls back to the + * dates themselves rather than guessing. */ +function soleWishId(ids: (string | null)[]): string | null { + const distinct = new Set(ids.filter((id): id is string => id !== null)) + return distinct.size === 1 ? [...distinct][0]! : null +} + +/** One parent's aggregated vacation<->FP reallocation from a single sync's + * `changes` — the pure "what moved" shape. `fromWishId`/`toWishId` name the + * period the days moved away from/into, when every date on that side + * agrees on a single wish; `null` otherwise. */ +export interface VacationSwap { + parentPersonalDataUuid: string + /** Vacation days moved away == vacation days gained, by construction (the + * reallocation is always 1-for-1) — one count, not two. Takes the + * smaller side defensively, so a mismatch (which should never happen) + * never overstates the toast. */ + count: number + awayDates: string[] + gainedDates: string[] + fromWishId: string | null + toWishId: string | null +} + +/** Pairs up genuine vacation<->FP swaps per parent from a sync's `changes`. + * A parent with vacation retyped away but nothing retyped back to vacation + * (or vice versa) is some other kind of retype — not this reallocation + * pass — and is skipped entirely, per the "only genuine swaps" rule. */ +export function findVacationSwaps(changes: PlanChange[]): VacationSwap[] { + const away = new Map() + const gained = new Map() + + for (const change of changes) { + if (change.kind !== 'retyped') continue + if (change.oldDayType === 'vacation' && isFpDayType(change.newDayType)) { + const list = away.get(change.parentPersonalDataUuid) ?? [] + list.push(change) + away.set(change.parentPersonalDataUuid, list) + } else if (change.newDayType === 'vacation' && isFpDayType(change.oldDayType)) { + const list = gained.get(change.parentPersonalDataUuid) ?? [] + list.push(change) + gained.set(change.parentPersonalDataUuid, list) + } + } + + const parents = new Set([...away.keys(), ...gained.keys()]) + const swaps: VacationSwap[] = [] + for (const parent of parents) { + const awayChanges = away.get(parent) ?? [] + const gainedChanges = gained.get(parent) ?? [] + if (awayChanges.length === 0 || gainedChanges.length === 0) continue + + swaps.push({ + parentPersonalDataUuid: parent, + count: Math.min(awayChanges.length, gainedChanges.length), + awayDates: awayChanges.map((c) => c.date).sort(), + gainedDates: gainedChanges.map((c) => c.date).sort(), + // The day's own wish shouldn't change across a mere retype — prefer + // `oldWishId`/`newWishId` respectively, but fall back to the other + // field so a wish id that happens to differ still resolves. + fromWishId: soleWishId(awayChanges.map((c) => c.oldWishId ?? c.newWishId)), + toWishId: soleWishId(gainedChanges.map((c) => c.newWishId ?? c.oldWishId)), + }) + } + return swaps.sort((a, b) => a.parentPersonalDataUuid.localeCompare(b.parentPersonalDataUuid)) +} + +/** Compact "20 juni"/"Jun 20" label for a list of dates — a toast has no + * room for `formatDateForDisplay`'s full "20 juni 2026" per date, and the + * year is redundant within one sync's toast. */ +function formatDateList(dates: string[], locale: Locale): string { + const formatter = new Intl.DateTimeFormat(locale === 'sv' ? 'sv-SE' : 'en-US', { + day: 'numeric', + month: 'short', + }) + return dates.map((d) => formatter.format(new Date(`${d}T00:00:00Z`))).join(', ') +} + +/** One side (origin or destination) of a swap: the wish name plus its + * dates when a single wish can be named, or the dates alone — never a raw + * uuid — when it can't. */ +function describeSide( + dates: string[], + wishId: string | null, + wishes: LeaveWish[], + t: Translate, + locale: Locale, +): string { + const wish = wishId ? wishes.find((w) => w.id === wishId) : undefined + const dateLabel = formatDateList(dates, locale) + return wish ? `${resolveWishName(wish, t)} (${dateLabel})` : dateLabel +} + +/** One toast fragment per parent with a genuine vacation<->FP swap this + * sync — empty when nothing moved. Multiple swap pairs for the same parent + * aggregate into that parent's single message rather than one per pair. */ +export function summarizeVacationSwaps( + changes: PlanChange[], + wishes: LeaveWish[], + t: Translate, + locale: Locale, +): string[] { + return findVacationSwaps(changes).map((swap) => { + const from = describeSide(swap.awayDates, swap.fromWishId, wishes, t, locale) + const to = describeSide(swap.gainedDates, swap.toWishId, wishes, t, locale) + return t('calendar.toastVacationSwap', { count: swap.count, from, to }) + }) +}