Files
F-r-ldraledigplannerare/dubbeldagar.md
T
jacobh2 2d40e4caa6 Explain what the profile fields do, and correct the dubbeldagar premise
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 13:51:37 +02:00

159 lines
8.5 KiB
Markdown

# 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.