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