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
+6
View File
@@ -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',
+6
View File
@@ -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',
+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())
+11 -2
View File
@@ -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
+178
View File
@@ -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<typeof translate>[1], params?: Parameters<typeof translate>[2]) =>
translate('sv', key, params)
const en = (key: Parameters<typeof translate>[1], params?: Parameters<typeof translate>[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')
})
})
+137
View File
@@ -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<string, PlanChange[]>()
const gained = new Map<string, PlanChange[]>()
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 })
})
}