Let vacation years go back to the child's birth year

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 20:49:56 +02:00
parent 06592df87c
commit 1a02bdcdbf
12 changed files with 248 additions and 33 deletions
+37 -8
View File
@@ -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 {
<h4>{t('settings.preschoolPerYearTitle')}</h4>
<p className="settings-panel__help">{t('settings.preschoolPerYearHelp')}</p>
<div className="preschool-year-rows" data-testid="preschool-year-rows">
{vacationYears.map((year) => (
{preschoolYears.map((year) => (
<PreschoolPlanningYearRow
key={year}
childId={selectedChildId}