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
+65 -20
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Callable, Coroutine, TypeAlias
from uuid import UUID, uuid4
@@ -41,11 +41,12 @@ from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.repositories.tip_dismissal_repository import TipDismissalRepository
from app.repositories.vacation_days_repository import VacationDaysRepository
from app.services.analytics_service import AnalyticsService, VacationYearBalance
from app.utils.errors import NotFoundError
from app.utils.tip_predicates import (
age4_pooling_loss,
days_left_at_age_4_projection,
has_vacation_near_expiry,
has_genuine_vacation_forfeiture,
hash_tip_data,
is_child_over_age,
preschool_buffer_needed,
@@ -90,6 +91,13 @@ class TipsContext:
vacation_by_parent: dict[UUID, list[VacationDays]]
leave_wishes: list[LeaveWish]
today: date # injected for deterministic testing
# This (calendar) year's vacation balance per parent, via the same FIFO
# bank simulation AnalyticsService uses (`_vacation_year_series`) — so
# `_rule_use_vacation_before_expiry` can never disagree with the
# dashboard's own `expiring`/`expiring_age`/`expiring_over_cap` figures.
# Defaulted so existing direct-construction call sites/tests don't need
# to supply it.
vacation_year_balance_by_parent: dict[UUID, VacationYearBalance] = field(default_factory=dict)
# Type alias for a rule function.
@@ -102,25 +110,39 @@ RuleFn: TypeAlias = Callable[[TipsContext], Coroutine[None, None, Tip | None]]
async def _rule_use_vacation_before_expiry(ctx: TipsContext) -> Tip | None:
"""Warn when a parent's vacation balance genuinely forfeits days this year.
Fires on the real per-year breakdown (`VacationYearBalance.expiring_age`
+ `.expiring_over_cap`, see `AnalyticsService._vacation_year_series`) —
not the old `saved_in > 0 and total > 5` heuristic, which could both miss
a genuine over-cap loss and flag parents who were never at risk.
Never fires for a year already in the past (`balance.year < ctx.today.
year`): those days are already gone, so a warning about them is pure
noise, not something actionable. The carry-forward simulation itself
still computes every year correctly, past ones included — this guard is
display-only, applied after the maths, using the injected `ctx.today`
rather than `date.today()` so it stays testable.
"""
for parent in ctx.parents:
for vd in ctx.vacation_by_parent.get(parent.uuid, []):
if has_vacation_near_expiry(vd.total_days, vd.saved_days_in):
# `vd.year` is the only date-shaped fact this rule has; anchor
# to that vacation year's last day — the natural forcing point
# for "use it before it's gone" — rather than leaving the rail
# with nothing to place this tip on.
year_end = date(vd.year, 12, 31)
return Tip(
id="use_vacation_before_expiry",
severity="warning",
message_key="tips.use_vacation_before_expiry",
data={
"parent_name": parent.name,
"saved_days": vd.saved_days_in,
"year": vd.year,
},
anchor_date=year_end,
)
balance = ctx.vacation_year_balance_by_parent.get(parent.uuid)
if balance is None or balance.year < ctx.today.year:
continue
if has_genuine_vacation_forfeiture(balance.expiring_age, balance.expiring_over_cap):
# The vacation year's last day is the natural forcing point for
# "use it before it's gone" — the only date-shaped fact here.
year_end = date(balance.year, 12, 31)
return Tip(
id="use_vacation_before_expiry",
severity="warning",
message_key="tips.use_vacation_before_expiry",
data={
"parent_name": parent.name,
"lost_days": balance.expiring,
"year": balance.year,
},
anchor_date=year_end,
)
return None
@@ -746,6 +768,28 @@ class TipsService:
personal_data_uuid=pd.uuid, workspace_id=workspace_id
)
# This year's real vacation forfeiture per parent — same FIFO bank
# simulation the dashboard uses (`AnalyticsService._vacation_year_series`),
# so `_rule_use_vacation_before_expiry` never derives a different
# answer than the numbers the user is already looking at.
carry_years = settings.rules.vacation_carry_forward_years
vacation_year_balance_by_parent: dict[UUID, VacationYearBalance] = {}
for pd in parents:
rows = vacation_by_parent[pd.uuid]
vacation_by_year = {v.year: v for v in rows}
parent_days = await self._planned.list(parent_personal_data_uuid=pd.uuid)
use_by_year: dict[int, int] = {}
for d in parent_days:
if d.day_type == "vacation":
use_by_year[d.date.year] = use_by_year.get(d.date.year, 0) + 1
known_years = {v.year for v in rows} | set(use_by_year.keys()) | {today.year}
start_year = min(min(known_years), today.year - (carry_years - 1))
years = list(range(start_year, today.year + 1))
series = AnalyticsService._vacation_year_series(
years, vacation_by_year, use_by_year, pd.min_vacation_days_per_year
)
vacation_year_balance_by_parent[pd.uuid] = series[-1]
workspace_ctx = TipsContext(
workspace_id=workspace_id,
children=[],
@@ -755,6 +799,7 @@ class TipsService:
vacation_by_parent=vacation_by_parent,
leave_wishes=leave_wishes,
today=today,
vacation_year_balance_by_parent=vacation_year_balance_by_parent,
)
child_contexts: list[TipsContext] = []