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