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
@@ -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')
})
})
@@ -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 {
<span
className="badge badge--warning vacation-rows__assumed-badge"
data-testid={`vacation-assumed-badge-${row.year}`}
title={t('vacationDaysTable.assumedPrompt')}
title={t(row.isPast ? 'vacationDaysTable.assumedPromptPast' : 'vacationDaysTable.assumedPrompt')}
>
{t('vacationDaysTable.assumedBadge')}
</span>
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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',
+34 -3
View File
@@ -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)
})
+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}
+5 -2
View File
@@ -260,9 +260,12 @@ export interface CalendarWindow {
export interface VacationYearBalance {
year: number
/** Entitlement for this year: the entered total, or the statutory default (25). */
/** Entitlement for this year: the entered total, or an assumed default when
* no row exists — the statutory default (25) for the current year or
* later, the per-parent floor for a past year (nothing phantom banked
* forward from a year the user never confirmed). */
earned: number
/** True when no `vacation_days` row exists for this (parent, year) — `earned` is the statutory default. */
/** True when no `vacation_days` row exists for this (parent, year) — `earned` is an assumed default (see above). */
isAssumed: boolean
/** Days carried in from prior years, still valid. */
carriedIn: number