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]: