Keep "both parents" wishes both-off, and warn when they can't be

A lower-priority 'both' wish (e.g. a yearly autumn week) quietly fell back
to one parent per day once summer/Jul had used both parents' vacation, and
still counted as funded, so nothing warned.

- Allocator rescue pass: frees a vacation day by switching one parent's
  vacation to FP on another date (interior v+v, then Mon/Fri bridge days
  without creating an FP weekend wrap, then solo days) and spends it on the
  one-parent date as co-leave. Pinned days count as present.
- WindowOutcome.unmet_both_dates + new both_parents_unmet plan warning.
- Frontend maps the new fields; Dashboard warning card, year-row flag and a
  Calendar save-toast line list the affected years (sv + en).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 23:24:28 +02:00
parent 1a02bdcdbf
commit 906cf70a98
20 changed files with 1265 additions and 14 deletions
@@ -119,6 +119,57 @@ describe('PlanWarningCard', () => {
expect(warning).not.toHaveTextContent('förslag på')
})
it('renders a both_parents_unmet warning with the (re-translated) wish name and its figures', () => {
render(
<PlanWarningCard
index={2}
nameFor={nameFor}
warning={makePlanWarning({
kind: 'both_parents_unmet',
messageKey: 'planWarnings.both_parents_unmet',
data: {
wish_id: 'lw-autumn',
series_id: null,
wish_name: 'Höst',
wish_name_message_key: null,
year: 2029,
count: 3,
total_days: 5,
first_date: '2029-09-10',
last_date: '2029-09-14',
},
})}
/>,
)
const warning = screen.getByTestId('dashboard-plan-warning-2')
expect(warning).toHaveTextContent("Föräldrarna kunde inte vara lediga tillsammans varje dag")
expect(warning).toHaveTextContent('Höst 2029: föräldrarna kunde inte vara lediga tillsammans 3 av 5 dagar')
expect(warning).not.toHaveTextContent('2029-09-10')
})
it('re-translates a preset wish name in a both_parents_unmet warning via its message key', () => {
render(
<PlanWarningCard
index={2}
nameFor={nameFor}
warning={makePlanWarning({
kind: 'both_parents_unmet',
messageKey: 'planWarnings.both_parents_unmet',
data: {
wish_name: 'Summer',
wish_name_message_key: 'wishEditor.seasonSommar',
year: 2029,
count: 1,
total_days: 5,
},
})}
/>,
)
const warning = screen.getByTestId('dashboard-plan-warning-2')
expect(warning).toHaveTextContent('Sommar 2029')
expect(warning).not.toHaveTextContent('Summer 2029')
})
it('falls back to the raw kind and messageKey for an unrecognised warning', () => {
render(
<PlanWarningCard
@@ -2,25 +2,29 @@ import { ReactNode } from 'react'
import type { PlanWarning } from '@/types'
import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n'
import { formatDateForDisplay } from '@/utils/dates'
import { resolveWishNameFromData } from '@/utils/wishName'
// The three `PlanWarning` kinds the backend can emit
// The four `PlanWarning` kinds the backend can emit
// (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`,
// `_vacation_expiry_warnings`). `kind` -> title, `messageKey` -> the body
// template. The backend's own `message_key` is already `f"planWarnings.
// {kind}"`, i.e. a real translation key — routed through a lookup anyway
// (like `SurplusAdviceTip`'s `SURPLUS_ADVICE_BODY_KEYS`) so an unrecognised
// kind (a newer backend rule this catalogue hasn't caught up with) falls
// back to the raw value instead of rendering a missing-key placeholder.
// `_vacation_expiry_warnings`, `_both_parents_unmet_warnings`). `kind` ->
// title, `messageKey` -> the body template. The backend's own `message_key`
// is already `f"planWarnings.{kind}"`, i.e. a real translation key — routed
// through a lookup anyway (like `SurplusAdviceTip`'s
// `SURPLUS_ADVICE_BODY_KEYS`) so an unrecognised kind (a newer backend rule
// this catalogue hasn't caught up with) falls back to the raw value instead
// of rendering a missing-key placeholder.
const PLAN_WARNING_TITLE_KEYS: Record<string, TranslationKey> = {
sgi_risk: 'planWarnings.sgiRiskTitle',
days_lost_at_age_4: 'planWarnings.daysLostAtAge4Title',
vacation_expiring: 'planWarnings.vacationExpiringTitle',
both_parents_unmet: 'planWarnings.bothParentsUnmetTitle',
}
const PLAN_WARNING_BODY_KEYS: Record<string, TranslationKey> = {
'planWarnings.sgi_risk': 'planWarnings.sgi_risk',
'planWarnings.days_lost_at_age_4': 'planWarnings.days_lost_at_age_4',
'planWarnings.vacation_expiring': 'planWarnings.vacation_expiring',
'planWarnings.both_parents_unmet': 'planWarnings.both_parents_unmet',
}
// `data` fields that carry an ISO-8601 date and must go through
@@ -30,6 +34,7 @@ const DATE_FIELDS_BY_KIND: Record<string, readonly string[]> = {
sgi_risk: ['week_start'],
days_lost_at_age_4: ['fourth_birthday'],
vacation_expiring: [],
both_parents_unmet: [],
}
export interface PlanWarningCardProps {
@@ -61,6 +66,13 @@ export function PlanWarningCard({ warning, nameFor, index }: PlanWarningCardProp
if (typeof uuid === 'string') {
params.parent_name = nameFor(uuid)
}
if (warning.kind === 'both_parents_unmet') {
// §both-parents-rescue — re-translate a preset wish's name into the
// UI's current locale, same rule `resolveWishName` applies everywhere
// else a wish name is shown (see `resolveWishNameFromData`'s doc
// comment).
params.wish_name = resolveWishNameFromData(warning.data, t)
}
for (const field of DATE_FIELDS_BY_KIND[warning.kind] ?? []) {
const raw = warning.data[field]
if (typeof raw === 'string') {
@@ -439,5 +439,29 @@ describe('YearRows', () => {
expect(screen.getByTestId('year-row-shortfall-lw-1-2026')).toBeInTheDocument()
expect(screen.queryByTestId('year-row-remedy-lw-1-2026-0')).not.toBeInTheDocument()
})
// §both-parents-rescue
it('flags a wish row whose window still has unmet both-parents dates', () => {
renderRows({
outcomes: [
makeWindowOutcome({
wishId: 'lw-1',
wishName: 'Höst',
year: 2027,
unmetBothDates: ['2027-09-10', '2027-09-11', '2027-09-12'],
}),
],
})
expect(screen.getByTestId('year-row-both-unmet-lw-1-2027')).toHaveTextContent(
'3 dagar kunde inte täckas av båda föräldrarna',
)
})
it('shows no both-parents-unmet flag when the list is empty', () => {
renderRows({
outcomes: [makeWindowOutcome({ wishId: 'lw-1', year: 2026, unmetBothDates: [] })],
})
expect(screen.queryByTestId('year-row-both-unmet-lw-1-2026')).not.toBeInTheDocument()
})
})
})
@@ -475,6 +475,14 @@ export function YearRows({
))}
</ul>
)}
{outcome.unmetBothDates.length > 0 && (
<span
className="year-row__both-unmet"
data-testid={`year-row-both-unmet-${outcome.wishId}-${year}`}
>
{t('yearRow.bothParentsUnmet', { count: outcome.unmetBothDates.length })}
</span>
)}
</li>
))}
</ul>
+5
View File
@@ -95,6 +95,11 @@ 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}',
// §both-parents-rescue — a line in the sync toast when at least one wish
// outcome has `unmetBothDates.length > 0` for that year, despite the
// backend's own repair pass (donating a vacation day from another wish in
// the same year). `{years}` is a comma-separated list, e.g. "2027, 2029".
'calendar.toastBothParentsUnmet': "Both parents couldn't be off together for {years}",
// 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
+4
View File
@@ -66,4 +66,8 @@ export const dashboard = {
'yearRow.vacationTitle': 'Vacation',
'yearRow.weeksFunded': '{funded} of {wished} weeks',
'yearRow.wishesTitle': 'Leave periods',
// §both-parents-rescue — shown under a wish when
// `WindowOutcome.unmetBothDates` isn't empty, i.e. despite the backend's
// own repair pass, both parents couldn't be off together every day.
'yearRow.bothParentsUnmet': "{count} {count|day|days} couldn't be covered by both parents",
} as const
+7
View File
@@ -77,6 +77,13 @@ export const plan = {
// `data.rescue_days_available` is > 0 (`PlanWarningCard.tsx`). Never shown
// on its own, so no separate title.
'planWarnings.vacationExpiringRescueHint': 'There are {count} suggested dates to rescue them — see the suggestions below.',
// §both-parents-rescue — `_both_parents_unmet_warnings` (allocation_service.py):
// a 'both'-mode period that still has dates only one parent could be off
// on, despite the backend's own repair pass (donating a vacation day from
// another wish in the same year).
'planWarnings.bothParentsUnmetTitle': "Both parents couldn't be off together every day",
'planWarnings.both_parents_unmet':
"{wish_name} {year}: both parents couldn't be off together on {count} of {total_days} days — not enough vacation or parental-leave days.",
// §vacation-rescue — `VacationRescueTip.tsx`, one card per (parent, year)
// group of `PlanProposal.rescueDays`.
'planResults.vacationRescueTitle': 'Rescue vacation days {year}',
+5
View File
@@ -92,6 +92,11 @@ 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}',
// §both-parents-rescue — en rad i synk-toasten när minst ett önskemåls
// utfall har `unmetBothDates.length > 0` för det året, trots backendens
// egen reparation (donerar en semesterdag från ett annat önskemål samma
// år). `{years}` är en kommaseparerad lista, t.ex. "2027, 2029".
'calendar.toastBothParentsUnmet': 'Båda föräldrarna kunde inte vara lediga samtidigt för {years}',
// 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
+4
View File
@@ -66,4 +66,8 @@ export const dashboard = {
'yearRow.vacationTitle': 'Semester',
'yearRow.weeksFunded': '{funded} av {wished} veckor',
'yearRow.wishesTitle': 'Ledigheter',
// §both-parents-rescue — visas under ett önskemål när
// `WindowOutcome.unmetBothDates` inte är tom, dvs. trots backendens egen
// reparation kunde inte båda föräldrarna vara lediga varje dag.
'yearRow.bothParentsUnmet': '{count} {count|dag|dagar} kunde inte täckas av båda föräldrarna',
} as const
+7
View File
@@ -77,6 +77,13 @@ export const plan = {
// `data.rescue_days_available` is > 0 (`PlanWarningCard.tsx`). Never shown
// on its own, so no separate title.
'planWarnings.vacationExpiringRescueHint': 'Det finns förslag på {count} datum för att rädda dem — se förslagen nedan.',
// §both-parents-rescue — `_both_parents_unmet_warnings` (allocation_service.py):
// a 'both'-läge-period som fortfarande har datum där bara en förälder
// kunde vara ledig, trots backendens egen reparation (donerar en
// semesterdag från ett annat önskemål samma år).
'planWarnings.bothParentsUnmetTitle': 'Föräldrarna kunde inte vara lediga tillsammans varje dag',
'planWarnings.both_parents_unmet':
'{wish_name} {year}: föräldrarna kunde inte vara lediga tillsammans {count} av {total_days} dagar — inte tillräckligt med semester eller föräldrapenningdagar.',
// §vacation-rescue — `VacationRescueTip.tsx`, one card per (parent, year)
// group of `PlanProposal.rescueDays`.
'planResults.vacationRescueTitle': 'Rädda semesterdagar {year}',
+41
View File
@@ -2051,6 +2051,47 @@ describe('Calendar', () => {
expect(toast).toHaveTextContent('2')
})
it('shows a toast when a sibling year could not keep both parents off despite the rescue pass', async () => {
// §both-parents-rescue — unlike the shortfall summary above, this is
// NOT gated on `changes` membership: a rescue can ripple into a
// sibling year this save never directly touched, so every outcome in
// the batch is scanned (mirrors the shift-gain loop).
const created = makeLeaveWish({ id: 'lw-preset', name: 'Höst' })
const syncWithWish = vi.fn().mockResolvedValue(
makePlanSyncWithWishResponse({
wish: created,
sync: makePlanSyncResponse({
changes: [],
proposal: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-preset',
year: 2027,
unmetBothDates: ['2027-09-10'],
}),
makeWindowOutcome({
wishId: 'lw-other',
year: 2029,
unmetBothDates: ['2029-09-10', '2029-09-11'],
}),
makeWindowOutcome({ wishId: 'lw-clean', year: 2028, unmetBothDates: [] }),
],
}),
}),
}),
)
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [created] }))
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ syncWithWish }))
render(<Calendar />)
fireEvent.click(screen.getByTestId('preset-click'))
const toast = await screen.findByTestId('calendar-toast')
expect(toast).toHaveTextContent("kunde inte vara lediga samtidigt")
expect(toast).toHaveTextContent('2027, 2029')
expect(toast).not.toHaveTextContent('2028')
})
it('does not show the shift toast when the wish with a positive shiftGainDays is not in changes', async () => {
// A plain re-open (nothing actually touched this wish's dates this
// sync) must never re-announce a stable, already-applied shift --
+20
View File
@@ -374,6 +374,26 @@ function buildSyncToastMessage(
)
}
// §both-parents-rescue — the backend's own repair pass
// (`_Ledger.rescue_degraded_both_windows`) already tried to fix a 'both'
// window that fell back to a sequential swap by donating a vacation day
// from elsewhere in the same year; this reports what's left over — every
// wish year that still has a date only one parent could be off on, no
// matter which wish was actually touched by this save (mirrors the
// shift-gain loop above, not gated on `watchWishId`/`changes` membership,
// since a rescue can ripple into a sibling year this save never directly
// touched).
const bothParentsUnmetYears = Array.from(
new Set(
result.proposal.outcomes
.filter((o) => o.unmetBothDates.length > 0)
.map((o) => o.year),
),
).sort((a, b) => a - b)
if (bothParentsUnmetYears.length > 0) {
parts.push(t('calendar.toastBothParentsUnmet', { years: bothParentsUnmetYears.join(', ') }))
}
for (const rejection of result.applyResult.rejections) {
parts.push(renderRejection(rejection, t, members))
}
+14 -1
View File
@@ -1571,7 +1571,10 @@ describe('endpoint functions (representative coverage)', () => {
weeks_wished: 6,
weeks_funded: 6,
shortfall_days: 0,
strategy: 'parent_swap',
strategy: 'co_leave',
requested_strategy: 'dubbeldag',
strategy_degraded: true,
unmet_both_dates: ['2026-09-10', '2026-09-11'],
message_key: 'planOutcomes.windowExplanation',
data: {
wish_name: 'Sommar',
@@ -1592,6 +1595,11 @@ describe('endpoint functions (representative coverage)', () => {
message_key: 'planWarnings.days_lost_at_age_4',
data: { expiring_days: 10 },
},
{
kind: 'both_parents_unmet',
message_key: 'planWarnings.both_parents_unmet',
data: { wish_name: 'Höst', year: 2026, count: 2, total_days: 5 },
},
],
pool_balances: [
{
@@ -1665,6 +1673,11 @@ describe('endpoint functions (representative coverage)', () => {
})
expect(result.warnings[0]!.kind).toBe('days_lost_at_age_4')
expect(result.warnings[0]!.messageKey).toBe('planWarnings.days_lost_at_age_4')
expect(result.warnings[1]!.kind).toBe('both_parents_unmet')
expect(result.warnings[1]!.data).toEqual({ wish_name: 'Höst', year: 2026, count: 2, total_days: 5 })
expect(result.outcomes[0]!.requestedStrategy).toBe('dubbeldag')
expect(result.outcomes[0]!.strategyDegraded).toBe(true)
expect(result.outcomes[0]!.unmetBothDates).toEqual(['2026-09-10', '2026-09-11'])
expect(result.poolBalances[0]!.sjukpenningLeft).toBe(100)
expect(result.vacationBalances[0]!.alreadyUsed).toBe(19)
expect(result.vacationBalances[0]!.isAssumed).toBe(true)
+6
View File
@@ -407,6 +407,9 @@ interface RawWindowOutcome {
weeks_funded: number
shortfall_days: number
strategy: string
requested_strategy: string
strategy_degraded: boolean
unmet_both_dates: string[]
message_key: string
data: Record<string, unknown>
cost_breakdown: Record<string, number>
@@ -504,6 +507,9 @@ function toPlanProposal(raw: RawPlanProposal): PlanProposal {
weeksFunded: o.weeks_funded,
shortfallDays: o.shortfall_days,
strategy: o.strategy,
requestedStrategy: o.requested_strategy,
strategyDegraded: o.strategy_degraded,
unmetBothDates: o.unmet_both_dates,
messageKey: o.message_key,
data: o.data,
costBreakdown: toCostBreakdown(o.cost_breakdown),
+6 -1
View File
@@ -466,10 +466,15 @@
}
.year-row__wish-weeks--short,
.year-row__shortfall {
.year-row__shortfall,
.year-row__both-unmet {
color: var(--c-danger);
}
.year-row__both-unmet {
flex-basis: 100%;
}
.year-row__remedies {
flex-basis: 100%;
padding-left: var(--sp-4);
+17 -2
View File
@@ -423,7 +423,22 @@ export interface WindowOutcome {
weeksWished: number
weeksFunded: number
shortfallDays: number
strategy: string // 'dubbeldag' | 'parent_swap' | 'solo' — kept open-ended, see allocation_service.py
strategy: string // 'dubbeldag' | 'parent_swap' | 'solo' | 'co_leave' | 'swap' | 'unplaced' — kept open-ended, see allocation_service.py
// What `parent_mode` (+ wants-dubbeldag) actually asked for, independent
// of whether it was achieved — see `WindowOutcome.requested_strategy`
// (allocation_service.py). Compare against `strategy` to tell "as
// requested" from "fell back".
requestedStrategy: string
// True when `strategy !== requestedStrategy` for a 'both'-mode window —
// see `WindowOutcome.strategy_degraded`.
strategyDegraded: boolean
// §both-parents-rescue — dates in this window still funded for only one
// parent after the backend's own repair pass tried to fix them by
// donating a vacation day from elsewhere in the same year. Empty for
// every non-'both' window and for a window that funded cleanly or was
// fully rescued. See `WindowOutcome.unmet_both_dates`
// (allocation_service.py) and `PlanWarning` kind 'both_parents_unmet'.
unmetBothDates: string[] // ISO-8601 dates
messageKey: string
data: Record<string, unknown>
costBreakdown: CostBreakdown
@@ -443,7 +458,7 @@ export interface WindowOutcome {
}
export interface PlanWarning {
kind: string // 'sgi_risk' | 'days_lost_at_age_4' | 'vacation_expiring'
kind: string // 'sgi_risk' | 'days_lost_at_age_4' | 'vacation_expiring' | 'both_parents_unmet'
messageKey: string
data: Record<string, unknown>
}
+3
View File
@@ -63,6 +63,9 @@ export function makeWindowOutcome(overrides: Partial<WindowOutcome> = {}): Windo
weeksFunded: 6,
shortfallDays: 0,
strategy: 'parent_swap',
requestedStrategy: 'parent_swap',
strategyDegraded: false,
unmetBothDates: [],
messageKey: 'planOutcomes.windowExplanation',
data: {},
costBreakdown: makeCostBreakdown({ free: 12, vacation: 19, sjukpenning: 11 }),