Merge branch 'vacation-rescue-and-wrap'

Vacation rescue/wrap in allocation_service, new plan models/endpoints,
VacationRescueTip, useRescueDayActions, vacationSwaps util, sv+en i18n,
Dashboard/Calendar wiring.

Conflicts came from main's "day assertions" restructure and the earlier
merges; both intents kept:

- plan.py: both import sets; main's sync-with-wish handler kept next to the
  branch's new /rescue-days/dismiss endpoint; HTTP_422_UNPROCESSABLE_CONTENT
  applied to the branch's code (and a survivor in auth.py).
- Accepting a rescue day now goes through POST /api/day-assertions (an
  ad-hoc 'vacation' assertion, no wish) instead of /plan/apply with an
  `is_pinned` flag that no longer exists: on main a pin IS a day assertion
  (see PlanningService.add_planned_day's docstring), and an assertion is
  what keeps a later sync from retyping the day. useRescueDayActions,
  its tests, VacationRescueTip's tests and the backend integration flow
  follow that contract; applyPlan stays as a client for the live endpoint
  minus the dropped field. The hook also gets the security branch's
  useLatestRequest guard with an unconditionally cleared busy flag.
- Calendar.tsx: `locale` threaded into main's applyWishSyncResult call of
  buildSyncToastMessage (the branch's new parameter); the branch's
  swap-visibility toast tests drive the preset path through syncWithWish,
  which is how main saves a wish now.
- types/api.ts: ProposedDayApplyItem kept (used by applyPlan) without
  isPinned; the Add/UpdatePlannedDayRequest types stay removed as on main.
- fixtures/plan.ts: ResourceShortage import dropped (type removed on main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
This commit is contained in:
2026-09-21 07:48:33 +02:00
42 changed files with 5531 additions and 103 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 })
})
}