Let a household choose to be paid for the red days

Asking for a full week off never spent anything on the Saturday, the
Sunday or a holiday, and a test now says so outright rather than leaving
it to be rediscovered. Those days are free because vacation cannot be
taken on a day that is not a working day.

Parental benefit is a payment rather than a schedule, though, so it can
be drawn on a red day to turn an unpaid day off into a paid one — at the
cost of a day the family would otherwise keep. That is now a workspace
setting, off by default, and it never reaches for a vacation day. With it
off the planner behaves exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
2026-09-19 14:28:38 +02:00
parent 1837154225
commit b87ad0e7ad
18 changed files with 511 additions and 10 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ _FULL_SCHEMA = """
CREATE TABLE IF NOT EXISTS workspace (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL
created_at TIMESTAMP NOT NULL,
take_pl_on_red_days BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS personal_data (
+6
View File
@@ -10,3 +10,9 @@ class Workspace:
id: UUID
name: str
created_at: datetime | None = None
# §todo item 2 — per-workspace opt-in, default False: allow the planning
# engine to fund a free day (Saturday, Sunday, public holiday) with a
# parental-benefit day instead of leaving it unpaid. Never vacation (see
# `app.services.allocation_service.REASON_FP_RED_DAY`). Threaded into the
# allocator by `AllocationInputLoader.load` as `AllocationInput.take_pl_on_red_days`.
take_pl_on_red_days: bool = False
+6
View File
@@ -15,12 +15,17 @@ class WorkspaceCreate(BaseModel):
class WorkspaceUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
# §todo item 2 — `None` means "leave unchanged"; only an explicit
# true/false updates the stored flag (mirrors `name`'s omit-to-keep
# convention above).
take_pl_on_red_days: bool | None = None
class WorkspaceResponse(BaseModel):
id: UUID
name: str
created_at: datetime | None
take_pl_on_red_days: bool
@classmethod
def from_entity(cls, ws: Workspace) -> "WorkspaceResponse":
@@ -28,6 +33,7 @@ class WorkspaceResponse(BaseModel):
id=ws.id,
name=ws.name,
created_at=ws.created_at,
take_pl_on_red_days=ws.take_pl_on_red_days,
)
@@ -26,19 +26,21 @@ class SQLiteWorkspaceRepository(WorkspaceRepository):
id=UUID(row["id"]),
name=row["name"],
created_at=datetime.fromisoformat(row["created_at"]),
take_pl_on_red_days=bool(row["take_pl_on_red_days"]),
)
async def create(self, item: Workspace) -> Workspace:
item.created_at = item.created_at or datetime.now()
await self._conn.execute(
"""
INSERT INTO workspace (id, name, created_at)
VALUES (?, ?, ?)
INSERT INTO workspace (id, name, created_at, take_pl_on_red_days)
VALUES (?, ?, ?, ?)
""",
(
str(item.id),
item.name,
item.created_at.isoformat(),
int(item.take_pl_on_red_days),
),
)
await commit(self._conn)
@@ -51,8 +53,8 @@ class SQLiteWorkspaceRepository(WorkspaceRepository):
async def update(self, id: UUID, item: Workspace) -> Workspace:
cursor = await self._conn.execute(
"UPDATE workspace SET name = ? WHERE id = ?",
(item.name, str(id)),
"UPDATE workspace SET name = ?, take_pl_on_red_days = ? WHERE id = ?",
(item.name, int(item.take_pl_on_red_days), str(id)),
)
await commit(self._conn)
if cursor.rowcount == 0:
+3 -1
View File
@@ -84,7 +84,9 @@ async def update_workspace(
service: WorkspaceServiceDep,
) -> WorkspaceResponse:
try:
ws = await service.update_workspace(workspace_id, payload.name)
ws = await service.update_workspace(
workspace_id, payload.name, take_pl_on_red_days=payload.take_pl_on_red_days
)
except (NotFoundError, ValidationError) as exc:
code = (
status.HTTP_404_NOT_FOUND
+70 -4
View File
@@ -62,6 +62,15 @@ REASON_FP_LAGSTA_STRETCH = "fp_lagsta_stretch"
REASON_SWAP_FIRST_HALF = "swap_first_half"
REASON_SWAP_SECOND_HALF = "swap_second_half"
REASON_DUBBELDAG = "dubbeldag"
# §todo item 2 — a free day (Saturday, Sunday or public holiday) funded with a
# parental-benefit day instead of being left free, opted into per-workspace via
# `AllocationInput.take_pl_on_red_days` (default False — see `_fund_solo`/
# `_fund_both`). Vacation is deliberately never used here: Swedish law does not
# let you "take vacation" on a day that already isn't a working day, but FP is
# a benefit payment, not a scheduling construct, so it can still be drawn on a
# red day to replace the (unpaid) day off with a paid one — at the cost of
# spending a day the household would otherwise have kept.
REASON_FP_RED_DAY = "fp_red_day"
# ---------------------------------------------------------------------------
# Surplus advice kinds (§9.2) — the mirror of a shortfall remedy: emitted
@@ -219,6 +228,15 @@ class AllocationInput:
# despite the calendar. `is_free_day` is the single place these are
# merged on top of `holidays`.
day_overrides: dict[date, bool] = field(default_factory=dict)
# §todo item 2 — per-workspace opt-in (default False, byte-identical to
# pre-existing behaviour when unset): allow a free day (Saturday, Sunday,
# public holiday) inside a funded window to be paid with an FP day
# (never vacation — see `REASON_FP_RED_DAY`). Read only by `_fund_solo`/
# `_fund_both`; deliberately NOT consulted by `is_free_day` itself, which
# stays the single, opt-in-agnostic definition of "is this day free" (it
# is also used for costing/klämdag-cluster detection, where the day must
# stay classified as free regardless of whether it ends up FP-funded).
take_pl_on_red_days: bool = False
# §fix (parent-reassignment moves dates, decision B) — the FULL,
# never-filtered snapshot of every persisted `planned_leave_day` row for
# this workspace, independent of `existing_planned` above (which
@@ -1057,6 +1075,25 @@ class _Ledger:
continue
if is_free_day(d, self._input.holidays, self._input.day_overrides):
fp_on_red_day = (
self._spend_fp(pool_key, prefer="sjukpenning")
if self._input.take_pl_on_red_days and pool_key is not None
else None
)
if fp_on_red_day is not None:
cost[fp_on_red_day] += 1
days.append(
ProposedDay(
d,
parent.uuid,
child_id,
fp_on_red_day,
False,
wish.id,
REASON_FP_RED_DAY,
)
)
continue
cost["free"] += 1
reason = free_reason_override or (
REASON_FREE_RED_DAY
@@ -1248,14 +1285,42 @@ class _Ledger:
continue
if is_free_day(d, self._input.holidays, self._input.day_overrides):
cost["free"] += 1
reason = (
# §todo item 2 — each parent's own FP pool is tried
# independently (never vacation, never a peeked-then-
# committed-together pair like dubbeldagar/co-leave above):
# one parent running out of FP must never stop the other
# from still getting paid for this same red day.
free_reason = (
REASON_FREE_RED_DAY
if _is_real_red_day(d, self._input.holidays)
else REASON_FREE_WEEKEND
)
days.append(ProposedDay(d, parent_a.uuid, child.id, "free", False, wish.id, reason))
days.append(ProposedDay(d, parent_b.uuid, child.id, "free", False, wish.id, reason))
for red_parent, red_pool in ((parent_a, pool_a), (parent_b, pool_b)):
fp_on_red_day = (
self._spend_fp((red_parent.uuid, child.id), prefer="sjukpenning")
if self._input.take_pl_on_red_days and red_pool is not None
else None
)
if fp_on_red_day is not None:
cost[fp_on_red_day] += 1
days.append(
ProposedDay(
d,
red_parent.uuid,
child.id,
fp_on_red_day,
False,
wish.id,
REASON_FP_RED_DAY,
)
)
else:
cost["free"] += 1
days.append(
ProposedDay(
d, red_parent.uuid, child.id, "free", False, wish.id, free_reason
)
)
continue
funded = False
@@ -2550,6 +2615,7 @@ class AllocationInputLoader:
existing_planned=existing_planned,
holidays=holidays_map,
day_overrides=day_overrides,
take_pl_on_red_days=ws.take_pl_on_red_days,
all_existing_planned=all_existing_planned,
)
@@ -82,10 +82,14 @@ class WorkspaceService:
self,
workspace_id: UUID,
name: str | None = None,
*,
take_pl_on_red_days: bool | None = None,
) -> Workspace:
ws = await self.get_workspace(workspace_id)
if name is not None:
ws.name = name
if take_pl_on_red_days is not None:
ws.take_pl_on_red_days = take_pl_on_red_days
return await self._ws.update(workspace_id, ws)
async def list_members(self, workspace_id: UUID) -> list[PersonalData]:
@@ -0,0 +1,38 @@
"""Add workspace.take_pl_on_red_days.
Per-workspace opt-in (default off) letting the planning engine fund a free
day (Saturday, Sunday, public holiday) with a parental-benefit day instead of
leaving it unpaid — never with a vacation day, since Swedish law does not let
you "take vacation" on a day that already isn't a working day. See
`app.services.allocation_service.REASON_FP_RED_DAY` and
`AllocationInput.take_pl_on_red_days`.
A plain `ADD COLUMN` suffices here (unlike 002's CHECK-constraint change,
which needed a full table rebuild) since SQLite supports adding a NOT NULL
column with a constant DEFAULT directly.
Must stay byte-for-byte equivalent to the `workspace` table in
`app/db/connection.py`'s `_FULL_SCHEMA` — `tests/integration/test_schema_parity.py`
asserts exactly that.
Revision ID: 003
Revises: 002
Create Date: 2026-09-19
"""
from __future__ import annotations
from alembic import op
revision: str = "003"
down_revision: str | None = "002"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.execute("ALTER TABLE workspace ADD COLUMN take_pl_on_red_days BOOLEAN NOT NULL DEFAULT 0")
def downgrade() -> None:
op.execute("ALTER TABLE workspace DROP COLUMN take_pl_on_red_days")
@@ -25,6 +25,7 @@ from app.entities.leave_wish import LeaveWish
from app.entities.planned_leave import PlannedLeaveDay
from app.services.allocation_service import (
REASON_DUBBELDAG,
REASON_FP_RED_DAY,
REASON_VACATION_WRAP_RED_DAY,
REASON_VACATION_WRAP_WEEKEND,
STRATEGY_UNPLACED,
@@ -140,6 +141,7 @@ def _build_input(
extra_holiday_years: tuple[int, ...] = (),
day_overrides: dict[date, bool] | None = None,
age4_savable_cap: int | None = None,
take_pl_on_red_days: bool = False,
) -> AllocationInput:
years = {today.year, horizon_end.year, horizon_end.year + 1, *extra_holiday_years}
if age4_savable_cap is None:
@@ -161,6 +163,7 @@ def _build_input(
existing_planned=existing_planned or [],
holidays=_holidays_for(*years),
day_overrides=day_overrides or {},
take_pl_on_red_days=take_pl_on_red_days,
all_existing_planned=all_existing_planned,
)
@@ -2520,3 +2523,245 @@ class TestCase29FixedYearlyWishPreservesWeekdayAlignment:
assert wish.window_for_year(2026) == (date(2026, 3, 1), date(2026, 5, 31))
assert wish.window_for_year(2027) == (date(2027, 3, 1), date(2027, 5, 31))
assert wish.window_for_year(2028) == (date(2028, 3, 1), date(2028, 5, 31))
# ---------------------------------------------------------------------------
# todo.md item 2, Part 1 — regression: a free day (Saturday, Sunday, public
# holiday) must never spend a vacation or FP day unless the workspace
# explicitly opts in via `take_pl_on_red_days`.
#
# Window: Mon 2027-03-22 -> Sun 2027-03-28 (verified against
# `get_swedish_holidays(2027)` directly):
# Mon 22, Tue 23, Wed 24, Thu 25 -- ordinary spendable weekdays, no holiday.
# Fri 26 -- Långfredagen (a REAL weekday public holiday).
# Sat 27 -- Påskafton (weekend; also separately named).
# Sun 28 -- "Påskdagen; Söndag" (weekend; a real holiday, not just the
# generic Sunday marker).
# 4 spendable days, 3 free days (one of which is a real red day even before
# counting the weekend).
# ---------------------------------------------------------------------------
_RED_WEEK = ((3, 22), (3, 28))
_RED_WEEK_FREE_DATES = (date(2027, 3, 26), date(2027, 3, 27), date(2027, 3, 28))
class TestCase30FreeDaysNeverSpendResourcesByDefault:
def test_weekend_and_red_day_spend_nothing_solo(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id, start=_RED_WEEK[0], end=_RED_WEEK[1], weeks=1, first_year=2027
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
# take_pl_on_red_days defaults to False -- the point of this test.
)
proposal = Allocator().propose(alloc_input)
days_by_date = {d.date: d for d in proposal.days}
for free_date in _RED_WEEK_FREE_DATES:
assert days_by_date[free_date].day_type == "free"
outcome = proposal.outcomes[0]
assert outcome.cost_breakdown["free"] == 3
# The 4 ordinary weekdays (Mon-Thu) are the only days that spend
# anything at all -- all via vacation (monthly-salary tier-3 policy).
assert outcome.cost_breakdown["vacation"] == 4
assert outcome.cost_breakdown["sjukpenning"] == 0
assert outcome.cost_breakdown["lagsta"] == 0
def test_weekend_and_red_day_spend_nothing_both_parents(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
wish = _wish(
child_id=child.id,
start=_RED_WEEK[0],
end=_RED_WEEK[1],
weeks=1,
first_year=2027,
parent_mode="both",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent_a, parent_b],
pools={
(parent_a.uuid, child.id): DayPool(200, 90, 60),
(parent_b.uuid, child.id): DayPool(200, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(25, 0, 0),
(parent_b.uuid, 2027): VacationBudget(25, 0, 0),
},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
# The 4 ordinary weekdays go through the normal co-leave split
# (irrelevant to this test); only the 3 free dates matter here.
for free_date in _RED_WEEK_FREE_DATES:
entries = [d for d in proposal.days if d.date == free_date]
assert len(entries) == 2
assert all(e.day_type == "free" for e in entries)
# ---------------------------------------------------------------------------
# todo.md item 2, Part 2 — `take_pl_on_red_days=True`: a free day may now be
# paid with a parental-benefit day (sjukpenning preferred over lägstanivå),
# but NEVER with vacation -- Swedish law does not let you "take vacation" on
# a day that already isn't a working day.
# ---------------------------------------------------------------------------
class TestCase31TakePlOnRedDaysSolo:
def test_red_days_funded_with_fp_when_available(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id, start=_RED_WEEK[0], end=_RED_WEEK[1], weeks=1, first_year=2027
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
take_pl_on_red_days=True,
)
proposal = Allocator().propose(alloc_input)
days_by_date = {d.date: d for d in proposal.days}
for free_date in _RED_WEEK_FREE_DATES:
day = days_by_date[free_date]
assert day.day_type == "sjukpenning"
assert day.reason == REASON_FP_RED_DAY
outcome = proposal.outcomes[0]
assert outcome.cost_breakdown["free"] == 0
assert outcome.cost_breakdown["vacation"] == 4 # unaffected: the 4 ordinary weekdays
assert outcome.cost_breakdown["sjukpenning"] == 3
assert outcome.shortfall_days == 0
def test_red_days_never_spend_vacation_even_when_fp_exhausted(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent = _parent(salary_type="monthly")
wish = _wish(
child_id=child.id, start=_RED_WEEK[0], end=_RED_WEEK[1], weeks=1, first_year=2027
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(0, 0, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
take_pl_on_red_days=True,
)
proposal = Allocator().propose(alloc_input)
days_by_date = {d.date: d for d in proposal.days}
for free_date in _RED_WEEK_FREE_DATES:
assert days_by_date[free_date].day_type == "free"
outcome = proposal.outcomes[0]
assert outcome.cost_breakdown["free"] == 3
# The 4 ordinary weekdays still spend vacation as usual -- only the
# red-day-FP branch is affected by the empty pool.
assert outcome.cost_breakdown["vacation"] == 4
class TestCase32TakePlOnRedDaysBothParents:
def test_each_parent_funded_independently_from_own_fp_pool(self) -> None:
"""Parent B's FP pool is empty, Parent A's is not. Each parent's own
pool is tried independently on a red day (never a peeked-then-
committed-together pair like dubbeldagar/co-leave) -- one parent
running out must never stop the other from still getting paid, and
must never fall back to vacation for the exhausted one either.
"""
child = _child(birth_date=date(2020, 1, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
wish = _wish(
child_id=child.id,
start=_RED_WEEK[0],
end=_RED_WEEK[1],
weeks=1,
first_year=2027,
parent_mode="both",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent_a, parent_b],
pools={
(parent_a.uuid, child.id): DayPool(200, 90, 60),
(parent_b.uuid, child.id): DayPool(0, 0, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(25, 0, 0),
(parent_b.uuid, 2027): VacationBudget(25, 0, 0),
},
wishes=[wish],
take_pl_on_red_days=True,
)
proposal = Allocator().propose(alloc_input)
for free_date in _RED_WEEK_FREE_DATES:
entries = {d.parent_personal_data_uuid: d for d in proposal.days if d.date == free_date}
assert entries[parent_a.uuid].day_type == "sjukpenning"
assert entries[parent_a.uuid].reason == REASON_FP_RED_DAY
assert entries[parent_b.uuid].day_type == "free"
def test_both_parents_funded_when_both_have_fp_left(self) -> None:
child = _child(birth_date=date(2020, 1, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
wish = _wish(
child_id=child.id,
start=_RED_WEEK[0],
end=_RED_WEEK[1],
weeks=1,
first_year=2027,
parent_mode="both",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent_a, parent_b],
pools={
(parent_a.uuid, child.id): DayPool(200, 90, 60),
(parent_b.uuid, child.id): DayPool(200, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(25, 0, 0),
(parent_b.uuid, 2027): VacationBudget(25, 0, 0),
},
wishes=[wish],
take_pl_on_red_days=True,
)
proposal = Allocator().propose(alloc_input)
for free_date in _RED_WEEK_FREE_DATES:
entries = [d for d in proposal.days if d.date == free_date]
assert len(entries) == 2
assert all(e.day_type == "sjukpenning" for e in entries)
assert all(e.reason == REASON_FP_RED_DAY for e in entries)
@@ -69,6 +69,30 @@ class TestWorkspaceUpdate:
with pytest.raises(NotFoundError):
await workspace_service.update_workspace(uuid4(), name="Whatever")
async def test_take_pl_on_red_days_defaults_to_false(
self,
workspace_service: WorkspaceService,
) -> None:
ws = await workspace_service.create_workspace("New Workspace")
assert ws.take_pl_on_red_days is False
async def test_update_take_pl_on_red_days(
self,
workspace_service: WorkspaceService,
) -> None:
ws = await workspace_service.create_workspace("Workspace")
updated = await workspace_service.update_workspace(ws.id, take_pl_on_red_days=True)
assert updated.take_pl_on_red_days is True
# Persisted, not just returned in-memory -- re-fetch to be sure.
refetched = await workspace_service.get_workspace(ws.id)
assert refetched.take_pl_on_red_days is True
# `None` (the default) means "leave unchanged", mirroring `name`'s
# omit-to-keep convention.
unchanged = await workspace_service.update_workspace(ws.id, name="Renamed")
assert unchanged.take_pl_on_red_days is True
class TestRenameMember:
"""Rename a workspace member's display name (placeholder or self)."""