Say what would close a shortfall, and where the free days are

A year that cannot afford the whole week was already funded as far as it
goes rather than dropped; there is now a test that says so, across four
years of a repeating wish where only three and a half are affordable.

The advice to move a window onto the red days was looking three days
either side, which can never find Easter. It now searches four weeks
either way — enough for Easter's drift plus the weekday snap — and ranks
placements with the same cost model flexible periods are already placed
with, naming the dates and the days gained.

None of this reached the screen before. The overview now lists each short
window with its gap and the ranked remedies underneath, one line each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 15:12:32 +02:00
parent f76de7ba04
commit 11f2a17b43
7 changed files with 480 additions and 40 deletions
@@ -10,6 +10,7 @@ import {
makePlanProposal,
makeWindowOutcome,
makeSurplusAdvice,
makeShortfallRemedy,
makePoolBalance,
makeVacationBalance,
} from '../../../tests/fixtures'
@@ -193,6 +194,147 @@ describe('PlanFold', () => {
expect(screen.getByTestId('plan-fold-vacation-balances')).toHaveTextContent('25')
})
it('does not render a shortfalls section when no window has a shortfall', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({ outcomes: [makeWindowOutcome({ shortfallDays: 0, remedies: [] })] }),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.queryByTestId('plan-fold-shortfalls')).not.toBeInTheDocument()
})
it('renders the shortfall line and its remedies for a window that ran short', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-1',
wishName: 'Sportlov',
year: 2030,
shortfallDays: 2,
remedies: [
makeShortfallRemedy({
kind: 'carry_vacation_forward',
messageKey: 'planRemedies.carry_vacation_forward',
data: { spare_total: 5 },
daysFreed: 5,
}),
],
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
const shortfall = screen.getByTestId('plan-fold-shortfall-lw-1-2030')
expect(shortfall).toHaveTextContent('Sportlov 2030')
expect(shortfall).toHaveTextContent('2 dag(ar) kunde inte finansieras')
expect(screen.getByTestId('plan-fold-remedy-lw-1-2030-0')).toHaveTextContent(
'5 semesterdagar från tidigare år kan sparas framåt',
)
})
it('renders the widened shift-window remedy with its concrete dates', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-2',
wishName: 'Vårvecka',
year: 2028,
shortfallDays: 5,
remedies: [
makeShortfallRemedy({
kind: 'shift_window_into_free_days',
messageKey: 'planRemedies.shift_window_into_free_days',
data: {
wish_name: 'Vårvecka',
new_start_date: '2028-04-11',
new_end_date: '2028-04-17',
shift_gain: 2,
},
daysFreed: 2,
}),
],
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-remedy-lw-2-2028-0')).toHaveTextContent(
'2028-04-11 – 2028-04-17',
)
})
it('renders a window with multiple remedies as one line each', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-3',
year: 2027,
shortfallDays: 4,
remedies: [
makeShortfallRemedy({
kind: 'reduce_preschool_reserve',
messageKey: 'planRemedies.reduce_preschool_reserve',
data: { reserved: 3 },
daysFreed: 3,
}),
makeShortfallRemedy({
kind: 'lower_priority_wish',
messageKey: 'planRemedies.lower_priority_wish',
data: { wish_name: 'Höstlov', from_weeks: 2, to_weeks: 1 },
daysFreed: 4,
}),
],
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-remedy-lw-3-2027-0')).toHaveTextContent(
'Minska förskolans planeringsdagar (3 reserverade) frigör 3 dagar',
)
expect(screen.getByTestId('plan-fold-remedy-lw-3-2027-1')).toHaveTextContent(
'Sänk Höstlov från 2 till 1 veckor frigör 4 dagar',
)
})
it('falls back to the raw messageKey for an unrecognised remedy kind', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(
makePlanProposalResult({
data: makePlanProposal({
outcomes: [
makeWindowOutcome({
wishId: 'lw-4',
year: 2027,
shortfallDays: 1,
remedies: [
makeShortfallRemedy({
kind: 'brand_new_kind',
messageKey: 'planRemedies.brand_new_kind',
data: {},
daysFreed: 1,
}),
],
}),
],
}),
}),
)
render(<PlanFold workspaceId="ws-1" members={members} />)
expect(screen.getByTestId('plan-fold-remedy-lw-4-2027-0')).toHaveTextContent(
'planRemedies.brand_new_kind',
)
})
it('renders nothing beyond the summary/hint when there is no proposal yet', () => {
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ data: null }))
render(<PlanFold workspaceId="ws-1" members={members} />)
+56 -3
View File
@@ -15,8 +15,10 @@
* "persists nothing, safe to spam" (`backend/app/routers/plan.py`).
*
* Dropped relative to the old `PlanResults.tsx` (content not duplicated here
* since the Hero and `Age4Bars` already cover it): the warnings section, the
* age4Projections table, and the per-window explanation/remedy detail cards.
* since the Hero and `Age4Bars` already cover it): the warnings section and
* the age4Projections table. The per-window shortfall + remedies section
* (§todo item 13) is NOT dropped — see the "plan-fold-shortfalls" section
* below, a from-scratch render (not a port of the old detail cards).
* Also dropped, as of the move to `/api/plan/sync`: the apply button,
* collision-confirmation UI, and rejection rendering — applying is no
* longer a thing this fold does (see `frontend/src/utils/rejectionMessages.ts`
@@ -24,7 +26,7 @@
* `Calendar.tsx`).
*/
import { ReactNode, useEffect } from 'react'
import type { PersonalData, SurplusAdvice, WindowOutcome } from '@/types'
import type { PersonalData, ShortfallRemedy, SurplusAdvice, WindowOutcome } from '@/types'
import { useLeaveWishes, usePlanProposal } from '@/hooks'
import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
import { nameResolver } from '@/utils/parentNames'
@@ -32,6 +34,26 @@ import { resolveWishName } from '@/utils/wishName'
type Translate = (key: TranslationKey, params?: TranslationParams) => string
// §todo item 13, part 3 — the shortfall remedies `_build_remedies`
// (allocation_service.py) already computes and puts on the wire were never
// rendered anywhere (ideas.md §3's "the engine can't explain itself"
// complaint). Mirrors `SURPLUS_ADVICE_BODY_KEYS` below: `messageKey` ->
// the body template that interpolates the remedy's own `data`.
const REMEDY_BODY_KEYS: Record<string, TranslationKey> = {
'planRemedies.reduce_preschool_reserve': 'planResults.remedyBodyReducePreschoolReserve',
'planRemedies.carry_vacation_forward': 'planResults.remedyBodyCarryVacationForward',
'planRemedies.lower_priority_wish': 'planResults.remedyBodyLowerPriorityWish',
'planRemedies.shift_window_into_free_days': 'planResults.remedyBodyShiftWindowIntoFreeDays',
}
function renderRemedyLine(remedy: ShortfallRemedy, t: Translate): string {
const key = REMEDY_BODY_KEYS[remedy.messageKey]
if (!key) return remedy.messageKey
const params = toTranslationParams(remedy.data)
params.days_freed = remedy.daysFreed
return t(key, params)
}
// §9.2 surplus-advice kinds — originally mirrored the deleted `PlanResults.tsx`'s
// `SURPLUS_ADVICE_KEYS`/`SURPLUS_ADVICE_BODY_KEYS`; this is now the sole copy
// (see the file banner above).
@@ -151,6 +173,37 @@ export function PlanFold({ workspaceId, members }: PlanFoldProps): ReactNode {
)}
</section>
{proposal.outcomes.some((o) => o.shortfallDays > 0) && (
<section className="plan-fold__section" data-testid="plan-fold-shortfalls">
<h4>{t('planResults.shortfallsTitle')}</h4>
<ul>
{proposal.outcomes
.filter((outcome) => outcome.shortfallDays > 0)
.map((outcome) => (
<li
key={`${outcome.wishId}-${outcome.year}`}
data-testid={`plan-fold-shortfall-${outcome.wishId}-${outcome.year}`}
>
<strong>
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}{' '}
{outcome.year}
</strong>
<p>{t('planResults.shortfallLine', { shortfall_days: outcome.shortfallDays })}</p>
{outcome.remedies.length > 0 && (
<ul data-testid={`plan-fold-remedies-${outcome.wishId}-${outcome.year}`}>
{outcome.remedies.map((remedy, idx) => (
<li key={`${remedy.kind}-${idx}`} data-testid={`plan-fold-remedy-${outcome.wishId}-${outcome.year}-${idx}`}>
{renderRemedyLine(remedy, t)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</section>
)}
{proposal.surplusAdvice.length > 0 && (
<section className="plan-fold__section" data-testid="plan-fold-surplus-advice">
<h4>{t('planResults.surplusAdviceTitle')}</h4>
+6
View File
@@ -11,6 +11,10 @@ export const plan = {
'planResults.leavesHeader': 'Leave periods',
'planResults.noWindowsInHorizon': 'No windows within the planning horizon.',
'planResults.parentHeader': 'Parent',
'planResults.remedyBodyCarryVacationForward': '{spare_total} vacation day(s) from earlier years can be carried forward',
'planResults.remedyBodyLowerPriorityWish': 'Lowering {wish_name} from {from_weeks} to {to_weeks} week(s) frees {days_freed} day(s)',
'planResults.remedyBodyReducePreschoolReserve': 'Reducing the preschool planning-day reserve ({reserved} reserved) frees {days_freed} day(s)',
'planResults.remedyBodyShiftWindowIntoFreeDays': 'Moving the period to {new_start_date} – {new_end_date} frees {days_freed} day(s)',
'planResults.rejectionCollisionsNeedReplace': '{count} day(s) already planned differently; pass replace=true to overwrite',
'planResults.rejectionConflictingDuplicate': 'Conflicting duplicate entries for {date} (child {child_id}, parent {parent_name}) in the same batch',
'planResults.rejectionDubbeldagBatchQuotaExceeded': 'This batch would result in {projected_total} double days, the max is {max_dubbeldagar}',
@@ -21,6 +25,8 @@ export const plan = {
'planResults.rejectionInsufficientVacationBalance': '{parent_name} would need {needed} more vacation day(s) in {year}, but only {remaining} remain',
'planResults.rejectionInvalidDayType': 'Day {index} ({date}): invalid day type "{day_type}"',
'planResults.rejectionOutsideEligibleWindow': '{date}: outside the child\'s eligible window ({birth_date} – {hard_expiry})',
'planResults.shortfallLine': '{shortfall_days} day(s) could not be funded',
'planResults.shortfallsTitle': 'Periods with a shortfall',
'planResults.sjukpenningLeftHeader': 'Sjukpenningnivå days left',
'planResults.surplusAdviceBodyFrontLoadEarlyYears': '{parent_name} has a large surplus ({days_involved} parental-benefit days) — take more days now while the child is still small and you are home anyway, rather than spreading them thinly until the 4th birthday.',
'planResults.surplusAdviceBodyShiftMixToFp': '{parent_name} could swap {days_involved} vacation day(s) for parental-benefit days in this plan — the parental-benefit days would otherwise be forfeited at the 4th birthday, while the vacation days would not.',
+6
View File
@@ -11,6 +11,10 @@ export const plan = {
'planResults.leavesHeader': 'Ledigheter',
'planResults.noWindowsInHorizon': 'Inga fönster inom planeringshorisonten.',
'planResults.parentHeader': 'Förälder',
'planResults.remedyBodyCarryVacationForward': '{spare_total} semesterdagar från tidigare år kan sparas framåt',
'planResults.remedyBodyLowerPriorityWish': 'Sänk {wish_name} från {from_weeks} till {to_weeks} veckor frigör {days_freed} dagar',
'planResults.remedyBodyReducePreschoolReserve': 'Minska förskolans planeringsdagar ({reserved} reserverade) frigör {days_freed} dagar',
'planResults.remedyBodyShiftWindowIntoFreeDays': 'Flytta perioden till {new_start_date} – {new_end_date} frigör {days_freed} dagar',
'planResults.rejectionCollisionsNeedReplace': '{count} dag(ar) är redan planerade på annat sätt; skicka replace=true för att skriva över',
'planResults.rejectionConflictingDuplicate': 'Motstridiga dubbletter för {date} (barn {child_id}, förälder {parent_name}) i samma omgång',
'planResults.rejectionDubbeldagBatchQuotaExceeded': 'Omgången skulle resultera i {projected_total} dubbeldagar, max är {max_dubbeldagar}',
@@ -21,6 +25,8 @@ export const plan = {
'planResults.rejectionInsufficientVacationBalance': '{parent_name} skulle behöva {needed} fler semesterdag(ar) {year}, men endast {remaining} finns kvar',
'planResults.rejectionInvalidDayType': 'Dag {index} ({date}): ogiltig dagtyp "{day_type}"',
'planResults.rejectionOutsideEligibleWindow': '{date}: utanför barnets tillåtna period ({birth_date} – {hard_expiry})',
'planResults.shortfallLine': '{shortfall_days} dag(ar) kunde inte finansieras',
'planResults.shortfallsTitle': 'Perioder med underskott',
'planResults.sjukpenningLeftHeader': 'Sjukpenningdagar kvar',
'planResults.surplusAdviceBodyFrontLoadEarlyYears': '{parent_name} har ett stort överskott ({days_involved} FP-dagar) — ta ut fler dagar nu medan barnet är litet och ni ändå är hemma, istället för att sprida ut dem tunt fram till 4-årsdagen.',
'planResults.surplusAdviceBodyShiftMixToFp': '{parent_name} kan byta {days_involved} semesterdagar mot föräldrapenningdagar under planen — FP-dagarna går annars förlorade vid 4-årsdagen, medan semestern inte gör det.',