2d40e4caa6
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
159 lines
8.5 KiB
Markdown
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.
|