From b87ad0e7add7efc303f54f0a41bf3312b5c5b986 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 19 Sep 2026 14:28:38 +0200 Subject: [PATCH] Let a household choose to be paid for the red days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep --- backend/app/db/connection.py | 3 +- backend/app/entities/workspace.py | 6 + backend/app/models/workspace.py | 6 + .../sqlite/workspace_repository.py | 10 +- backend/app/routers/workspace.py | 4 +- backend/app/services/allocation_service.py | 74 +++++- backend/app/services/workspace_service.py | 4 + .../versions/003_add_take_pl_on_red_days.py | 38 +++ .../test_services/test_allocation_service.py | 245 ++++++++++++++++++ backend/tests/unit/test_workspace_service.py | 24 ++ frontend/src/i18n/en/settings.ts | 3 + frontend/src/i18n/sv/settings.ts | 3 + frontend/src/pages/Settings.test.tsx | 65 +++++ frontend/src/pages/Settings.tsx | 25 ++ frontend/src/services/api.ts | 3 + frontend/src/types/api.ts | 1 + frontend/src/types/entities.ts | 6 + frontend/tests/fixtures/mockData.ts | 1 + 18 files changed, 511 insertions(+), 10 deletions(-) create mode 100644 backend/migrations/versions/003_add_take_pl_on_red_days.py diff --git a/backend/app/db/connection.py b/backend/app/db/connection.py index 5b68e1c..8a537c7 100644 --- a/backend/app/db/connection.py +++ b/backend/app/db/connection.py @@ -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 ( diff --git a/backend/app/entities/workspace.py b/backend/app/entities/workspace.py index 851db83..f318537 100644 --- a/backend/app/entities/workspace.py +++ b/backend/app/entities/workspace.py @@ -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 diff --git a/backend/app/models/workspace.py b/backend/app/models/workspace.py index 7b8ae99..93a56a9 100644 --- a/backend/app/models/workspace.py +++ b/backend/app/models/workspace.py @@ -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, ) diff --git a/backend/app/repositories_impl/sqlite/workspace_repository.py b/backend/app/repositories_impl/sqlite/workspace_repository.py index cae07f6..a41f662 100644 --- a/backend/app/repositories_impl/sqlite/workspace_repository.py +++ b/backend/app/repositories_impl/sqlite/workspace_repository.py @@ -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: diff --git a/backend/app/routers/workspace.py b/backend/app/routers/workspace.py index 4c3b976..0cf46d6 100644 --- a/backend/app/routers/workspace.py +++ b/backend/app/routers/workspace.py @@ -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 diff --git a/backend/app/services/allocation_service.py b/backend/app/services/allocation_service.py index 8ec8b99..6dd14b7 100644 --- a/backend/app/services/allocation_service.py +++ b/backend/app/services/allocation_service.py @@ -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, ) diff --git a/backend/app/services/workspace_service.py b/backend/app/services/workspace_service.py index b84cfa3..d506925 100644 --- a/backend/app/services/workspace_service.py +++ b/backend/app/services/workspace_service.py @@ -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]: diff --git a/backend/migrations/versions/003_add_take_pl_on_red_days.py b/backend/migrations/versions/003_add_take_pl_on_red_days.py new file mode 100644 index 0000000..7e7573f --- /dev/null +++ b/backend/migrations/versions/003_add_take_pl_on_red_days.py @@ -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") diff --git a/backend/tests/unit/test_services/test_allocation_service.py b/backend/tests/unit/test_services/test_allocation_service.py index 762d1e0..f6da46a 100644 --- a/backend/tests/unit/test_services/test_allocation_service.py +++ b/backend/tests/unit/test_services/test_allocation_service.py @@ -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) diff --git a/backend/tests/unit/test_workspace_service.py b/backend/tests/unit/test_workspace_service.py index 41f4f79..e4683a5 100644 --- a/backend/tests/unit/test_workspace_service.py +++ b/backend/tests/unit/test_workspace_service.py @@ -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).""" diff --git a/frontend/src/i18n/en/settings.ts b/frontend/src/i18n/en/settings.ts index bfcf13f..f0fc9f1 100644 --- a/frontend/src/i18n/en/settings.ts +++ b/frontend/src/i18n/en/settings.ts @@ -112,6 +112,9 @@ export const settings = { 'settings.saveVacationSuccess': 'Vacation days for {year} saved!', 'settings.selectedChildLabel': 'Selected child:', 'settings.showWeekNumbers': 'Show week numbers', + 'settings.takePlOnRedDays': 'Take parental leave on red days', + 'settings.takePlOnRedDaysError': 'Could not save the setting', + 'settings.takePlOnRedDaysHelp': 'You get paid for Saturdays, Sundays and red days in the plan — but you spend days you would otherwise have kept, since vacation cannot be taken on these days.', 'settings.title': 'Settings', 'settings.vacationHelp': 'Enter vacation days per year. In the first year you are usually home more and have fewer days to spend on vacation, while later years have your full annual allotment. This controls how much time the planner has to work with.', 'settings.vacationSectionTitle': 'Vacation days', diff --git a/frontend/src/i18n/sv/settings.ts b/frontend/src/i18n/sv/settings.ts index 2d0e8b6..451741a 100644 --- a/frontend/src/i18n/sv/settings.ts +++ b/frontend/src/i18n/sv/settings.ts @@ -112,6 +112,9 @@ export const settings = { 'settings.saveVacationSuccess': 'Semesterdagar för {year} sparade!', 'settings.selectedChildLabel': 'Valt barn:', 'settings.showWeekNumbers': 'Visa veckonummer', + 'settings.takePlOnRedDays': 'Ta ut föräldrapenning på röda dagar', + 'settings.takePlOnRedDaysError': 'Kunde inte spara inställningen', + 'settings.takePlOnRedDaysHelp': 'Du får betalt för lördagar, söndagar och röda dagar i planeringen — men du förbrukar dagar du annars hade sparat, eftersom semester inte kan tas ut på dessa dagar.', 'settings.title': 'Inställningar', 'settings.vacationHelp': 'Ange semesterdagar per år. Det första året är du oftast hemma mer och har färre dagar att lägga på semester, medan kommande år har din fulla årliga tilldelning. Detta styr hur mycket tid planeraren har att arbeta med.', 'settings.vacationSectionTitle': 'Semesterdagar', diff --git a/frontend/src/pages/Settings.test.tsx b/frontend/src/pages/Settings.test.tsx index c38f6c5..8b800ff 100644 --- a/frontend/src/pages/Settings.test.tsx +++ b/frontend/src/pages/Settings.test.tsx @@ -647,6 +647,71 @@ describe('Settings', () => { expect(setShowWeekNumbers).toHaveBeenCalledWith(true) }) + describe('take PL on red days', () => { + it('renders unchecked by default', () => { + renderSettings() + expect(screen.getByTestId('take-pl-on-red-days-checkbox')).not.toBeChecked() + }) + + it('renders checked when the workspace has it enabled', () => { + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { + id: 'ws-1', + name: 'Test Workspace', + createdAt: '2026-01-01', + takePlOnRedDays: true, + }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }], + selectedChildId: 'c-1', + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + }) + ) + renderSettings() + expect(screen.getByTestId('take-pl-on-red-days-checkbox')).toBeChecked() + }) + + it('saves the setting and refreshes the workspace context on toggle', async () => { + vi.mocked(api.updateWorkspace).mockResolvedValue({ + id: 'ws-1', + name: 'Test Workspace', + createdAt: '2026-01-01', + takePlOnRedDays: true, + }) + const mockRefreshContext = vi.fn().mockResolvedValue(undefined) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }], + selectedChildId: 'c-1', + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + refreshContext: mockRefreshContext, + }) + ) + renderSettings() + + fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox')) + + await waitFor(() => + expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { takePlOnRedDays: true }) + ) + expect(mockRefreshContext).toHaveBeenCalled() + }) + + it('shows an error message when saving fails', async () => { + vi.mocked(api.updateWorkspace).mockRejectedValue(new Error('network down')) + renderSettings() + + fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox')) + + expect(await screen.findByTestId('save-message')).toHaveTextContent('network down') + }) + }) + describe('language selector', () => { // Node's own experimental `localStorage` global shadows jsdom's working // implementation in this environment and throws on every call — stub a diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 5a89e1d..38a480f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -144,6 +144,7 @@ export function Settings(): ReactNode { const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null) const [balanceFormUuid, setBalanceFormUuid] = useState(null) const [isSavingBalance, setIsSavingBalance] = useState(false) + const [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false) // Settings isn't scoped to a single child the way Onboarding/Dashboard are // — mirrors the fallback `handleSaveBalance` below uses when saving. @@ -361,6 +362,19 @@ export function Settings(): ReactNode { } } + const handleToggleTakePlOnRedDays = async (checked: boolean) => { + if (!workspace?.id) return + try { + setIsSavingRedDayPref(true) + await api.updateWorkspace(workspace.id, { takePlOnRedDays: checked }) + await refreshContext() + } catch (err) { + showMessage('error', err instanceof Error ? err.message : t('settings.takePlOnRedDaysError')) + } finally { + setIsSavingRedDayPref(false) + } + } + const handleOpenBalanceForm = (uuid: string) => { setBalanceFormUuid(uuid) } @@ -621,6 +635,17 @@ export function Settings(): ReactNode { /> {t('settings.showWeekNumbers')} + +

{t('settings.takePlOnRedDaysHelp')}

diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0239a18..1bd0ad1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -458,6 +458,7 @@ interface RawWorkspace { id: string name: string created_at: string + take_pl_on_red_days: boolean } function toWorkspace(raw: RawWorkspace): Workspace { @@ -465,6 +466,7 @@ function toWorkspace(raw: RawWorkspace): Workspace { id: raw.id, name: raw.name, createdAt: raw.created_at, + takePlOnRedDays: raw.take_pl_on_red_days, } } @@ -716,6 +718,7 @@ export async function updateWorkspace( method: 'PUT', body: JSON.stringify({ name: req.name, + take_pl_on_red_days: req.takePlOnRedDays, }), })) as RawWorkspace return toWorkspace(raw) diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 331cb8f..7ab220a 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -45,6 +45,7 @@ export interface WorkspaceCreateRequest { export interface WorkspaceUpdateRequest { name?: string + takePlOnRedDays?: boolean } // Parental leave manual entry — per-parent balances, as shown on FK's "Mina diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index 238578a..2756d27 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -5,6 +5,12 @@ export interface Workspace { id: string name: string createdAt: string // ISO-8601 + // Optional (rather than required) so pre-existing test literals across the + // codebase that construct a `Workspace` by hand don't all need updating — + // a real API response always includes it (see `toWorkspace` in + // src/services/api.ts). Absent/undefined means "not entered yet, treat as + // false", matching the backend column's default. + takePlOnRedDays?: boolean } export interface PersonalData { diff --git a/frontend/tests/fixtures/mockData.ts b/frontend/tests/fixtures/mockData.ts index b46fdba..6f0fe56 100644 --- a/frontend/tests/fixtures/mockData.ts +++ b/frontend/tests/fixtures/mockData.ts @@ -30,6 +30,7 @@ export function makeWorkspace(overrides: Partial = {}): Workspace { id: 'ws-1', name: 'Familjen Testsson', createdAt: '2026-01-01T00:00:00', + takePlOnRedDays: false, ...overrides, } }