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:
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user