From 1a02bdcdbf10988c1e6e3a94e77c0b10f9f0b8dd Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Tue, 22 Sep 2026 20:49:56 +0200 Subject: [PATCH] Let vacation years go back to the child's birth year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings only offered vacation-day rows from the current year forward, while analytics ran its balance series from the child's birth year. Every year in the gap had no vacation_days row, so the series assumed a full statutory 25 earned with no use and banked earned - floor of it forward — phantom saved days the user had no row to correct. Settings now offers editable rows back to the birth year, and a past year with no row assumes only the floor was earned, so it banks nothing. A past year the user did enter is untouched by that rule. Co-Authored-By: Claude Opus 5 --- backend/app/services/allocation_service.py | 10 +- backend/app/services/analytics_service.py | 31 +++++- backend/app/services/tips_service.py | 12 ++- backend/tests/unit/test_analytics_router.py | 11 ++- .../test_services/test_analytics_service.py | 97 +++++++++++++++++-- .../vacation-days/VacationDaysTable.test.tsx | 14 +++ .../vacation-days/VacationDaysTable.tsx | 11 ++- frontend/src/i18n/en/settings.ts | 3 +- frontend/src/i18n/sv/settings.ts | 3 +- frontend/src/pages/Settings.test.tsx | 37 ++++++- frontend/src/pages/Settings.tsx | 45 +++++++-- frontend/src/types/entities.ts | 7 +- 12 files changed, 248 insertions(+), 33 deletions(-) diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index 6871739..f053fc3 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -3896,8 +3896,16 @@ class AllocationInputLoader: for pd in parent_planned_days: if pd.day_type == "vacation": use_by_year[pd.date.year] = use_by_year.get(pd.date.year, 0) + 1 + # `forecast_years` starts at `today.year` (never earlier), so + # every year this simulation sees already satisfies `year >= + # current_year` — the past-year `earned = floor` rule added for + # Settings' widened range never actually fires here. vacation_balances = AnalyticsService._vacation_year_series( - forecast_years, vac_by_year, use_by_year, parent.min_vacation_days_per_year + forecast_years, + vac_by_year, + use_by_year, + parent.min_vacation_days_per_year, + today.year, ) for year, balance in zip(forecast_years, vacation_balances, strict=True): if balance.expiring > 0: diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index 87d4be4..e92bf0c 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -114,6 +114,18 @@ class ParentYearBalance: purely to the over-cap cause (e.g. earned 30, floor 20, used 15 → 5 days over-cap lost) while the dashboard showed "Expiring 0" — that silent gap is why the breakdown exists now. + - `earned` for a year with no `vacation_days` row depends on whether the + year is in the past relative to `current_year` (the caller's "now"). + `year >= current_year` assumes the full statutory entitlement, same as + always — unconfirmed (`is_assumed=True`), but a forward-looking guess. + `year < current_year` instead assumes only the floor was earned, so + `current_year_saveable` is `max(floor - floor, 0) = 0` and + `carried_out = min(left, old_carry_forward)` — a past year with no row + never phantom-banks days the user never told us they saved. Settings + offers editable rows back to the child's birth year precisely so a + genuine past figure can replace this assumption. A row the user DID + enter for a past year is untouched by this rule: `earned` is always + that row's `total_days`, regardless of year. `saved_days_in` on a `vacation_days` row is normally only used to *seed* the bank for the first year of the series (year-earned = year − 1, since @@ -352,7 +364,7 @@ class AnalyticsService: vacation_use_by_year[d.date.year] = vacation_use_by_year.get(d.date.year, 0) + 1 vacation_balances = self._vacation_year_series( - years, vacation_by_year, vacation_use_by_year, floor + years, vacation_by_year, vacation_use_by_year, floor, date.today().year ) fp_rows = [ @@ -387,9 +399,12 @@ class AnalyticsService: vacation_by_year: dict[int, VacationDays], use_by_year: dict[int, int], floor: int, + current_year: int, ) -> list[VacationYearBalance]: """FIFO carry-forward bank simulation — see `ParentYearBalance`'s - docstring for the full algorithm this implements.""" + docstring for the full algorithm this implements, including the + past-vs-current-year `earned` rule (`current_year`) applied below for + a year with no entered `vacation_days` row.""" carry_years = settings.rules.vacation_carry_forward_years default_earned = settings.rules.statutory_vacation_days_per_year # (year_earned, remaining_count) entries, oldest first. @@ -398,7 +413,17 @@ class AnalyticsService: for i, year in enumerate(years): row = vacation_by_year.get(year) - earned = row.total_days if row is not None else default_earned + if row is not None: + earned = row.total_days + elif year >= current_year: + earned = default_earned + else: + # Past year, no entered row: assume only the floor was + # earned/used, not the full statutory default — see + # `ParentYearBalance`'s docstring. Keeps a phantom past year + # from banking `default_earned - floor` days the user never + # actually saved. + earned = floor is_assumed = row is None if i == 0: diff --git a/backend/app/services/tips_service.py b/backend/app/services/tips_service.py index 2005d4e..8310c5c 100644 --- a/backend/app/services/tips_service.py +++ b/backend/app/services/tips_service.py @@ -785,8 +785,18 @@ class TipsService: known_years = {v.year for v in rows} | set(use_by_year.keys()) | {today.year} start_year = min(min(known_years), today.year - (carry_years - 1)) years = list(range(start_year, today.year + 1)) + # `years` reaches back to `today.year - (carry_years - 1)` (or an + # earlier known year) so the FIFO bank simulation can correctly + # age out old entries by `today.year` — those earlier years are + # real past years, not a forecast, so they get the same + # past-year `earned = floor` assumption as Settings/Analytics + # for a year with no row (`current_year=today.year`), rather + # than silently assuming the full statutory default the way a + # future year would. Keeps this tip's forfeiture warning from + # disagreeing with the dashboard's own numbers for the same + # unconfirmed past year. series = AnalyticsService._vacation_year_series( - years, vacation_by_year, use_by_year, pd.min_vacation_days_per_year + years, vacation_by_year, use_by_year, pd.min_vacation_days_per_year, today.year ) vacation_year_balance_by_parent[pd.uuid] = series[-1] diff --git a/backend/tests/unit/test_analytics_router.py b/backend/tests/unit/test_analytics_router.py index f9be41c..0fcd17e 100644 --- a/backend/tests/unit/test_analytics_router.py +++ b/backend/tests/unit/test_analytics_router.py @@ -77,11 +77,18 @@ class TestGetOverviewYearBalances: "expiring_age", "expiring_over_cap", } - assert vacation["earned"] == 25 # statutory default — no vacation_days row entered + # `birth` (2025-01-01) is a PAST year relative to "today" in this + # test run — no `vacation_days` row was entered for it, so + # `_vacation_year_series`'s past-year rule applies: `earned` is the + # parent's floor (`PersonalData.min_vacation_days_per_year`, default + # 20), not the statutory default, so a phantom past year can't bank + # `25 - floor` days forward the user never actually saved. See + # `ParentYearBalance`'s docstring. + assert vacation["earned"] == 20 assert vacation["is_assumed"] is True assert vacation["carried_in"] == 0 assert vacation["planned_use"] == 0 - assert vacation["left"] == 25 + assert vacation["left"] == 20 parental_leave = birth_year["parental_leave"] assert set(parental_leave) == { diff --git a/backend/tests/unit/test_services/test_analytics_service.py b/backend/tests/unit/test_services/test_analytics_service.py index af7d620..0bc24fe 100644 --- a/backend/tests/unit/test_services/test_analytics_service.py +++ b/backend/tests/unit/test_services/test_analytics_service.py @@ -843,11 +843,18 @@ class TestVacationYearBalanceSeries: """Pure unit tests against `AnalyticsService._vacation_year_series` — the FIFO carry-forward bank simulation documented on `ParentYearBalance`. + + Every case here passes `current_year=years[0]` so its whole series is + "current year or later" — these tests exercise the FIFO mechanics + themselves, not the past-vs-current-year `earned` rule, which + `TestPastYearEarnedRule` below covers on its own. """ def test_no_rows_assumes_statutory_default_and_floor(self) -> None: years = [2025, 2026, 2027] - result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20) + result = AnalyticsService._vacation_year_series( + years, {}, {}, floor=20, current_year=years[0] + ) first = result[0] assert first.earned == 25 # rules.statutory_vacation_days_per_year default @@ -866,7 +873,9 @@ class TestVacationYearBalanceSeries: def test_carry_chain_accumulates_across_years(self) -> None: years = [2025, 2026, 2027] - result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20) + result = AnalyticsService._vacation_year_series( + years, {}, {}, floor=20, current_year=years[0] + ) # Unused years keep saving 5/year (25 earned - 20 floor) and the # prior years' saved days carry forward untouched. @@ -881,7 +890,9 @@ class TestVacationYearBalanceSeries: # Exactly carry_years + 1 years: only 2020's own batch can possibly # have aged out by the last year in this series. years = list(range(2020, 2020 + carry_years + 1)) - result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20) + result = AnalyticsService._vacation_year_series( + years, {}, {}, floor=20, current_year=years[0] + ) by_year = {r.year: r for r in result} # 2020's 5 saved days must not still be sitting in `carried_in` @@ -905,7 +916,9 @@ class TestVacationYearBalanceSeries: carry_years = settings.rules.vacation_carry_forward_years years = list(range(2020, 2020 + carry_years + 3)) - result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20) + result = AnalyticsService._vacation_year_series( + years, {}, {}, floor=20, current_year=years[0] + ) by_year = {r.year: r for r in result} for year in years[carry_years:]: @@ -931,7 +944,9 @@ class TestVacationYearBalanceSeries: saved_days_in=12, ), } - result = AnalyticsService._vacation_year_series(years, rows, {}, floor=20) + result = AnalyticsService._vacation_year_series( + years, rows, {}, floor=20, current_year=years[0] + ) by_year = {r.year: r for r in result} assert by_year[2025].carried_in == 0 # nothing entered/simulated yet @@ -954,7 +969,9 @@ class TestVacationYearBalanceSeries: saved_days_in=0, ), } - result = AnalyticsService._vacation_year_series(years, rows, {}, floor=20) + result = AnalyticsService._vacation_year_series( + years, rows, {}, floor=20, current_year=years[0] + ) by_year = {r.year: r for r in result} # 2025 saved 5 (25 - floor 20) with no usage; a zero saved_days_in on @@ -980,7 +997,9 @@ class TestVacationYearBalanceSeries: ), } use_by_year = {2025: 15} - result = AnalyticsService._vacation_year_series(years, rows, use_by_year, floor=20) + result = AnalyticsService._vacation_year_series( + years, rows, use_by_year, floor=20, current_year=years[0] + ) balance = result[0] assert balance.left == 15 @@ -1000,7 +1019,9 @@ class TestVacationYearBalanceSeries: carry_years = settings.rules.vacation_carry_forward_years years = list(range(2020, 2020 + carry_years + 1)) - result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20) + result = AnalyticsService._vacation_year_series( + years, {}, {}, floor=20, current_year=years[0] + ) last = result[-1] assert last.expiring_age == 5 @@ -1009,6 +1030,66 @@ class TestVacationYearBalanceSeries: assert last.expiring == last.expiring_age + last.expiring_over_cap +class TestPastYearEarnedRule: + """`_vacation_year_series`'s `current_year` parameter — the fix for + + Settings now offering editable rows back to the child's birth year + (`computeVacationYearRange`). Before this, a year before `current_year` + with no `vacation_days` row was indistinguishable from a future year: + it assumed the full statutory 25 and banked `25 - floor` forward even + though the row was never rendered for the user to correct — phantom + "saved" vacation. See `ParentYearBalance`'s docstring. + """ + + def test_phantom_past_years_with_no_row_carry_nothing_forward(self) -> None: + years = [2020, 2021, 2022] + result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20, current_year=2023) + by_year = {r.year: r for r in result} + + for year in years: + assert by_year[year].earned == 20 # floor, not the statutory 25 + assert by_year[year].is_assumed is True + assert by_year[year].carried_in == 0 + assert by_year[year].carried_out == 0 + assert [r.carried_in for r in result] == [0, 0, 0] + + def test_entered_past_row_still_carries_its_surplus_forward(self) -> None: + """A past year the user genuinely entered data for is untouched by + + the phantom-year rule above: its `total_days` still earns normally, + and any real surplus it banks still carries into the next (also + past) year — only a phantom (no-row) year banks nothing, never an + entered one. + """ + from app.entities.vacation_days import VacationDays + + years = [2020, 2021] + rows = { + 2020: VacationDays( + id=uuid4(), + personal_data_uuid=uuid4(), + workspace_id=uuid4(), + year=2020, + total_days=30, + saved_days_in=0, + ), + } + result = AnalyticsService._vacation_year_series( + years, rows, {}, floor=20, current_year=2023 + ) + by_year = {r.year: r for r in result} + + assert by_year[2020].earned == 30 # the entered row, not the floor + assert by_year[2020].is_assumed is False + assert by_year[2020].carried_out == 10 # 30 - floor(20), genuinely saved + + # 2021 has no row and is itself a past year, but 2020's REAL surplus + # must still show up here, untouched. + assert by_year[2021].earned == 20 # floor — 2021 itself has no row + assert by_year[2021].carried_in == 10 + assert by_year[2021].carried_out == 10 + + class TestParentalLeaveYearBalanceSeries: """Pure unit tests against `AnalyticsService._pl_year_series`.""" diff --git a/frontend/src/components/vacation-days/VacationDaysTable.test.tsx b/frontend/src/components/vacation-days/VacationDaysTable.test.tsx index 25756fb..6719ec4 100644 --- a/frontend/src/components/vacation-days/VacationDaysTable.test.tsx +++ b/frontend/src/components/vacation-days/VacationDaysTable.test.tsx @@ -223,4 +223,18 @@ describe('VacationDaysTable', () => { const atRisk = screen.getByTestId('vacation-at-risk-2026') expect(atRisk.title).toBe('4 dagar har passerat 5-årsgränsen för att spara semester') }) + + it('shows the past-year assumed prompt for an assumed row before the current year, not the statutory-25 wording', () => { + const rows = [ + { year: 2020, totalDays: 20, savedDaysIn: 0, isAssumed: true, isPast: true, expiring: 0, expiringAge: 0, expiringOverCap: 0 }, + ...ROWS, + ] + renderTable(rows, vi.fn().mockResolvedValue(undefined)) + + const badge = screen.getByTestId('vacation-assumed-badge-2020') + expect(badge.title).toBe( + 'Inget värde är registrerat för detta år — vi antar att du tog ut din semester och inte sparade något. Ändra nedan om du sparade dagar till året efter.', + ) + expect(badge.title).not.toContain('25 dagar') + }) }) diff --git a/frontend/src/components/vacation-days/VacationDaysTable.tsx b/frontend/src/components/vacation-days/VacationDaysTable.tsx index 22e74f9..f88b38f 100644 --- a/frontend/src/components/vacation-days/VacationDaysTable.tsx +++ b/frontend/src/components/vacation-days/VacationDaysTable.tsx @@ -59,9 +59,14 @@ export interface VacationDaysTableRow { totalDays: number savedDaysIn: number // True when no vacation-days entry exists yet for this year and the row - // shown is the statutory default (25 days) rather than something the user - // entered — §3.2. Prompts the user to confirm/override. + // shown is an assumed default rather than something the user entered — + // §3.2. Prompts the user to confirm/override. isAssumed?: boolean + // True when `year` is before the current calendar year — swaps the + // assumed-row prompt for one that doesn't claim the statutory 25 was + // assumed (a past year's assumed default is the per-parent floor, not + // the statutory figure — see `AnalyticsService._vacation_year_series`). + isPast?: boolean // Every vacation day forecast lost at this year's end — the sum of // `expiringAge` + `expiringOverCap` (see `VacationYearBalance` in // `types/entities.ts`). 0 when nothing is at risk. @@ -162,7 +167,7 @@ function VacationYearRow({ row, onSave }: VacationYearRowProps): ReactNode { {t('vacationDaysTable.assumedBadge')} diff --git a/frontend/src/i18n/en/settings.ts b/frontend/src/i18n/en/settings.ts index fb63303..1e06552 100644 --- a/frontend/src/i18n/en/settings.ts +++ b/frontend/src/i18n/en/settings.ts @@ -115,10 +115,11 @@ export const settings = { 'settings.takePlOnRedDaysError': 'Could not save the setting', 'settings.takePlOnRedDaysHelp': 'You get paid for Saturdays, Sundays and red days in the plan — but you spend days you would otherwise have kept, since vacation cannot be taken on these days.', 'settings.title': 'Settings', - 'settings.vacationHelp': 'Enter vacation days per year. In the first year you are usually home more and have fewer days to spend on vacation, while later years have your full annual allotment. This controls how much time the planner has to work with.', + 'settings.vacationHelp': 'Enter vacation days per year. In the first year you are usually home more and have fewer days to spend on vacation, while later years have your full annual allotment. This controls how much time the planner has to work with. Years are also editable back to the child\'s birth year — for a past year, enter the days you actually had left, not the full allotment.', 'settings.youBadge': '(You)', 'vacationDaysTable.assumedBadge': 'Assumed', 'vacationDaysTable.assumedPrompt': 'No value is recorded for this year — the statutory 25 days has been assumed. Confirm or edit below.', + 'vacationDaysTable.assumedPromptPast': 'No value is recorded for this year — we assume you used your vacation and saved nothing. Edit below if you carried days over.', 'vacationDaysTable.atRiskAgeDetail': '{days} days have passed the 5-year limit for saving vacation', 'vacationDaysTable.atRiskHeader': 'At risk', 'vacationDaysTable.atRiskOverCapDetail': '{days} days exceed how much may be saved for next year', diff --git a/frontend/src/i18n/sv/settings.ts b/frontend/src/i18n/sv/settings.ts index e4a3605..578e670 100644 --- a/frontend/src/i18n/sv/settings.ts +++ b/frontend/src/i18n/sv/settings.ts @@ -115,10 +115,11 @@ export const settings = { 'settings.takePlOnRedDaysError': 'Kunde inte spara inställningen', 'settings.takePlOnRedDaysHelp': 'Du får betalt för lördagar, söndagar och röda dagar i planeringen — men du förbrukar dagar du annars hade sparat, eftersom semester inte kan tas ut på dessa dagar.', 'settings.title': 'Inställningar', - 'settings.vacationHelp': 'Ange semesterdagar per år. Det första året är du oftast hemma mer och har färre dagar att lägga på semester, medan kommande år har din fulla årliga tilldelning. Detta styr hur mycket tid planeraren har att arbeta med.', + 'settings.vacationHelp': 'Ange semesterdagar per år. Det första året är du oftast hemma mer och har färre dagar att lägga på semester, medan kommande år har din fulla årliga tilldelning. Detta styr hur mycket tid planeraren har att arbeta med. Åren går även att redigera bakåt till barnets födelseår — för ett tidigare år anger du hur många dagar du faktiskt hade kvar, inte hela tilldelningen.', 'settings.youBadge': '(Du)', 'vacationDaysTable.assumedBadge': 'Antaget', 'vacationDaysTable.assumedPrompt': 'Inget värde är registrerat för detta år — 25 dagar (lagstadgat) har antagits. Bekräfta eller ändra nedan.', + 'vacationDaysTable.assumedPromptPast': 'Inget värde är registrerat för detta år — vi antar att du tog ut din semester och inte sparade något. Ändra nedan om du sparade dagar till året efter.', 'vacationDaysTable.atRiskAgeDetail': '{days} dagar har passerat 5-årsgränsen för att spara semester', 'vacationDaysTable.atRiskHeader': 'Riskerar', 'vacationDaysTable.atRiskOverCapDetail': '{days} dagar överstiger hur mycket som får sparas till nästa år', diff --git a/frontend/src/pages/Settings.test.tsx b/frontend/src/pages/Settings.test.tsx index ce6b670..a5923d2 100644 --- a/frontend/src/pages/Settings.test.tsx +++ b/frontend/src/pages/Settings.test.tsx @@ -271,14 +271,44 @@ describe('Settings', () => { }) describe('vacation days table', () => { - it('renders one row per year from the current year through the year the child turns 4', () => { + it('renders one row per year from the child\'s birth year through the year the child turns 4', () => { renderSettings() - for (let year = CURRENT_YEAR; year <= CURRENT_YEAR + 3; year++) { + for (let year = CURRENT_YEAR - 1; year <= CURRENT_YEAR + 3; year++) { expect(screen.getByTestId(`vacation-year-row-${year}`)).toBeInTheDocument() } + expect(screen.queryByTestId(`vacation-year-row-${CURRENT_YEAR - 2}`)).not.toBeInTheDocument() expect(screen.queryByTestId(`vacation-year-row-${CURRENT_YEAR + 4}`)).not.toBeInTheDocument() }) + it('marks a year before the current year as past and shows the past-year assumed prompt', () => { + renderSettings() + const pastYear = CURRENT_YEAR - 1 + const badge = screen.getByTestId(`vacation-assumed-badge-${pastYear}`) + expect(badge.title).toBe( + 'Inget värde är registrerat för detta år — vi antar att du tog ut din semester och inte sparade något. Ändra nedan om du sparade dagar till året efter.', + ) + // The current year (also assumed, no row in mockVacationDays for it + // beyond CURRENT_YEAR itself which IS seeded) still gets the regular + // prompt, not the past-year one. + const futureBadge = screen.getByTestId(`vacation-assumed-badge-${CURRENT_YEAR + 1}`) + expect(futureBadge.title).toBe( + 'Inget värde är registrerat för detta år — 25 dagar (lagstadgat) har antagits. Bekräfta eller ändra nedan.', + ) + }) + + it('autosaves an edit to a past year under that past year, not the current one', async () => { + renderSettings() + const pastYear = CURRENT_YEAR - 1 + const totalInput = screen.getByTestId(`vacation-total-days-${pastYear}`) + fireEvent.change(totalInput, { target: { value: '15' } }) + + await waitFor(() => + expect(mockUpsertVacationDays).toHaveBeenCalledWith( + expect.objectContaining({ personalDataUuid: 'pd-1', year: pastYear, totalDays: 15 }), + ), + ) + }) + it('prefills a year that already has saved data', () => { renderSettings() const totalInput = screen.getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement @@ -462,11 +492,12 @@ describe('Settings', () => { }) describe('preschool planning days — per-year editing', () => { - it('renders one row per vacation year, wired to usePreschoolPlanningDays', () => { + it('renders one row per year from the current year (not the vacation table\'s widened birth-year start) through the year the child turns 4, wired to usePreschoolPlanningDays', () => { renderSettings() for (let year = CURRENT_YEAR; year <= CURRENT_YEAR + 3; year++) { expect(screen.getByTestId(`preschool-year-row-${year}`)).toBeInTheDocument() } + expect(screen.queryByTestId(`preschool-year-row-${CURRENT_YEAR - 1}`)).not.toBeInTheDocument() expect(hooks.usePreschoolPlanningDays).toHaveBeenCalledWith('c-1', CURRENT_YEAR) }) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 5c2067e..7a5a884 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -90,18 +90,30 @@ function describePartnerError(err: unknown, t: (key: TranslationKey, params?: Re return err instanceof Error ? err.message : fallback } -/** Current year through (at minimum) the year the child turns 4. */ +/** + * The child's birth year (or the current year, if earlier/no birth date) + * through — at minimum — the year the child turns 4. + * + * Starting at the birth year, not `currentYear`, lets Settings offer an + * editable vacation-days row for every past year too — see + * `AnalyticsService._vacation_year_series`'s `current_year` rule, which + * stops assuming the full statutory default for a past year with no row + * precisely so a real figure can be entered here instead. + */ function computeVacationYearRange(birthDate: string | undefined, currentYear: number): number[] { + let startYear = currentYear let endYear = currentYear if (birthDate) { - const fourthBirthday = new Date(birthDate) - if (!isNaN(fourthBirthday.getTime())) { + const birth = new Date(birthDate) + if (!isNaN(birth.getTime())) { + startYear = Math.min(startYear, birth.getFullYear()) + const fourthBirthday = new Date(birthDate) fourthBirthday.setFullYear(fourthBirthday.getFullYear() + 4) endYear = Math.max(endYear, fourthBirthday.getFullYear()) } } const years: number[] = [] - for (let year = currentYear; year <= endYear; year++) { + for (let year = startYear; year <= endYear; year++) { years.push(year) } return years @@ -352,6 +364,15 @@ export function Settings(): ReactNode { () => computeVacationYearRange(selectedChild?.birth_date, currentYear), [selectedChild?.birth_date, currentYear], ) + // Preschool planning-day rows only ever make sense current-year forward + // (§Task 1 preschool reserve is a forecast, not a historical record) — + // `vacationYears` above was widened back to the child's birth year for + // the vacation-days table only, so this block gets its own slice rather + // than sharing it. + const preschoolYears = useMemo( + () => vacationYears.filter((year) => year >= currentYear), + [vacationYears, currentYear], + ) // Keyed on (personalDataUuid, year), NOT year alone — with more than one // workspace member, matching on year alone would silently pick up // whichever member's row for that year happened to come first in @@ -366,21 +387,29 @@ export function Settings(): ReactNode { // — the at-risk figures then default to 0 rather than block the // rest of this editable row on a supplementary fetch. const vacationBalance = projection?.yearBalances.find((b) => b.year === year)?.vacation + // No row entered: fall back to the SAME figure analytics assumed + // for this year (`AnalyticsService._vacation_year_series`'s + // `earned` — statutory default for a current/future year, the + // floor for a past one) rather than re-deriving that rule here. + // Only falls back further to the statutory default when analytics + // hasn't loaded yet, matching the pre-existing no-data behaviour. + const fallbackTotalDays = vacationBalance?.earned ?? DEFAULT_VACATION_DAYS return { year, - totalDays: existing?.totalDays ?? DEFAULT_VACATION_DAYS, + totalDays: existing?.totalDays ?? fallbackTotalDays, savedDaysIn: existing?.savedDaysIn ?? 0, - // §3.2 — no vacation_days row for this year yet: the statutory + // §3.2 — no vacation_days row for this year yet: an assumed // default is shown, not an entered value. Mirrors the backend's // `VacationBalanceResponse.is_assumed` for the propose-time table. isAssumed: existing === undefined, + isPast: year < currentYear, expiring: vacationBalance?.expiring ?? 0, expiringAge: vacationBalance?.expiringAge ?? 0, expiringOverCap: vacationBalance?.expiringOverCap ?? 0, } }) }), - [orderedMembers, vacationYears, vacDays, analytics], + [orderedMembers, vacationYears, vacDays, analytics, currentYear], ) if (isLoading) { @@ -655,7 +684,7 @@ export function Settings(): ReactNode {

{t('settings.preschoolPerYearTitle')}

{t('settings.preschoolPerYearHelp')}

- {vacationYears.map((year) => ( + {preschoolYears.map((year) => (