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
@@ -732,7 +732,14 @@ class TestVacationYearBalanceSeries:
assert first.carried_in == 0
assert first.left == 25
assert first.carried_out == 5 # max(25 - floor(20), 0)
assert first.expiring == 0
# `expiring` now reports EVERY lost day, not just age-based losses
# (the bug this predicate/service pair fixes): of the 25 unused
# days, only 5 (earned - floor) can be banked — the other 20 (the
# unused floor) are forfeited outright, with no age-expiry involved
# yet in year one.
assert first.expiring == 20
assert first.expiring_age == 0
assert first.expiring_over_cap == 20
def test_carry_chain_accumulates_across_years(self) -> None:
years = [2025, 2026, 2027]
@@ -756,10 +763,14 @@ class TestVacationYearBalanceSeries:
# 2020's 5 saved days must not still be sitting in `carried_in`
# `carry_years` years later, and must have been reported as
# `expiring` exactly once, in the year they lapsed.
expiring_years = [y for y, r in by_year.items() if r.expiring > 0]
assert expiring_years == [2020 + carry_years]
assert by_year[2020 + carry_years].expiring == 5
# `expiring_age` exactly once, in the year they lapsed. (`expiring`
# itself is nonzero every year here too, since the unused floor is
# an over-cap loss every year — isolate the age-based portion.)
age_expiring_years = [y for y, r in by_year.items() if r.expiring_age > 0]
assert age_expiring_years == [2020 + carry_years]
assert by_year[2020 + carry_years].expiring_age == 5
assert by_year[2020 + carry_years].expiring_over_cap == 20
assert by_year[2020 + carry_years].expiring == 25
def test_continuous_saving_expires_the_oldest_batch_every_year_thereafter(self) -> None:
"""Once the bank has been running >= carry_years, an unbroken
@@ -775,7 +786,9 @@ class TestVacationYearBalanceSeries:
by_year = {r.year: r for r in result}
for year in years[carry_years:]:
assert by_year[year].expiring == 5
assert by_year[year].expiring_age == 5
assert by_year[year].expiring_over_cap == 20 # unused floor, every year
assert by_year[year].expiring == 25
# Carried-in plateaus at exactly carry_years batches of 5.
assert by_year[years[-1]].carried_in == 5 * carry_years
@@ -825,6 +838,53 @@ class TestVacationYearBalanceSeries:
# 2026's row must not wipe that computed carry-in out.
assert by_year[2026].carried_in == 5
def test_expiring_breakdown_matches_dashboard_screenshot_case(self) -> None:
"""Real dashboard bug report: earned 30, floor 20, planned_use 15 →
left 15, carried_out 10 — 5 days genuinely lost, but the OLD
age-only `expiring` showed 0. `expiring` must report every lost day,
with `expiring_over_cap` isolating the over-cap portion."""
from app.entities.vacation_days import VacationDays
years = [2025]
rows = {
2025: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2025,
total_days=30,
saved_days_in=0,
),
}
use_by_year = {2025: 15}
result = AnalyticsService._vacation_year_series(years, rows, use_by_year, floor=20)
balance = result[0]
assert balance.left == 15
assert balance.carried_out == 10
assert balance.expiring == 5
assert balance.expiring_age == 0
assert balance.expiring_over_cap == 5
def test_age_expiry_and_over_cap_loss_coincide_without_double_counting(self) -> None:
"""A bank entry aging out at the 5-year cap and a fresh over-cap
loss can both happen in the same year (earned 25, floor 20, never
used: every year loses the unused floor AND, from year
`carry_years` on, also ages out that year's oldest 5-day batch).
`expiring` must equal their sum exactly once, never double-counted.
"""
from app.config import settings
carry_years = settings.rules.vacation_carry_forward_years
years = list(range(2020, 2020 + carry_years + 1))
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
last = result[-1]
assert last.expiring_age == 5
assert last.expiring_over_cap == 20
assert last.expiring == 25
assert last.expiring == last.expiring_age + last.expiring_over_cap
class TestParentalLeaveYearBalanceSeries:
"""Pure unit tests against `AnalyticsService._pl_year_series`."""