Give tips a date, a scope per child, and a dismissal that sticks

Three faults, all invisible while the tests passed. Advice was built for the
first child only, so a second child got none. The planned days feeding every
count were fetched once per parent and appended to one list, so a couple's
days were counted twice and every intensity and projection derived from them
was wrong. And dismissing a tip forgot the moment the page reloaded.

Tips now carry the date they concern, lifted from figures the rules already
computed, so a view of a month can show only what applies to it. They are
built per child and identified per child, so the same rule firing for two
children is two separate pieces of advice.

A dismissal is stored against a fingerprint of the facts that were dismissed.
When those facts change the advice returns with its new numbers, rather than
one click silencing a warning about a forfeiture that keeps growing.
This commit is contained in:
2026-09-17 22:32:03 +02:00
parent c15cd44cb4
commit fdbfb7d580
20 changed files with 1396 additions and 57 deletions
+286 -26
View File
@@ -22,7 +22,7 @@ from collections import defaultdict
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Callable, Coroutine, TypeAlias
from uuid import UUID
from uuid import UUID, uuid4
from dateutil.relativedelta import relativedelta as _relativedelta
@@ -32,17 +32,22 @@ from app.entities.leave_wish import LeaveWish
from app.entities.parental_leave import ParentalLeaveFetch
from app.entities.personal_data import PersonalData
from app.entities.planned_leave import PlannedLeaveDay
from app.entities.tip_dismissal import TipDismissal
from app.entities.vacation_days import VacationDays
from app.repositories.child_repository import ChildRepository
from app.repositories.leave_wish_repository import LeaveWishRepository
from app.repositories.parental_leave_repository import ParentalLeaveRepository
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.repositories.workspace_repository import WorkspaceRepository
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,
hash_tip_data,
is_child_over_age,
preschool_buffer_needed,
red_day_clusters_in_range,
@@ -58,15 +63,31 @@ class Tip:
severity: str # 'info' | 'warning' | 'action'
message_key: str # i18n key for frontend
data: dict # context for frontend rendering (e.g. {days: 5, period: "Jul–Aug"})
# Where this tip is anchored on a calendar: a single day (e.g. "use this
# vacation day"), a span (e.g. a break period), or neither. Lifted out of
# `data` — where the value already lived — rather than invented; see the
# individual rules for which `data` key each maps to. Rules with no
# meaningful date (balance_between_parents, lagsta_niva_coverage,
# stretch_time_at_home, sgi_minimum_after_age_1,
# reserve_for_preschool_planning_days) leave both null.
anchor_date: date | None = None
anchor_range: tuple[date, date] | None = None
@dataclass
class TipsContext:
workspace_id: UUID
# Either empty (the workspace-level context — see `_WORKSPACE_SCOPED_RULES`
# in `TipsService`) or a single child (a per-child context, built once per
# child in `_build_context`). Never more than one: entitlement and
# planned days are tracked per child, so a rule scoped to "the" child
# would silently pick an arbitrary one in a multi-child workspace.
children: list[Child]
parents: list[PersonalData]
leave_by_parent: dict[UUID, ParentalLeaveFetch | None] # personal_data_uuid → latest
planned_days: list[PlannedLeaveDay] # all future planned days for workspace
# Future planned days for `children[0]` only (empty for the workspace
# context) — see `_build_context`.
planned_days: list[PlannedLeaveDay]
vacation_by_parent: dict[UUID, list[VacationDays]]
leave_wishes: list[LeaveWish]
today: date # injected for deterministic testing
@@ -89,6 +110,11 @@ async def _rule_use_vacation_before_expiry(ctx: TipsContext) -> Tip | None:
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",
@@ -98,6 +124,7 @@ async def _rule_use_vacation_before_expiry(ctx: TipsContext) -> Tip | None:
"saved_days": vd.saved_days_in,
"year": vd.year,
},
anchor_date=year_end,
)
return None
@@ -159,10 +186,120 @@ async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
"expiring_days": expiring_at_b4,
"fourth_birthday": fourth_bday.isoformat(),
},
anchor_date=fourth_bday,
)
return None
async def _rule_age4_pooling_loss(ctx: TipsContext) -> Tip | None:
"""Warn a couple when one parent is over the per-parent age-4 cap while
the other has unused headroom — days that could have been pooled but
won't be, because `resolve_age4_savable_cap` splits the ceiling per
parent once a workspace is in `couple` mode (see `age4_pooling_loss`).
Advice order is the substance of this tip: taking more leave before the
birthday converts entitlement into actual time at home (strictly
better), so it is offered first. Transferring days at Försäkringskassan
only preserves the entitlement — it is offered second, and the transfer
figure is bounded by what `ParentalLeaveFetch.transferable_days_left`
(not the raw days-left total, which includes non-transferable reserved
days) says is actually movable. When the partner has no FK snapshot the
exact transfer figure can't be known, so the tip degrades to a hedged
"if you both have days left" phrasing instead of guessing a number.
"""
if ctx.workspace_planning_mode != "couple" or not ctx.children:
return None
child = ctx.children[0]
if not is_child_over_age(child.birth_date, settings.rules.age4_pooling_tip_min_child_age_years):
return None # first couple of years: too early to usefully act on this
fourth_bday = child.birth_date + _relativedelta(years=4)
per_parent_cap = resolve_age4_savable_cap(ctx.workspace_planning_mode, len(ctx.parents))
per_child_cap = settings.rules.max_savable_days_at_age_4
projected_left_by_parent: dict[UUID, int] = {}
excess_by_parent: dict[UUID, int] = {}
for parent in ctx.parents:
leave = ctx.leave_by_parent.get(parent.uuid)
if leave is None:
continue
planned_fp_before_b4 = sum(
1
for d in ctx.planned_days
if d.parent_personal_data_uuid == parent.uuid
and d.day_type in ("sjukpenning", "lagsta")
and d.date < fourth_bday
)
projected_left, expiring = days_left_at_age_4_projection(
leave.days_used, planned_fp_before_b4, leave.total_days, per_parent_cap
)
projected_left_by_parent[parent.uuid] = projected_left
excess_by_parent[parent.uuid] = expiring
if not projected_left_by_parent:
return None
family_loss = age4_pooling_loss(
list(projected_left_by_parent.values()), per_parent_cap, per_child_cap
)
if family_loss <= 0:
return None
surplus_uuid = max(excess_by_parent, key=lambda u: excess_by_parent[u])
surplus_parent = next(p for p in ctx.parents if p.uuid == surplus_uuid)
surplus_leave = ctx.leave_by_parent[surplus_uuid]
excess_days = excess_by_parent[surplus_uuid]
other_parents = [p for p in ctx.parents if p.uuid != surplus_uuid]
partner_has_snapshot = False
lagging_parent: PersonalData | None = None
lagging_headroom = 0
for other in other_parents:
other_leave = ctx.leave_by_parent.get(other.uuid)
if other_leave is None:
continue
partner_has_snapshot = True
headroom = max(per_parent_cap - projected_left_by_parent[other.uuid], 0)
if headroom > lagging_headroom:
lagging_headroom = headroom
lagging_parent = other
if lagging_parent is not None and lagging_headroom > 0:
transfer_days = min(excess_days, lagging_headroom, surplus_leave.transferable_days_left)
transfer_days = max(transfer_days, 0)
return Tip(
id="age4_pooling_loss",
severity="warning",
message_key="tips.age4_pooling_loss_exact",
data={
"surplus_parent": surplus_parent.name,
"lagging_parent": lagging_parent.name,
"excess_days": excess_days,
"transfer_days": transfer_days,
"family_loss_days": family_loss,
"parent_cap": per_parent_cap,
"fourth_birthday": fourth_bday.isoformat(),
},
)
if not partner_has_snapshot and other_parents:
return Tip(
id="age4_pooling_loss",
severity="warning",
message_key="tips.age4_pooling_loss_hedged",
data={
"surplus_parent": surplus_parent.name,
"excess_days": excess_days,
"family_loss_days": family_loss,
"parent_cap": per_parent_cap,
"fourth_birthday": fourth_bday.isoformat(),
},
)
return None
async def _rule_use_dubbeldagar_strategically(ctx: TipsContext) -> Tip | None:
if len(ctx.parents) < 2 or not ctx.children:
return None
@@ -189,6 +326,7 @@ async def _rule_use_dubbeldagar_strategically(ctx: TipsContext) -> Tip | None:
severity="action",
message_key="tips.use_dubbeldagar_strategically",
data={"date": day_date.isoformat()},
anchor_date=day_date,
)
return None
@@ -304,6 +442,7 @@ async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None:
"friday": fp_date.isoformat(),
"monday": monday.isoformat(),
},
anchor_range=(fp_date, monday),
)
return None
@@ -337,6 +476,7 @@ async def _rule_wrap_red_days_with_vacation(ctx: TipsContext) -> Tip | None:
"cluster_start": cluster_start.isoformat(),
"cluster_end": cluster_end.isoformat(),
},
anchor_range=(cluster_start, cluster_end),
)
return None
@@ -393,6 +533,7 @@ async def _rule_long_break_parent_swap(ctx: TipsContext) -> Tip | None:
"parent_name": parent.name,
"other_parent_name": other.name,
},
anchor_range=(start_date, end_date),
)
return None
@@ -430,6 +571,7 @@ RULE_REGISTRY: list[RuleFn] = [
_rule_use_vacation_before_expiry,
_rule_balance_between_parents,
_rule_preserve_for_age_4,
_rule_age4_pooling_loss,
_rule_use_dubbeldagar_strategically,
_rule_lagsta_niva_coverage,
_rule_stretch_time_at_home,
@@ -440,6 +582,18 @@ RULE_REGISTRY: list[RuleFn] = [
_rule_reserve_for_preschool_planning_days,
]
# Parental-leave entitlement (`parental_leave_fetch`), and every planned day
# that draws on it, is tracked per CHILD (see `ParentalLeaveRepository.
# get_latest_by_parent` / `PlannedLeaveRepository.list_by_workspace_child`).
# `TipsService.get_tips` therefore runs most rules once per child, against a
# context scoped to that child's own leave/planned-day data (see
# `_build_context`). `use_vacation_before_expiry` is the one rule that reads
# neither — vacation days belong to the parent, not any child — so running it
# per child would just print the same warning N times for an N-child
# workspace; it runs once instead, against a childless workspace-level
# context.
_WORKSPACE_SCOPED_RULES: frozenset[RuleFn] = frozenset({_rule_use_vacation_before_expiry})
class TipsService:
def __init__(
@@ -451,6 +605,7 @@ class TipsService:
vacation_repo: VacationDaysRepository,
leave_wish_repo: LeaveWishRepository,
workspace_repo: WorkspaceRepository,
dismissal_repo: TipDismissalRepository,
) -> None:
self._leave = leave_repo
self._planned = planned_repo
@@ -459,47 +614,152 @@ class TipsService:
self._vacation = vacation_repo
self._leave_wishes = leave_wish_repo
self._workspace_repo = workspace_repo
self._dismissals = dismissal_repo
async def get_tips(self, workspace_id: UUID) -> list[Tip]:
"""Load workspace context then run all rules, returning non-None tips."""
ctx = await self._build_context(workspace_id)
results = await asyncio.gather(*(rule(ctx) for rule in RULE_REGISTRY))
return [tip for tip in results if tip is not None]
"""Load workspace context(s), run all rules, drop stale-dismissed tips.
async def _build_context(self, workspace_id: UUID) -> TipsContext:
One workspace-level context feeds `_WORKSPACE_SCOPED_RULES`; one
per-child context feeds every other rule (see `_build_context` and
`_WORKSPACE_SCOPED_RULES` for why). A child-scoped tip's `id` is
suffixed with its child's id so that e.g. `preserve_for_age_4` firing
for two different children never collides — neither in the returned
list nor in `TipDismissal` (dismissing child A's warning must not
dismiss child B's).
"""
workspace_ctx, child_contexts = await self._build_context(workspace_id)
tips: list[Tip] = []
workspace_rules = [rule for rule in RULE_REGISTRY if rule in _WORKSPACE_SCOPED_RULES]
child_rules = [rule for rule in RULE_REGISTRY if rule not in _WORKSPACE_SCOPED_RULES]
workspace_results = await asyncio.gather(*(rule(workspace_ctx) for rule in workspace_rules))
tips.extend(tip for tip in workspace_results if tip is not None)
for ctx in child_contexts:
child = ctx.children[0]
results = await asyncio.gather(*(rule(ctx) for rule in child_rules))
for tip in results:
if tip is None:
continue
tip.data = {**tip.data, "child_id": str(child.id), "child_name": child.name}
tip.id = f"{tip.id}:{child.id}"
tips.append(tip)
return await self._drop_stale_dismissed(workspace_id, tips)
async def dismiss(self, workspace_id: UUID, tip_id: str) -> None:
"""Record that `tip_id` is dismissed, fingerprinted to its current `data`.
Recomputes the live tip list (rather than trusting a client-supplied
`data` payload) so the stored fingerprint is always the engine's own
truth. Raises NotFoundError if `tip_id` doesn't match any
currently-firing tip — there is nothing meaningful to dismiss.
"""
tips = await self.get_tips(workspace_id)
tip = next((t for t in tips if t.id == tip_id), None)
if tip is None:
raise NotFoundError(f"Tip {tip_id!r} is not currently active for this workspace")
data_hash = hash_tip_data(tip.data)
existing = await self._dismissals.get_by_workspace_tip(workspace_id, tip_id)
if existing is not None:
existing.data_hash = data_hash
await self._dismissals.update(existing.id, existing)
return
await self._dismissals.create(
TipDismissal(
id=uuid4(),
workspace_id=workspace_id,
tip_id=tip_id,
data_hash=data_hash,
)
)
async def _drop_stale_dismissed(self, workspace_id: UUID, tips: list[Tip]) -> list[Tip]:
"""Filter out tips whose dismissal is still fresh.
A dismissal only silences the *exact* situation the user acknowledged
(same tip id, same `data`). If the rule now fires with different data
— a bigger `expiring_days` count, a moved `fourth_birthday`, ... — the
old dismissal describes a fact that's no longer true, so the tip
resurfaces instead of staying silenced forever. See module docstring
on `app.entities.tip_dismissal.TipDismissal` for why an unconditional
(non-expiring) dismissal would be dangerous for warnings like
`preserve_for_age_4`.
"""
dismissals = await self._dismissals.list_by_workspace(workspace_id)
if not dismissals:
return tips
dismissed_hash_by_id = {d.tip_id: d.data_hash for d in dismissals}
return [tip for tip in tips if dismissed_hash_by_id.get(tip.id) != hash_tip_data(tip.data)]
async def _build_context(self, workspace_id: UUID) -> tuple[TipsContext, list[TipsContext]]:
"""Build the workspace-level context plus one context per child.
Two bugs used to live here:
1. **Single-child only.** Only `children[0]` was ever fetched, so a
workspace with two children got advice about one and silence about
the other — despite `list_by_workspace` already returning every
child. Now every child gets its own context (see the loop below).
2. **Doubled planned days.** `list_by_workspace_child` already
returns every parent's days for a given child; the old code called
it again *inside* the per-parent loop and kept appending to the
same list, so an N-parent workspace saw each planned day N times.
Every count-sensitive rule (intensity, SGI, age-4 projections) was
silently working from doubled input. Fetched once per child now,
outside any per-parent loop.
"""
parents = await self._parents.list_by_workspace(workspace_id)
children = await self._children.list_by_workspace(workspace_id)
leave_wishes = await self._leave_wishes.get_by_workspace(workspace_id)
today = date.today()
all_planned: list[PlannedLeaveDay] = []
leave_by_parent: dict[UUID, ParentalLeaveFetch | None] = {}
vacation_by_parent: dict[UUID, list[VacationDays]] = {}
if children:
child = children[0] # MVP: single child
for pd in parents:
leave_by_parent[pd.uuid] = await self._leave.get_latest_by_parent(pd.uuid, child.id)
future = await self._planned.list_by_workspace_child(
workspace_id, child.id, from_date=today
)
all_planned.extend(future)
vacation_by_parent[pd.uuid] = await self._vacation.list(
personal_data_uuid=pd.uuid, workspace_id=workspace_id
)
# Vacation days belong to the parent, not any child — fetched once,
# shared by the workspace context and every per-child context.
vacation_by_parent: dict[UUID, list[VacationDays]] = {}
for pd in parents:
vacation_by_parent[pd.uuid] = await self._vacation.list(
personal_data_uuid=pd.uuid, workspace_id=workspace_id
)
ws = await self._workspace_repo.get_by_id(workspace_id)
planning_mode = ws.planning_mode if ws else "solo"
return TipsContext(
workspace_ctx = TipsContext(
workspace_id=workspace_id,
children=children,
children=[],
parents=parents,
leave_by_parent=leave_by_parent,
planned_days=all_planned,
leave_by_parent={},
planned_days=[],
vacation_by_parent=vacation_by_parent,
leave_wishes=leave_wishes,
today=today,
workspace_planning_mode=planning_mode,
)
child_contexts: list[TipsContext] = []
for child in children:
leave_by_parent: dict[UUID, ParentalLeaveFetch | None] = {}
for pd in parents:
leave_by_parent[pd.uuid] = await self._leave.get_latest_by_parent(pd.uuid, child.id)
planned_days = await self._planned.list_by_workspace_child(
workspace_id, child.id, from_date=today
)
child_contexts.append(
TipsContext(
workspace_id=workspace_id,
children=[child],
parents=parents,
leave_by_parent=leave_by_parent,
planned_days=planned_days,
vacation_by_parent=vacation_by_parent,
leave_wishes=leave_wishes,
today=today,
workspace_planning_mode=planning_mode,
)
)
return workspace_ctx, child_contexts