Track preschool planning days, partner invites and real vacation forfeiture

Three strands of work that grew together across the same settings screen and
API client, so they land as one change.

Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.

Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.

Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 11:22:07 +02:00
parent 6904961bb4
commit 0e9b20ec37
88 changed files with 5301 additions and 348 deletions
+37 -4
View File
@@ -58,7 +58,16 @@ class VacationYearBalance:
planned_use: int # committed 'vacation' planned_leave_day rows in this year (see PlanningService._vacation_days_left)
left: int # max(earned + carried_in - planned_use, 0)
carried_out: int # days saved forward into next year's carried_in
expiring: int # days that hit the 5-year (rules.vacation_carry_forward_years) cap this year — LOST if not used
# EVERY day lost at this year's end, from both causes combined — the sum
# of `expiring_age` and `expiring_over_cap` below. Never a subset: a UI
# showing only `expiring` must not silently under-report a forfeiture.
expiring: int
# subset of `expiring`: bank entries that hit the 5-year
# (rules.vacation_carry_forward_years) age cap this year.
expiring_age: int
# subset of `expiring`: days above (earned - floor) that couldn't be
# banked — includes an unused floor itself when usage is low.
expiring_over_cap: int
@dataclass
@@ -94,7 +103,17 @@ class ParentYearBalance:
- A bank entry in its final valid year (earned exactly
`vacation_carry_forward_years` years ago) that still has an unconsumed
balance drops out instead of carrying forward, and is reported as
`expiring` for that year.
`expiring_age` for that year.
- Separately, whatever of `left` cannot be absorbed by `carried_out`
(days above `earned - floor` with nowhere to bank — including an
unused floor itself, when usage is low) is `expiring_over_cap` for
that year.
- `expiring` is `expiring_age + expiring_over_cap`: EVERY vacation day
lost at this year's end, however it was lost. Earlier versions of this
field counted only the age-cap portion, so a family could lose days
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.
`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
@@ -416,7 +435,7 @@ class AnalyticsService:
# Entries still valid to carry into next year vs. ones in their
# final valid year (expire now if any balance survived).
old_carry_forward = sum(c for ye, c in bank if ye >= year - (carry_years - 1))
expiring = sum(c for ye, c in bank if ye == year - carry_years)
expiring_age_raw = sum(c for ye, c in bank if ye == year - carry_years)
bank = [e for e in bank if e[0] >= year - (carry_years - 1)]
current_year_saveable = max(earned - floor, 0)
@@ -425,6 +444,18 @@ class AnalyticsService:
if new_entry_amount > 0:
bank.append([year, new_entry_amount])
# `left` minus what actually got banked is EVERY day lost this
# year — from both causes. `expiring_age_raw` (bank entries that
# just hit the age cap) is a subset of that total by construction
# (those days are inside `left` and excluded from `carried_out`
# via `old_carry_forward`), but clamp it defensively so the two
# portions can never sum to more than `total_lost` in an edge
# case (e.g. `left` itself clamped to 0 by the `max(..., 0)`
# above, when planned_use outstrips earned + carried_in).
total_lost = max(left - carried_out, 0)
expiring_age = min(expiring_age_raw, total_lost)
expiring_over_cap = total_lost - expiring_age
result.append(
VacationYearBalance(
year=year,
@@ -434,7 +465,9 @@ class AnalyticsService:
planned_use=planned_use,
left=left,
carried_out=carried_out,
expiring=expiring,
expiring=total_lost,
expiring_age=expiring_age,
expiring_over_cap=expiring_over_cap,
)
)