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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user