Sort the loose planning notes into folders

Reference notes move to information/, unstarted feature write-ups to
new-features/, so the repo root stops being a pile of markdown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 11:21:21 +02:00
parent d54d944cbe
commit 6904961bb4
7 changed files with 9 additions and 45 deletions
+158
View File
@@ -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.
+136
View File
@@ -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 |