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 })
+ })
+}