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
+28 -3
View File
@@ -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: