Warn when saving unevenly costs the family days

Only ninety-six days survive a child's fourth birthday, and with two parents
that is forty-eight each rather than a shared pool. A parent sitting on sixty
days while the other has ten therefore forfeits twelve that the family could
have kept, and nothing said so.

The advice leads with taking the days as leave before the birthday, which
turns them into actual time at home, and offers transferring only as the
fallback that merely preserves them. The transferable figure is bounded by
what Försäkringskassan actually reports as transferable, since the reserved
days cannot move, and the copy says plainly that a transfer happens there and
not in this app. Without the partner's figures the tip hedges rather than
inventing a number.
This commit is contained in:
2026-09-17 22:31:37 +02:00
parent a4fce16dd1
commit c15cd44cb4
7 changed files with 221 additions and 1 deletions
+6
View File
@@ -41,6 +41,12 @@ class LeaveRules(BaseModel):
max_savable_days_per_parent_at_age_4: int = 48
hard_expiry_age_years: int = 12
# A couple's per-parent age-4 pooling-loss tip (see
# `app.utils.tip_predicates.age4_pooling_loss`) only starts warning once
# the child has turned this many years old — the first couple of years
# are too early to usefully act on, so don't nag before then.
age4_pooling_tip_min_child_age_years: int = 2
sgi_protected_first_months: int = 12
sgi_min_days_per_week_after_age_1: int = 5
+49
View File
@@ -7,6 +7,8 @@ supporting value. Keeping them pure makes them trivial to unit-test.
from __future__ import annotations
import hashlib
import json
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
@@ -69,6 +71,35 @@ def days_left_at_age_4_projection(
return projected_left, expiring
def age4_pooling_loss(
parent_projected_lefts: list[int],
per_parent_cap: int,
per_child_cap: int,
) -> int:
"""Days a couple loses because the age-4 cap applies per parent, not pooled.
Each parent's own projected balance at the child's 4th birthday is capped
individually at `per_parent_cap` (see `resolve_age4_savable_cap`) before
summing, even though the family's shared ceiling is `per_child_cap`. When
one parent is over their own cap while another still has headroom under
it, the family ends up keeping less than `per_child_cap` would otherwise
allow — e.g. 60 and 10 days left (cap 48, ceiling 96): the family could
keep 70 pooled but only keeps 48 + 10 = 58, losing 12.
A parent with no known projection should simply be left out of
`parent_projected_lefts` — omitting them is the conservative treatment on
both sides of the comparison (as if their balance were 0), so a caller
missing one parent's data still gets a safe (never-overstated) answer.
Returns the shortfall, 0 when nothing is lost.
"""
if not parent_projected_lefts:
return 0
reachable_pooled = min(sum(parent_projected_lefts), per_child_cap)
reachable_per_parent = sum(min(left, per_parent_cap) for left in parent_projected_lefts)
return max(reachable_pooled - reachable_per_parent, 0)
def is_child_under_age(birth_date: date, months: int) -> bool:
"""True if the child's age in months is less than `months`."""
return date.today() < birth_date + relativedelta(months=months)
@@ -170,3 +201,21 @@ def has_vacation_near_expiry(
expiry logic when it has the full vacation history per year.
"""
return vacation_saved_in > 0 and vacation_total > threshold_remaining_days
def hash_tip_data(data: dict[str, object]) -> str:
"""Stable fingerprint of a tip's `data` bag.
Used to decide whether a previously-dismissed tip's underlying situation
has changed materially: if a rule fires again under the same `Tip.id` but
with different `data` (a bigger `expiring_days` count, a moved
`fourth_birthday`, a new `gap_days`, ...), the old dismissal describes a
fact that's no longer true and must not silence the new one. See
`app.entities.tip_dismissal.TipDismissal` and
`app.services.tips_service.TipsService._drop_stale_dismissed`.
`data` only ever holds JSON scalars (see `app.models.tips.TipDataValue`),
so `sort_keys=True` alone gives a stable, order-independent digest.
"""
canonical = json.dumps(data, sort_keys=True, default=str)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()