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