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:
@@ -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.
|
||||
Reference in New Issue
Block a user