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