diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index 2bef20d..efd6578 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -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 diff --git a/backend/scripts/demo_plan.py b/backend/scripts/demo_plan.py index fea24d2..49b5bd5 100644 --- a/backend/scripts/demo_plan.py +++ b/backend/scripts/demo_plan.py @@ -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" diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index 712010f..264c9eb 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -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 diff --git a/frontend/src/components/dashboard/PlanFold.test.tsx b/frontend/src/components/dashboard/PlanFold.test.tsx index 7c8b2a5..921ea8f 100644 --- a/frontend/src/components/dashboard/PlanFold.test.tsx +++ b/frontend/src/components/dashboard/PlanFold.test.tsx @@ -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() + 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() + 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() + 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() + 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() + 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() diff --git a/frontend/src/components/dashboard/PlanFold.tsx b/frontend/src/components/dashboard/PlanFold.tsx index 335956b..47ef085 100644 --- a/frontend/src/components/dashboard/PlanFold.tsx +++ b/frontend/src/components/dashboard/PlanFold.tsx @@ -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 = { + '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 { )} + {proposal.outcomes.some((o) => o.shortfallDays > 0) && ( +
+

{t('planResults.shortfallsTitle')}

+
    + {proposal.outcomes + .filter((outcome) => outcome.shortfallDays > 0) + .map((outcome) => ( +
  • + + {resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}{' '} + {outcome.year} + +

    {t('planResults.shortfallLine', { shortfall_days: outcome.shortfallDays })}

    + {outcome.remedies.length > 0 && ( +
      + {outcome.remedies.map((remedy, idx) => ( +
    • + {renderRemedyLine(remedy, t)} +
    • + ))} +
    + )} +
  • + ))} +
+
+ )} + {proposal.surplusAdvice.length > 0 && (

{t('planResults.surplusAdviceTitle')}

diff --git a/frontend/src/i18n/en/plan.ts b/frontend/src/i18n/en/plan.ts index 2ed62e4..242dd51 100644 --- a/frontend/src/i18n/en/plan.ts +++ b/frontend/src/i18n/en/plan.ts @@ -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.', diff --git a/frontend/src/i18n/sv/plan.ts b/frontend/src/i18n/sv/plan.ts index b7e6d69..cbbb8db 100644 --- a/frontend/src/i18n/sv/plan.ts +++ b/frontend/src/i18n/sv/plan.ts @@ -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.',