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
+87 -36
View File
@@ -150,6 +150,19 @@ _SPENDABLE_DAYS_PER_WEEK = 5
_LARGE_SURPLUS_THRESHOLD_DAYS = 4 * _SPENDABLE_DAYS_PER_WEEK
# "Small child" window for the front_load_early_years suggestion.
_FRONT_LOAD_CHILD_AGE_YEARS = 2
# §todo item 13 — how far `_shift_window_into_free_days_remedy` looks either
# side of a fixed window's placed dates for a cheaper placement. Sized to
# reliably catch a moving public holiday (Easter): the cached calendar
# (`swedish_calendar.json`, 2026-2055) shows a max year-to-year Easter Sunday
# drift of ~20 days, and `LeaveWish.window_for_year`'s weekday-preserving
# snap (fixed-mode recurrence) can shift a naive same-month/day anchor by up
# to another 3 days -- 28 (4 weeks) covers that worst case with a small
# margin. Kept a module constant, not unbounded, so `propose()` stays cheap:
# each remedy call is O(bound * window_length) `is_free_day` lookups, each
# already O(1).
_SHIFT_REMEDY_SEARCH_BOUND_DAYS = 28
# How many ranked alternative placements the remedy reports at most.
_SHIFT_REMEDY_MAX_SUGGESTIONS = 2
# ---------------------------------------------------------------------------
@@ -1555,7 +1568,11 @@ class _Ledger:
# -- shortfall remedies (§3.6, §9.1) ------------------------------------
def _build_remedies(
self, wish: LeaveWish, w: _WishWindow, shortfall_days: int
self,
wish: LeaveWish,
w: _WishWindow,
shortfall_days: int,
include_shift_remedy: bool = True,
) -> list[ShortfallRemedy]:
if shortfall_days <= 0:
return []
@@ -1563,7 +1580,13 @@ class _Ledger:
remedies.extend(self._reduce_preschool_reserve_remedy(wish))
remedies.extend(self._carry_vacation_forward_remedy(wish, w))
remedies.extend(self._lower_priority_wish_remedy(wish))
remedies.extend(self._shift_window_into_free_days_remedy(wish, w))
# §10.4 — `include_shift_remedy=False` from `build_unplaced_outcome`:
# there `w` is the whole bounding range, not a single placed window,
# so there is no one winning edge to shift and no point running the
# (now real, `_SHIFT_REMEDY_SEARCH_BOUND_DAYS`-wide) search only to
# discard its result.
if include_shift_remedy:
remedies.extend(self._shift_window_into_free_days_remedy(wish, w))
return remedies
def _reduce_preschool_reserve_remedy(self, wish: LeaveWish) -> list[ShortfallRemedy]:
@@ -1663,35 +1686,65 @@ class _Ledger:
def _shift_window_into_free_days_remedy(
self, wish: LeaveWish, w: _WishWindow
) -> list[ShortfallRemedy]:
shift_gain = 0
probe = w.start - timedelta(days=1)
for _ in range(3):
if is_free_day(probe, self._input.holidays, self._input.day_overrides):
shift_gain += 1
probe -= timedelta(days=1)
else:
break
probe = w.end + timedelta(days=1)
for _ in range(3):
if is_free_day(probe, self._input.holidays, self._input.day_overrides):
shift_gain += 1
probe += timedelta(days=1)
else:
break
if shift_gain <= 0:
return []
return [
ShortfallRemedy(
kind=REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS,
message_key=f"planRemedies.{REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS}",
data={
"wish_name": wish.name,
"wish_name_message_key": wish.name_message_key,
"shift_gain": shift_gain,
},
days_freed=shift_gain,
"""§todo item 13, part 2 — a real search over the surrounding weeks
for a cheaper placement of this exact-length window, not just the
3-days-either-side nudge this used to be. The motivating case is a
moving public holiday (Easter): a wish fixed at the same month/day
every year drifts further from Easter in some years than others, and
a placement a couple of weeks away can be strictly cheaper.
Reuses `_candidate_cost` — the same "fewest spendable days" cost
model `_rank_candidate`/`resolve_flexible_window` already use to
score a flexible wish's placement — rather than inventing a second
scorer. Candidates are every same-length window inside
`[w.start - _SHIFT_REMEDY_SEARCH_BOUND_DAYS, w.end +
_SHIFT_REMEDY_SEARCH_BOUND_DAYS]` (via `_candidate_windows`, the same
sliding-window generator flexible resolution uses), ranked by
(cost, distance from the current placement, start date) and reported
only when strictly cheaper than the window actually funded.
"""
length_days = (w.end - w.start).days + 1
current_cost = _candidate_cost(
w.start, w.end, self._input.holidays, self._input.day_overrides
)
if current_cost <= 0:
return [] # already all free days -- nowhere cheaper to move to
bound_start = w.start - timedelta(days=_SHIFT_REMEDY_SEARCH_BOUND_DAYS)
bound_end = w.end + timedelta(days=_SHIFT_REMEDY_SEARCH_BOUND_DAYS)
scored: list[tuple[int, int, date, date]] = []
for c_start, c_end in _candidate_windows(bound_start, bound_end, length_days):
if c_start == w.start and c_end == w.end:
continue
c_cost = _candidate_cost(
c_start, c_end, self._input.holidays, self._input.day_overrides
)
]
if c_cost < current_cost:
scored.append((c_cost, abs((c_start - w.start).days), c_start, c_end))
if not scored:
return []
scored.sort(key=lambda t: (t[0], t[1], t[2]))
remedies: list[ShortfallRemedy] = []
for c_cost, _distance, c_start, c_end in scored[:_SHIFT_REMEDY_MAX_SUGGESTIONS]:
shift_gain = current_cost - c_cost
remedies.append(
ShortfallRemedy(
kind=REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS,
message_key=f"planRemedies.{REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS}",
data={
"wish_name": wish.name,
"wish_name_message_key": wish.name_message_key,
"shift_gain": shift_gain,
"new_start_date": c_start.isoformat(),
"new_end_date": c_end.isoformat(),
},
days_freed=shift_gain,
)
)
return remedies
# -- flexible ("när det passar bäst") wish resolution (§10.2) ----------
@@ -1971,12 +2024,10 @@ class _Ledger:
)
# §10.4: every remedy `_build_remedies` can generate still makes sense
# against the clipped bound EXCEPT shift_window_into_free_days — there
# is no single winning window edge left to shift.
remedies = [
r
for r in self._build_remedies(wish, bound_window, length_days)
if r.kind != REMEDY_SHIFT_WINDOW_INTO_FREE_DAYS
]
# is no single winning window edge left to shift (`bound_window`
# spans the whole bound, not one placement), so skip computing it
# rather than discarding its result after the fact.
remedies = self._build_remedies(wish, bound_window, length_days, include_shift_remedy=False)
responsible = self._resolve_parents(wish)
if responsible.mode == "dubbeldag":
requested_strategy = "dubbeldag" if wish.wants_dubbeldag else STRATEGY_CO_LEAVE
+3 -1
View File
@@ -206,7 +206,9 @@ _SV_TEMPLATES: dict[str, str] = {
"planRemedies.lower_priority_wish": (
"Sänk {wish_name} från {from_weeks} till {to_weeks} veckor frigör {freed_days} dagar"
),
"planRemedies.shift_window_into_free_days": "Flytta {wish_name} närmare en helg frigör {shift_gain} dagar",
"planRemedies.shift_window_into_free_days": (
"Flytta {wish_name} till {new_start_date} – {new_end_date} frigör {shift_gain} dagar"
),
"planWarnings.days_lost_at_age_4": (
"riskerar att förlora {expiring_days} dagar vid barnets 4-årsdag "
"({fourth_birthday}) om inte fler dagar tas ut innan dess"
@@ -2842,3 +2842,183 @@ class TestCase33LongChunkCoLeave:
count_b = fp_payer_sequence.count(parent_b.uuid)
assert count_a > 0 and count_b > 0
assert abs(count_a - count_b) <= 2
# ---------------------------------------------------------------------------
# §todo item 13, part 1 — PLANNING_ENGINE_SPEC.md §3.6 "shortfall, not
# failure": a yearly wish whose pool affords only ~3.5 of its 4 wished weeks
# must fund the first three years in full and PART-fund the fourth (never
# drop it) -- the user's own example ("1 week/spring, enough days for 3 of 4
# years").
# ---------------------------------------------------------------------------
class TestCase34PartialFundingAcrossYearsShortfallNotFailure:
def test_fourth_year_partially_funded_not_dropped(self) -> None:
# Any 7-day window contains exactly one Saturday + one Sunday
# (verified below against real spendable-day counts, not assumed),
# so every one of these 4 one-week windows costs exactly 5 spendable
# days regardless of which weekday it starts on -- a clean way to
# make the pool math ("18 covers 3 years + 3/5 of a 4th") exact.
# August has no Swedish red days nearby, so tier-2 never fires.
child = _child(birth_date=date(2027, 9, 1)) # fourth birthday well after 2030
parent = _parent(salary_type="hourly")
wish = _wish(
child_id=child.id,
name="Sportlov",
start=(8, 2),
end=(8, 8),
weeks=1,
recurrence="yearly",
first_year=2027,
last_year=2030,
priority=1,
)
# A lower-priority sibling wish purely so `lower_priority_wish` has
# something to suggest once the pool runs dry -- its own funding
# (or lack of it) is irrelevant to this test.
sibling = _wish(
child_id=child.id,
name="Höstlov",
start=(11, 1),
end=(11, 14),
weeks=2,
first_year=2027,
priority=2,
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2030, 12, 31),
children=[child],
parents=[parent],
# 18 sjukpenning days: exactly 3 full years (3x5=15) + 3 of the
# 4th year's 5 spendable days -- a genuine partial-year
# shortfall, not a full-window one.
pools={(parent.uuid, child.id): DayPool(18, 0, 60)},
vacation={(parent.uuid, y): VacationBudget(0, 0, 0) for y in range(2026, 2031)},
wishes=[wish, sibling],
# `_build_input` only auto-merges holidays for
# {today.year, horizon_end.year, horizon_end.year + 1} =
# {2027, 2030, 2031} -- without this, 2028/2029 would be missing
# their Sunday markers entirely (only Saturday is free-by-
# weekday) and silently inflate their spendable-day counts.
extra_holiday_years=(2028, 2029),
)
proposal = Allocator().propose(alloc_input)
by_year = {o.year: o for o in proposal.outcomes if o.wish_id == wish.id}
assert set(by_year) == {2027, 2028, 2029, 2030}
for y in (2027, 2028, 2029):
outcome = by_year[y]
assert outcome.shortfall_days == 0, (y, outcome)
assert outcome.weeks_funded == 1.0
assert outcome.cost_breakdown["sjukpenning"] == 5
assert outcome.cost_breakdown["free"] == 2
# The fourth year is NOT dropped: it is still a real WindowOutcome
# with real funded days, just an incomplete one.
last = by_year[2030]
assert last.shortfall_days == 2
assert last.weeks_funded == round(5 / 7, 2)
assert last.cost_breakdown["sjukpenning"] == 3
assert last.cost_breakdown["free"] == 2
assert last.remedies, "expected at least one remedy for the shortfall year"
assert "lower_priority_wish" in {r.kind for r in last.remedies}
# A usable plan, not a refusal: the fourth year still has concrete
# `ProposedDay`s for the days it could actually afford (2 free + 3
# sjukpenning = 5), not zero.
year4_days = [d for d in proposal.days if d.wish_id == wish.id and d.date.year == 2030]
assert len(year4_days) == 5
assert sum(1 for d in year4_days if d.day_type == "sjukpenning") == 3
# ---------------------------------------------------------------------------
# §todo item 13, part 2 — the widened `shift_window_into_free_days` remedy
# must find a genuinely better placement around a MOVING public holiday
# (Easter), not just the +/-3-day nudge it used to be. Real dates from
# `backend/app/data/swedish_calendar.json`: Easter Sunday is 2027-03-28 and
# 2028-04-16 (18 days apart) -- a fixed wish anchored at the same March
# month/day drifts a different distance from Easter each year, verified
# against `LeaveWish.window_for_year`'s own weekday-preserving snap (not
# hand-derived) below.
# ---------------------------------------------------------------------------
class TestCase35ShiftRemedyFindsMovingEaster:
def test_widened_search_finds_that_years_easter_cluster(self) -> None:
child = _child(birth_date=date(2026, 1, 1)) # fourth birthday 2030 -- past both years
parent = _parent(salary_type="hourly")
wish = _wish(
child_id=child.id,
name="Vårvecka",
start=(3, 22),
end=(3, 28),
weeks=1,
recurrence="yearly",
first_year=2027,
last_year=2028,
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2028, 12, 31),
children=[child],
parents=[parent],
# Deliberately starved (1 sjukpenning day total, shared across
# both years) so both years carry a genuine shortfall -- the
# shift remedy is calendar-only and fires independent of *how*
# short a window is, but `_build_remedies` still gates on
# `shortfall_days > 0`.
pools={(parent.uuid, child.id): DayPool(1, 0, 60)},
vacation={
(parent.uuid, 2027): VacationBudget(0, 0, 0),
(parent.uuid, 2028): VacationBudget(0, 0, 0),
},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
by_year = {o.year: o for o in proposal.outcomes}
# The wish's own placement each year, from the production entity --
# NOT hand-derived -- confirms the weekday-preserving snap moves the
# naive 2028 anchor from Mon 3/20 rather than literally 3/22.
assert wish.window_for_year(2027) == (date(2027, 3, 22), date(2027, 3, 28))
assert wish.window_for_year(2028) == (date(2028, 3, 20), date(2028, 3, 26))
outcome_2027 = by_year[2027]
assert outcome_2027.shortfall_days > 0
shift_2027 = next(
r for r in outcome_2027.remedies if r.kind == "shift_window_into_free_days"
)
# Best placement: shift the window 1 day later to pick up Easter
# Monday (3/29 is a real holiday; the anchor window ends 3/28).
assert shift_2027.data["new_start_date"] == "2027-03-23"
assert shift_2027.data["new_end_date"] == "2027-03-29"
assert shift_2027.days_freed == 1
assert shift_2027.data["shift_gain"] == 1
outcome_2028 = by_year[2028]
assert outcome_2028.shortfall_days > 0
shift_2028 = next(
r for r in outcome_2028.remedies if r.kind == "shift_window_into_free_days"
)
# Best placement: the window that fully overlaps Easter 2028's
# cluster (Good Friday 4/14 .. Easter Monday 4/17) -- 22 days later
# than the anchor's own weekday-snapped placement (3/20-3/26), well
# beyond the old +/-3-day probe but inside the widened search bound.
assert shift_2028.data["new_start_date"] == "2028-04-11"
assert shift_2028.data["new_end_date"] == "2028-04-17"
assert shift_2028.days_freed == 2
assert shift_2028.data["shift_gain"] == 2
# Easter Sunday genuinely falls inside the suggested window.
assert date(2028, 4, 16) <= date(2028, 4, 17) # sanity: window contains it
assert date(2028, 4, 11) <= date(2028, 4, 16) <= date(2028, 4, 17)
# Both wish-name provenance fields still travel onto the remedy
# (§branches test already covers this for the non-Easter case; keep
# it here too since this remedy's `data` shape just grew).
assert shift_2027.data["wish_name_message_key"] == wish.name_message_key
assert shift_2028.data["wish_name_message_key"] == wish.name_message_key
@@ -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.',