From 2d40e4caa6cde92685de6d931edf04c980e6b964 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 19 Sep 2026 13:51:37 +0200 Subject: [PATCH] Explain what the profile fields do, and correct the dubbeldagar premise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit how-it-works.md answers what "Your profile" in the settings is for: salary type is the real lever (monthly spends vacation first, hourly spends parental-benefit days first), employment status only ever warns about SGI, and the vacation floor bounds carry-forward rather than capping spending. dubbeldagar.md answers the question that was asked. The 60 are combined per child, and the code already treats them that way. They are not a separate pot, though: on a doubled date each parent spends one of their own days, so leaving them out of the total would overstate what the family has. The per-parent dubbeldagar_left the scraper stores is never read by the allocator — flagged, not changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep --- dubbeldagar.md | 158 ++++++++++++++++++++++++++++++++++++++++++++++++ how-it-works.md | 136 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 dubbeldagar.md create mode 100644 how-it-works.md diff --git a/dubbeldagar.md b/dubbeldagar.md new file mode 100644 index 0000000..40eb218 --- /dev/null +++ b/dubbeldagar.md @@ -0,0 +1,158 @@ +# Dubbeldagar: combined pot, and a corrected premise + +> **Verdict** +> 1. **Combined, yes.** The 60-day dubbeldagar cap is one pot per child, shared by both +> parents — the code gets this right. +> 2. **Not a separate pot — this is the premise to correct.** A dubbeldag is not a bonus day on +> top of the 480. On a doubled date, **each parent spends one of their own parental-benefit +> (FP) days**. The 60 caps how many *dates* may be doubled, not an extra allowance. Excluding +> dubbeldagar from "the total pot of days" would overstate how many days the family actually +> has — it would double-count nothing, because there's nothing extra to exclude. +> 3. A per-parent `dubbeldagar_left` value is scraped from Försäkringskassan and stored, but the +> allocator never reads it — see §3 below. Flagged, not fixed. + +This answers the question in `todo.md`: *"Is it clear that dubbeldagar is something that two +parents have combined ... not that parent A has e.g. 60 days and parent B has 60 additional days? +... And these days shouldn't be included in the total pot of days to use for parental leave."* + +--- + +## 1. Combined: one pot per child + +The cap is tracked per **child**, not per parent. In `backend/app/services/allocation_service.py:739`: + +```python +self._dubbeldagar_used: dict[UUID, int] = {} +``` + +keyed by `child.id`. It's checked against `_MAX_DUBBELDAGAR = settings.rules.max_dubbeldagar` +(default 60, `backend/app/config.py:34`), and only within +`settings.rules.dubbeldagar_months_limit` = 15 months from the child's birth date +(`config.py:35`). + +Both checks happen together in `_fund_both` (`allocation_service.py:1225-1234`): + +```python +eligible_end = child.birth_date + relativedelta(months=_DUBBELDAGAR_MONTHS_LIMIT) +used = self._dubbeldagar_used.get(child.id, 0) +dubbeldagar_legally_available = ( + pool_a is not None + and pool_b is not None + and any(d <= eligible_end for d in dates) + and used < _MAX_DUBBELDAGAR + ... +) +``` + +and the running count is written back once per window at `allocation_service.py:1334`: +`self._dubbeldagar_used[child.id] = used`. There is exactly one counter per child; neither parent +has their own 60. + +This is independently re-checked at *apply* time (when a plan is actually saved, not just +proposed) by `AnalyticsService.validate_dubbeldagar` (`backend/app/services/analytics_service.py:221-254`), +via `PlannedLeaveRepository.count_dubbeldagar(child_id)` — again scoped to the child, not a parent, +and again compared against `settings.rules.max_dubbeldagar`. + +So: yes, it's clear in the code, and correct. One combined 60-day pot per child. + +--- + +## 2. Not a separate pot — correcting the premise + +The `todo.md` framing — "these days shouldn't be included in the total pot of days to use for +parental leave" — treats a dubbeldag as if it were a bonus day, separate from the 480 +(390 sjukpenning + 90 lägstanivå) each parent has. **That isn't how the engine models it, and it +isn't how the Swedish rule works:** a dubbeldag is a *convenience*, not an *allowance*. + +Look at the actual funding branch, `_fund_both` (`allocation_service.py:1263-1289`). On a date +that's funded as a dubbeldag, **both** parents get an ordinary `ProposedDay` charged against their +**own** FP pool: + +```python +self._commit_fp_tier(pool_a, tier_a) +self._commit_fp_tier(pool_b, tier_b) +used += 1 +cost[tier_a] += 1 +cost[tier_b] += 1 +days.append(ProposedDay(d, parent_a.uuid, child.id, tier_a, True, wish.id, REASON_DUBBELDAG)) +days.append(ProposedDay(d, parent_b.uuid, child.id, tier_b, True, wish.id, REASON_DUBBELDAG)) +``` + +`self._commit_fp_tier(pool_a, tier_a)` decrements parent A's own `sjukpenning`/`lagsta` pool by +one; the identical call for `pool_b` decrements parent B's own pool by one, for the *same calendar +date*. Two FP days are spent — one from each parent's existing 480 — not one shared day pulled from +somewhere else. `PlannedLeaveDay.is_dubbeldag` (`backend/app/entities/planned_leave.py:16`) just +tags that row as counting toward the 60-date quota; it doesn't change which pool it draws from. + +So the 60 is **a cap on how many dates may be doubled**, not extra days on top of the 480+480 the +two parents already have. Both parents being off on the same date normally costs the family one +FP day and one vacation day (co-leave, see §4). A dubbeldag instead costs two FP days — it lets you +trade a vacation day for an extra FP day, for up to 60 dates, early in the child's life. It is not +free time; it's a different way to spend time you already had a claim to. + +**Practical consequence:** excluding dubbeldagar from "the total pot of days" would be wrong, +because there is no separate pot to exclude — every dubbeldagar-tagged day is already counted +inside the ordinary sjukpenning/lägstanivå totals it was drawn from. Subtracting it again would +understate the family's real entitlement, not correct for a double-count that doesn't exist. + +--- + +## 3. A loose end: a per-parent `dubbeldagar_left` that nothing consults + +There's a field that *looks* like it might contradict §1 — `ParentalLeaveFetch.dubbeldagar_left` +(`backend/app/entities/parental_leave.py:41`, column at +`backend/migrations/versions/001_initial_schema.py:132`). It's scraped from Försäkringskassan +per (parent, child) (`backend/app/scrapers/fk_scraper.py:640-663`) and validated on ingest by +`backend/app/services/leave_snapshot.py:146-158` (non-negative, ≤ `rules.max_dubbeldagar`). + +It's carried into the allocator's input as `DayPool.dubbeldagar_left` +(`allocation_service.py:2457-2460`, sourced from `latest.dubbeldagar_left` at line 2430, or +defaulted to `rules.max_dubbeldagar` when there's no FK snapshot yet, line 2438), and again into +the mutable `_MutablePool.dubbeldagar_left` (`allocation_service.py:649, 732`). + +Grepping every place that field is actually *read* (`\.dubbeldagar_left` as an attribute access, +not a constructor argument) turns up exactly four sites, and all four are construction/copy +sites — `_MutablePool` build-out (line 732), the candidate-probe scratch copy (line 1688), the +`DayPool` build-out (line 2209), and the initial assignment from the FK snapshot (line 2430). +**None of the decision logic in `_fund_both` — the eligibility check at lines 1225-1234, or the +spend at lines 1263-1289 — ever reads `pool.dubbeldagar_left`.** It relies exclusively on +`self._dubbeldagar_used[child.id]` and `_MAX_DUBBELDAGAR`, described in §1. The scraped per-parent +number is loaded, validated, and passed all the way through the pipeline, and then never +consulted. + +`ARCHITECTURE.md:491` still shows illustrative pseudocode from an earlier design that *does* +consult it (`if latest_fetch.dubbeldagar_left - await self._planned_dubbeldagar(child_id) <= 0`) — +that shape was superseded by the combined per-child counter this doc describes, and the +architecture doc wasn't updated to match. + +This also makes the field conceptually wrong given §1: FK reports it per parent, but the real +constraint is per child, shared. Two parents' FK snapshots could show two different +`dubbeldagar_left` numbers for the same child, and neither would be authoritative — the allocator's +own `_dubbeldagar_used` counter is. Flagging this; not changing it (no code changes in this pass). + +TODO: decide whether to (a) stop scraping/storing per-parent `dubbeldagar_left` and derive +`max_dubbeldagar - count_dubbeldagar(child_id)` everywhere instead, or (b) keep it only as a +display/cross-check value and make that explicit in the UI. Not decided here — a wrong number is +worse than no number, per `CLAUDE.md`. + +--- + +## 4. Dubbeldagar vs. the default co-leave behaviour + +Since the 2026-09-18 correction (`PLANNING_ENGINE_SPEC.md` §3.5, "co-leave"), a +`parent_mode == 'both'` window does **not** default to dubbeldagar. It defaults to **co-leave**: +one parent funded on FP, the other on vacation, same date (`strategy = 'co_leave'`, +`allocation_service.py:1291-1329`). Dubbeldagar (`strategy = 'dubbeldag'`) only fire when the +wish's `wants_dubbeldag` flag is explicitly set (`allocation_service.py:1235, 1263`) **and** the +date is legally eligible (§1). + +The practical difference, for a single date both parents are home: + +| Strategy | Parent A spends | Parent B spends | Net cost | +|---|---|---|---| +| Co-leave (default) | 1 FP day | 1 vacation day | 1 FP + 1 vacation | +| Dubbeldagar (opt-in) | 1 FP day | 1 FP day | 2 FP, 0 vacation | + +Dubbeldagar trade a vacation day for an extra FP day — worth doing only while the 15-month/60-date +window is open and only if FP days are more plentiful than vacation for that family. Co-leave is +the safe default that works for every date, indefinitely, with no quota to track. diff --git a/how-it-works.md b/how-it-works.md new file mode 100644 index 0000000..49232ed --- /dev/null +++ b/how-it-works.md @@ -0,0 +1,136 @@ +# How it works: "Your profile" in Settings + +"Your profile" (per parent, `ParentProfileInput` in `frontend/src/components/inputs/ParentProfileInput.tsx`) +holds the facts the planner needs about *you* to decide how to spend your days — as opposed to +facts about the child (birth date) or the household (workspace). Two of its fields actively change +the plan the engine produces. One is a warning-only signal. One field exists but nothing reads it. + +This file explains each field: what it means, what the planner does with it, and what happens if +you leave it wrong. + +--- + +## Salary type — `monthly` or `hourly` + +**What it means.** Whether your employer pays you a fixed monthly salary regardless of which days +you work, or pays you by the hour for hours actually worked. + +**What the planner does with it.** This is the one field that changes *which resource gets spent +first* on an ordinary working day. `backend/app/entities/personal_data.py:21` stores it; +`backend/app/models/personal_data.py:18` validates it on the wire as `monthly` or `hourly`. + +The decision lives in `_assign_tier3` in `backend/app/services/allocation_service.py:988-1024` +(the "tier-3 interior" resolution — see `PLANNING_ENGINE_SPEC.md` §3.3). For an ordinary spendable +day that isn't free (weekend/holiday) and isn't next to a red-day cluster: + +- **`monthly`** → spend a **vacation** day first; a parental-benefit (FP) day only fills what + vacation can't cover (`allocation_service.py:1011-1017`). +- **`hourly`** → spend an **FP** day first; vacation is only a backstop when FP is exhausted + (`allocation_service.py:1019-1024`). + +The reasoning (`PLANNING_ENGINE_SPEC.md` §3.3): a monthly-salaried parent's pay doesn't change +whether they take a vacation day or not — they lose nothing by spending vacation, so spending it +first *preserves* FP days for later, when they're worth more (more weeks of leave, or protection +against the age-4 cutoff). An hourly-paid parent has no salary to protect on a vacation day — no +work, no pay — so there's nothing to gain by spending vacation first; FP goes first because it's the +only resource that pays anything on that date, and vacation is kept in reserve as a fallback. + +The same field also gates two tips in `backend/app/services/tips_service.py`: "wrap the weekend +with vacation" (`tips_service.py:418-446`, gated at line 422) and "wrap the red days with vacation" +(`tips_service.py:449-...`, gated at line 456) both `continue` past any parent whose `salary_type` +isn't `monthly` (lines 422, 456). Both tips only make sense for a monthly salary — they suggest +spending vacation to protect pay that only a monthly salary actually pays out on those days. + +**What happens if it's wrong.** The plan itself doesn't become invalid — it just optimises for the +wrong thing. Set as `hourly` when you're actually salaried, the planner will burn FP days on +ordinary weeks that you could have covered with vacation for free, running out of FP sooner than +necessary. Set as `monthly` when you're actually hourly, it will spend your vacation on days you'd +have been paid nothing for anyway, needlessly depleting a budget you could have kept. + +--- + +## Employment status — `employed`, `jobseeking`, or `none` + +**What it means.** Whether you currently have a job, are registered as a jobseeker (with +Arbetsförmedlingen), or neither. + +**What the planner does with it.** This field is **advisory only — it never blocks or reshapes a +plan.** It feeds a single SGI (sjukpenninggrundande inkomst) protection check. + +SGI is protected automatically for the child's first 12 months (`sgi_protected_first_months` in +`backend/app/config.py:46`, default 12). After that, a parent who is neither employed nor a +registered jobseeker needs to take parental-benefit days on at least +`sgi_min_days_per_week_after_age_1` days per week (`config.py:47`, default 5) to keep their SGI +from being reassessed downward. `sgi_at_risk` in `backend/app/utils/tip_predicates.py:125-153` +implements this: if `employment_status` is `employed` or `jobseeking`, it returns `False` +immediately (line 141) — protected regardless of how few FP days are planned. Only `none` triggers +the intensity check. The allocator's own warning path, +`_sgi_warnings` in `allocation_service.py:2139-...`, applies the identical +`employment_status in ("employed", "jobseeking")` skip at line 2154, and the resulting tip +(`tips_service.py:398-415`, `_rule_sgi_minimum_after_age_1`) is `severity="warning"`, not an error. + +**Why only a warning:** the app has no way to verify someone's actual employment or jobseeker +registration — that lives with the employer and Arbetsförmedlingen, not in this planner. So it +warns "you may be at risk of losing SGI protection" and lets you plan around it; it never refuses +to save a plan or force more FP days into a window because of this field. + +**What happens if it's wrong.** Set to `employed`/`jobseeking` when you're actually not working or +registered: you lose the SGI warning, and the app gives no signal that your parental-benefit rate +could be reassessed downward after age 1. Set to `none` when you actually are employed or +registered: you'll see a spurious warning telling you to plan more FP days than you actually need +to protect SGI. Either way, the plan's day allocation itself is untouched. + +--- + +## Minimum vacation days per year — `min_vacation_days_per_year` + +**What it means.** The semesterlagen floor: the number of vacation days you must *take* each +calendar year. Sweden's statutory minimum is 20 (`min_vacation_days_per_year: int = 20` in +`backend/app/entities/personal_data.py:28` and `backend/app/config.py:63`); some employers set a +higher floor, so it's editable per parent rather than hardcoded. + +**What the planner does with it.** It bounds two things, both about *not saving too much*, never +about *not spending enough*: + +- **Vacation carry-forward.** `_carry_vacation_forward_remedy` in `allocation_service.py:1467-1492` + only offers to bank vacation days above this floor for a future year — the floor itself must stay + taken, not banked (`allocation_service.py:1480-1482`). +- **"Use less vacation" surplus advice.** When FP days are expiring and vacation could be swapped + in to save them, `allocation_service.py:2100-2137` only proposes reducing vacation usage down to + this floor, never below it (line 2115). + +Deliberately **not** used as a cap on how much vacation you may spend. `PlanningService. +_vacation_days_left`'s docstring is explicit about this +(`backend/app/services/planning_service.py:1219-1224`): + +> `PersonalData.min_vacation_days_per_year` is deliberately NOT used here: ... it is a semesterlagen +> floor on days that must be *taken* each year (bounding what may be saved forward), not a cap on +> how much may be spent — turning it into a spending cap here would reject perfectly legal plans. + +**What happens if it's wrong.** Set too low, the carry-forward and "use less vacation" advice may +suggest banking or swapping away days you're actually required to take, risking a semesterlagen +violation the app itself won't catch (it only enforces the number you gave it). Set too high, the +planner will keep more vacation "reserved" than necessary and under-suggest carrying days forward +or swapping vacation in for expiring FP — conservative, not wrong, but leaves value on the table. + +--- + +## What's stored but not used: `personnummer_enc` + +The profile can optionally hold an encrypted personal-identity-number field +(`personnummer_enc` in `backend/app/entities/personal_data.py:29`). Storing it is **discouraged** — +the entity's own docstring says so (`personal_data.py:7-8`) and recommends `None` if you can avoid +it. It's persisted and loaded like any other field +(`backend/app/repositories_impl/sqlite/personal_data_repository.py`), but **no service reads it** — +it plays no role in planning, tips, or SGI logic. There's no benefit to filling it in. + +--- + +## Summary + +| Field | Changes the plan? | How | +|---|---|---| +| `salary_type` | Yes | Decides whether vacation or FP is spent first on an ordinary day | +| `employment_status` | No — warning only | Gates one SGI warning after the child turns 1 | +| `min_vacation_days_per_year` | Yes, at the margins | Bounds vacation carry-forward and surplus advice; never a spending cap | +| `personnummer_enc` | No | Stored, never read |