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)."""
+3
View File
@@ -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',
+3
View File
@@ -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',
+65
View File
@@ -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
+25
View File
@@ -144,6 +144,7 @@ export function Settings(): ReactNode {
const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null)
const [balanceFormUuid, setBalanceFormUuid] = useState<string | null>(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 {
/>
<span>{t('settings.showWeekNumbers')}</span>
</label>
<label className="settings-page__checkbox-label">
<input
type="checkbox"
checked={workspace?.takePlOnRedDays ?? false}
onChange={(e) => handleToggleTakePlOnRedDays(e.target.checked)}
disabled={isSavingRedDayPref}
data-testid="take-pl-on-red-days-checkbox"
/>
<span>{t('settings.takePlOnRedDays')}</span>
</label>
<p className="settings-page__help">{t('settings.takePlOnRedDaysHelp')}</p>
</section>
<section className="settings-page__section">
+3
View File
@@ -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)
+1
View File
@@ -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
+6
View File
@@ -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 {
+1
View File
@@ -30,6 +30,7 @@ export function makeWorkspace(overrides: Partial<Workspace> = {}): Workspace {
id: 'ws-1',
name: 'Familjen Testsson',
createdAt: '2026-01-01T00:00:00',
takePlOnRedDays: false,
...overrides,
}
}