76b1e712b9
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
346 lines
12 KiB
Python
346 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Demo: run the pure planning-engine Allocator on a realistic family.
|
|
|
|
Builds an `AllocationInput` entirely in memory (no database) using Swedish
|
|
defaults — two parents on a joint 240/240 split, 25 semesterdagar/year each,
|
|
a child born 2026-03-01 — plus two recurring wishes ("6 veckor sommar" and
|
|
"4 veckor jul") running until the child turns 4. Runs `Allocator().propose()`
|
|
(the pure half of the planning engine — see app.services.allocation_service)
|
|
and prints a human-readable Swedish summary per window.
|
|
|
|
Usage (from backend/):
|
|
uv run python scripts/demo_plan.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
from uuid import uuid4
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from app.entities.leave_wish import LeaveWish
|
|
from app.services.allocation_service import (
|
|
AllocationInput,
|
|
Allocator,
|
|
ChildFacts,
|
|
DayPool,
|
|
ParentFacts,
|
|
PlanProposal,
|
|
VacationBudget,
|
|
WindowOutcome,
|
|
)
|
|
from app.utils.holidays import get_swedish_holidays
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Build a realistic AllocationInput, in memory, Swedish defaults.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CHILD_BIRTH_DATE = date(2026, 3, 1)
|
|
TODAY = date.today()
|
|
|
|
|
|
def _build_input() -> tuple[AllocationInput, dict]:
|
|
child = ChildFacts(
|
|
id=uuid4(),
|
|
birth_date=CHILD_BIRTH_DATE,
|
|
fourth_birthday=CHILD_BIRTH_DATE + relativedelta(years=4),
|
|
preschool_start_date=CHILD_BIRTH_DATE + relativedelta(years=1, months=4),
|
|
preschool_planning_days_per_year=6,
|
|
)
|
|
|
|
parent_1 = ParentFacts(
|
|
uuid=uuid4(), name="Alex", salary_type="monthly", employment_status="employed"
|
|
)
|
|
parent_2 = ParentFacts(
|
|
uuid=uuid4(), name="Sam", salary_type="monthly", employment_status="employed"
|
|
)
|
|
|
|
# 390 sjukpenning-days + 90 lägstanivå-days = 480 total, split 240/240
|
|
# between the two parents (the default joint-custody split, see
|
|
# app.config.LeaveRules).
|
|
pools = {
|
|
(parent_1.uuid, child.id): DayPool(
|
|
sjukpenning_left=195, lagsta_left=45, dubbeldagar_left=60
|
|
),
|
|
(parent_2.uuid, child.id): DayPool(
|
|
sjukpenning_left=195, lagsta_left=45, dubbeldagar_left=60
|
|
),
|
|
}
|
|
|
|
horizon_end = child.fourth_birthday + relativedelta(years=1)
|
|
vacation = {
|
|
(parent_1.uuid, year): VacationBudget(total_days=25, saved_days_in=0, already_used=0)
|
|
for year in range(TODAY.year, horizon_end.year + 1)
|
|
}
|
|
vacation.update(
|
|
{
|
|
(parent_2.uuid, year): VacationBudget(total_days=25, saved_days_in=0, already_used=0)
|
|
for year in range(TODAY.year, horizon_end.year + 1)
|
|
}
|
|
)
|
|
|
|
summer_wish = LeaveWish(
|
|
id=uuid4(),
|
|
workspace_id=uuid4(),
|
|
child_id=child.id,
|
|
name="Sommar",
|
|
window_start_month=6,
|
|
window_start_day=15,
|
|
window_end_month=7,
|
|
window_end_day=26, # 6 weeks
|
|
target_weeks=6,
|
|
recurrence="yearly",
|
|
first_year=CHILD_BIRTH_DATE.year,
|
|
last_year=None, # until the child's 4th birthday
|
|
priority=1,
|
|
parent_mode="either", # lets the allocator swap between parents
|
|
parent_personal_data_uuid=None,
|
|
)
|
|
christmas_wish = LeaveWish(
|
|
id=uuid4(),
|
|
workspace_id=summer_wish.workspace_id,
|
|
child_id=child.id,
|
|
name="Jul",
|
|
window_start_month=12,
|
|
window_start_day=20,
|
|
window_end_month=1,
|
|
window_end_day=16, # wraps into January; 28 days = 4 weeks
|
|
target_weeks=4,
|
|
recurrence="yearly",
|
|
first_year=CHILD_BIRTH_DATE.year,
|
|
last_year=None,
|
|
priority=1,
|
|
parent_mode="either",
|
|
parent_personal_data_uuid=None,
|
|
)
|
|
|
|
holidays_map: dict[date, str] = {}
|
|
for year in range(TODAY.year, horizon_end.year + 2):
|
|
holidays_map.update(get_swedish_holidays(year))
|
|
|
|
alloc_input = AllocationInput(
|
|
today=TODAY,
|
|
horizon_end=horizon_end,
|
|
planning_goal="balanced",
|
|
planning_mode="couple",
|
|
children=[child],
|
|
parents=[parent_1, parent_2],
|
|
pools=pools,
|
|
vacation=vacation,
|
|
wishes=[summer_wish, christmas_wish],
|
|
existing_planned=[],
|
|
holidays=holidays_map,
|
|
)
|
|
|
|
names = {parent_1.uuid: parent_1.name, parent_2.uuid: parent_2.name}
|
|
return alloc_input, names
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Pretty-print helpers (Swedish)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DAY_TYPE_LABELS = {
|
|
"vacation": "semesterdagar",
|
|
"sjukpenning": "FP-dagar (sjukpenningnivå)",
|
|
"lagsta": "FP-dagar (lägstanivå)",
|
|
"free": "fria dagar (helg/röd dag)",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class _RenderedWindow:
|
|
heading: str
|
|
body_lines: list[str]
|
|
|
|
|
|
def _render_window(outcome: WindowOutcome) -> _RenderedWindow:
|
|
heading = (
|
|
f"{outcome.wish_name} {outcome.year} "
|
|
f"({outcome.start_date.isoformat()} → {outcome.end_date.isoformat()})"
|
|
)
|
|
lines: list[str] = []
|
|
lines.append(
|
|
f" {outcome.weeks_wished} veckor önskade -> {outcome.weeks_funded:.1f} veckor finansierade"
|
|
)
|
|
|
|
breakdown_bits = []
|
|
for key in ("free", "vacation", "sjukpenning", "lagsta"):
|
|
count = outcome.cost_breakdown.get(key, 0)
|
|
if count:
|
|
breakdown_bits.append(f"{count} {DAY_TYPE_LABELS[key]}")
|
|
if breakdown_bits:
|
|
lines.append(" Dagar: " + ", ".join(breakdown_bits))
|
|
|
|
lines.append(f" Strategi: {outcome.strategy}")
|
|
lines.append(f" Förklaring: {_render_window_explanation(outcome)}")
|
|
|
|
if outcome.shortfall_days > 0:
|
|
lines.append(f" ⚠ Underskott: {outcome.shortfall_days} dagar kunde inte finansieras")
|
|
for remedy in outcome.remedies:
|
|
lines.append(f" → {_render_message(remedy.message_key, remedy.data)}")
|
|
else:
|
|
lines.append(" ✓ Fullt finansierad")
|
|
|
|
return _RenderedWindow(heading=heading, body_lines=lines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# §3.5 — the engine now returns `message_key` + `data` (not pre-rendered
|
|
# Swedish, see `PlanResults.tsx` / `frontend/src/i18n/sv.ts` for the real
|
|
# UI-facing catalogue) so it can be shown in either locale. This script is a
|
|
# developer CLI, not the UI — it only needs a Swedish rendering, so it keeps
|
|
# a tiny local copy of the same templates rather than reaching into the
|
|
# frontend's TypeScript i18n files.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_SV_TEMPLATES: dict[str, str] = {
|
|
"planRemedies.reduce_preschool_reserve": (
|
|
"Minska förskolans planeringsdagar ({reserved} reserverade) frigör {reserved} dagar"
|
|
),
|
|
"planRemedies.carry_vacation_forward": (
|
|
"{spare_total} semesterdagar från tidigare år kan sparas framåt"
|
|
),
|
|
"planRemedies.lower_priority_wish": (
|
|
"Sänk {wish_name} från {from_weeks} till {to_weeks} veckor frigör {freed_days} dagar"
|
|
),
|
|
"planRemedies.shift_window_into_free_days": "Flytta {wish_name} närmare en helg frigör {shift_gain} dagar",
|
|
"planWarnings.days_lost_at_age_4": (
|
|
"riskerar att förlora {expiring_days} dagar vid barnets 4-årsdag "
|
|
"({fourth_birthday}) om inte fler dagar tas ut innan dess"
|
|
),
|
|
"planWarnings.sgi_risk": (
|
|
"riskerar SGI-skyddet veckan som börjar {week_start} "
|
|
"({days_planned} dagar planerade, minst {min_days_per_week} krävs)"
|
|
),
|
|
"planWarnings.vacation_expiring": (
|
|
"har {saved_days_in} sparade semesterdagar från {year} som riskerar att gå förlorade"
|
|
),
|
|
"planSurplusAdvice.wish_more_leave": (
|
|
"har råd med ytterligare {weeks} veckor ledigt — lägg till en vecka på "
|
|
"sommaren eller ett höstlov"
|
|
),
|
|
"planSurplusAdvice.shift_mix_to_fp": (
|
|
"kan byta semesterdagar mot föräldrapenningdagar under planen"
|
|
),
|
|
"planSurplusAdvice.front_load_early_years": (
|
|
"har ett stort överskott — ta ut fler dagar nu medan barnet är litet"
|
|
),
|
|
"planSurplusAdvice.use_less_vacation": (
|
|
"kan ta ut färre semesterdagar ({years_text}) utan att gå under lägsta "
|
|
"uttaget ({min_vacation_days_per_year} dagar/år)"
|
|
),
|
|
}
|
|
|
|
|
|
def _render_message(message_key: str, data: dict) -> str:
|
|
template = _SV_TEMPLATES.get(message_key)
|
|
if template is None:
|
|
return f"[{message_key}] {data}"
|
|
try:
|
|
return template.format(**data)
|
|
except KeyError:
|
|
return f"[{message_key}] {data}"
|
|
|
|
|
|
def _render_window_explanation(outcome: WindowOutcome) -> str:
|
|
data = outcome.data
|
|
text = (
|
|
f"{data['wish_name']} {data['year']} — {data['weeks_wished']} veckor önskade, "
|
|
f"{data['weeks_funded']} veckor finansierade"
|
|
)
|
|
if data.get("shortfall_days", 0):
|
|
text += f" ({data['shortfall_days']} dagar saknas)"
|
|
|
|
bits: list[str] = []
|
|
cost = outcome.cost_breakdown
|
|
fp_days = cost.get("sjukpenning", 0) + cost.get("lagsta", 0)
|
|
if cost.get("vacation", 0) > 0 and fp_days > 0:
|
|
bits.append(f"du behåller lönen {cost['vacation']} dagar och sparar {fp_days} FP-dagar")
|
|
elif cost.get("vacation", 0) > 0:
|
|
bits.append(f"{cost['vacation']} semesterdagar")
|
|
if cost.get("sjukpenning", 0) > 0:
|
|
bits.append(f"{cost['sjukpenning']} föräldrapenningdagar (sjukpenningnivå)")
|
|
if cost.get("lagsta", 0) > 0:
|
|
bits.append(f"{cost['lagsta']} föräldrapenningdagar (lägstanivå)")
|
|
if cost.get("free", 0) > 0:
|
|
bits.append(f"{cost['free']} helt fria dagar (helg/röd dag)")
|
|
if bits:
|
|
text += ": " + ", ".join(bits)
|
|
return text
|
|
|
|
|
|
def _print_report(proposal: PlanProposal, parent_names: dict) -> None:
|
|
print("=" * 78)
|
|
print("FÖRÄLDRALEDIGHETSPLAN — förslag (ej sparat)")
|
|
print(f"Barn fött: {CHILD_BIRTH_DATE.isoformat()} | Dagens datum: {TODAY.isoformat()}")
|
|
print("=" * 78)
|
|
print()
|
|
|
|
outcomes = sorted(proposal.outcomes, key=lambda o: (o.year, o.start_date))
|
|
if not outcomes:
|
|
print("Inga fönster inom planeringshorisonten (allt ligger i det förflutna).")
|
|
for outcome in outcomes:
|
|
rendered = _render_window(outcome)
|
|
print(rendered.heading)
|
|
for line in rendered.body_lines:
|
|
print(line)
|
|
print()
|
|
|
|
total_wished_weeks = sum(o.weeks_wished for o in outcomes)
|
|
total_funded_weeks = sum(o.weeks_funded for o in outcomes)
|
|
total_shortfall = sum(o.shortfall_days for o in outcomes)
|
|
print("-" * 78)
|
|
print(
|
|
f"Totalt: {total_wished_weeks} veckor önskade, {total_funded_weeks:.1f} veckor "
|
|
f"finansierade över {len(outcomes)} fönster ({total_shortfall} dagar i underskott totalt)."
|
|
)
|
|
print()
|
|
|
|
if proposal.warnings:
|
|
print("Varningar:")
|
|
for warning in proposal.warnings:
|
|
name = parent_names.get(warning.data.get("parent_personal_data_uuid"), "")
|
|
body = _render_message(warning.message_key, warning.data)
|
|
print(f" [{warning.kind}] {name} {body}".strip())
|
|
print()
|
|
|
|
if proposal.surplus_advice:
|
|
print("Förslag (överskott vid 4-årsdagen, §9.2):")
|
|
for advice in proposal.surplus_advice:
|
|
name = parent_names.get(advice.data.get("parent_personal_data_uuid"), "")
|
|
body = _render_message(advice.message_key, advice.data)
|
|
print(f" → [{advice.kind}] {name} {body}".strip())
|
|
print()
|
|
|
|
print("Kvarvarande saldon efter planen:")
|
|
for (parent_uuid, child_id), pool in proposal.balances_after.pools_after.items():
|
|
name = parent_names.get(parent_uuid, str(parent_uuid))
|
|
print(
|
|
f" {name}: {pool.sjukpenning_left} sjukpenningdagar, "
|
|
f"{pool.lagsta_left} lägstanivådagar, {pool.dubbeldagar_left} dubbeldagar kvar"
|
|
)
|
|
print()
|
|
for (parent_uuid, year), budget in sorted(
|
|
proposal.balances_after.vacation_after.items(), key=lambda kv: (str(kv[0][0]), kv[0][1])
|
|
):
|
|
name = parent_names.get(parent_uuid, str(parent_uuid))
|
|
used = budget.already_used
|
|
if used == 0:
|
|
continue
|
|
print(
|
|
f" {name} {year}: {used}/{budget.total_days + budget.saved_days_in} semesterdagar använda"
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
alloc_input, parent_names = _build_input()
|
|
proposal = Allocator().propose(alloc_input)
|
|
_print_report(proposal, parent_names)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|