Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes

Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
2026-09-14 02:33:03 +02:00
parent 3ca1418d94
commit 76b1e712b9
218 changed files with 29867 additions and 6085 deletions
@@ -1,115 +0,0 @@
"""SQLite implementation of BreakPeriodRepository.
Handles the break_period table with date fields (start_date, end_date).
"""
from datetime import date, datetime
from uuid import UUID
import aiosqlite
from app.entities.break_period import BreakPeriod
from app.repositories.break_period_repository import BreakPeriodRepository
from app.utils.errors import NotFoundError
class SQLiteBreakPeriodRepository(BreakPeriodRepository):
def __init__(self, connection: aiosqlite.Connection) -> None:
self._conn = connection
@staticmethod
def _row_to_entity(row: aiosqlite.Row) -> BreakPeriod:
return BreakPeriod(
id=UUID(row["id"]),
workspace_id=UUID(row["workspace_id"]),
name=row["name"],
start_date=date.fromisoformat(row["start_date"]),
end_date=date.fromisoformat(row["end_date"]),
target_weeks=row["target_weeks"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
async def create(self, item: BreakPeriod) -> BreakPeriod:
now = datetime.now()
item.created_at = item.created_at or now
item.updated_at = now
await self._conn.execute(
"""
INSERT INTO break_period
(id, workspace_id, name, start_date, end_date, target_weeks,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(item.id),
str(item.workspace_id),
item.name,
item.start_date.isoformat(),
item.end_date.isoformat(),
item.target_weeks,
item.created_at.isoformat(),
item.updated_at.isoformat(),
),
)
await self._conn.commit()
return item
async def get_by_id(self, id: UUID) -> BreakPeriod | None:
cursor = await self._conn.execute("SELECT * FROM break_period WHERE id = ?", (str(id),))
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def get_by_workspace(self, workspace_id: UUID) -> list[BreakPeriod]:
cursor = await self._conn.execute(
"SELECT * FROM break_period WHERE workspace_id = ? ORDER BY start_date ASC",
(str(workspace_id),),
)
rows = await cursor.fetchall()
return [self._row_to_entity(row) for row in rows]
async def update(self, id: UUID, item: BreakPeriod) -> BreakPeriod:
item.updated_at = datetime.now()
cursor = await self._conn.execute(
"""
UPDATE break_period
SET name = ?, start_date = ?, end_date = ?, target_weeks = ?,
updated_at = ?
WHERE id = ?
""",
(
item.name,
item.start_date.isoformat(),
item.end_date.isoformat(),
item.target_weeks,
item.updated_at.isoformat(),
str(id),
),
)
await self._conn.commit()
if cursor.rowcount == 0:
raise NotFoundError(f"BreakPeriod {id} not found")
refreshed = await self.get_by_id(id)
assert refreshed is not None # just updated
return refreshed
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM break_period WHERE id = ?", (str(id),))
await self._conn.commit()
if cursor.rowcount == 0:
raise NotFoundError(f"BreakPeriod {id} not found")
async def list(self, **filters: object) -> list[BreakPeriod]:
clauses: list[str] = []
params: list[object] = []
for column in ("workspace_id",):
if column in filters and filters[column] is not None:
value = filters[column]
clauses.append(f"{column} = ?")
params.append(str(value) if isinstance(value, UUID) else value)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
cursor = await self._conn.execute(
f"SELECT * FROM break_period{where} ORDER BY start_date ASC", params
)
rows = await cursor.fetchall()
return [self._row_to_entity(row) for row in rows]
@@ -26,6 +26,7 @@ class SQLiteChildRepository(ChildRepository):
if row["preschool_start_date"]
else None
),
preschool_planning_days_per_year=row["preschool_planning_days_per_year"],
created_at=datetime.fromisoformat(row["created_at"]),
)
@@ -33,8 +34,10 @@ class SQLiteChildRepository(ChildRepository):
item.created_at = item.created_at or datetime.now()
await self._conn.execute(
"""
INSERT INTO child (id, workspace_id, name, birth_date, preschool_start_date, created_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO child
(id, workspace_id, name, birth_date, preschool_start_date,
preschool_planning_days_per_year, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
str(item.id),
@@ -42,6 +45,7 @@ class SQLiteChildRepository(ChildRepository):
item.name,
item.birth_date.isoformat(),
item.preschool_start_date.isoformat() if item.preschool_start_date else None,
item.preschool_planning_days_per_year,
item.created_at.isoformat(),
),
)
@@ -57,13 +61,15 @@ class SQLiteChildRepository(ChildRepository):
cursor = await self._conn.execute(
"""
UPDATE child
SET name = ?, birth_date = ?, preschool_start_date = ?
SET name = ?, birth_date = ?, preschool_start_date = ?,
preschool_planning_days_per_year = ?
WHERE id = ?
""",
(
item.name,
item.birth_date.isoformat(),
item.preschool_start_date.isoformat() if item.preschool_start_date else None,
item.preschool_planning_days_per_year,
str(id),
),
)
@@ -0,0 +1,109 @@
"""SQLite implementation of DayOverrideRepository.
Follows the VacationDaysRepository reference shape: map rows <-> entities
explicitly in `_row_to_entity`, always parameterize queries, raise
NotFoundError from update/delete when the row is missing.
"""
from datetime import date, datetime
from uuid import UUID
import aiosqlite
from app.entities.day_override import DayOverride
from app.repositories.day_override_repository import DayOverrideRepository
from app.utils.errors import NotFoundError
class SQLiteDayOverrideRepository(DayOverrideRepository):
def __init__(self, connection: aiosqlite.Connection) -> None:
self._conn = connection
@staticmethod
def _row_to_entity(row: aiosqlite.Row) -> DayOverride:
return DayOverride(
id=UUID(row["id"]),
workspace_id=UUID(row["workspace_id"]),
date=date.fromisoformat(row["date"]),
is_working_day=bool(row["is_working_day"]),
note=row["note"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
async def create(self, item: DayOverride) -> DayOverride:
now = datetime.now()
item.created_at = item.created_at or now
item.updated_at = now
await self._conn.execute(
"""
INSERT INTO day_override
(id, workspace_id, date, is_working_day, note, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
str(item.id),
str(item.workspace_id),
item.date.isoformat(),
int(item.is_working_day),
item.note,
item.created_at.isoformat(),
item.updated_at.isoformat(),
),
)
await self._conn.commit()
return item
async def get_by_id(self, id: UUID) -> DayOverride | None:
cursor = await self._conn.execute("SELECT * FROM day_override WHERE id = ?", (str(id),))
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def get_by_workspace_date(self, workspace_id: UUID, day: date) -> DayOverride | None:
cursor = await self._conn.execute(
"SELECT * FROM day_override WHERE workspace_id = ? AND date = ?",
(str(workspace_id), day.isoformat()),
)
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def update(self, id: UUID, item: DayOverride) -> DayOverride:
item.updated_at = datetime.now()
cursor = await self._conn.execute(
"""
UPDATE day_override
SET is_working_day = ?, note = ?, updated_at = ?
WHERE id = ?
""",
(int(item.is_working_day), item.note, item.updated_at.isoformat(), str(id)),
)
await self._conn.commit()
if cursor.rowcount == 0:
raise NotFoundError(f"DayOverride {id} not found")
refreshed = await self.get_by_id(id)
assert refreshed is not None # just updated
return refreshed
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM day_override WHERE id = ?", (str(id),))
await self._conn.commit()
if cursor.rowcount == 0:
raise NotFoundError(f"DayOverride {id} not found")
async def list(self, **filters: object) -> list[DayOverride]:
clauses: list[str] = []
params: list[object] = []
for column in ("workspace_id",):
if column in filters and filters[column] is not None:
value = filters[column]
clauses.append(f"{column} = ?")
params.append(str(value) if isinstance(value, UUID) else value)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
cursor = await self._conn.execute(
f"SELECT * FROM day_override{where} ORDER BY date ASC", params
)
rows = await cursor.fetchall()
return [self._row_to_entity(row) for row in rows]
async def list_by_workspace(self, workspace_id: UUID) -> list[DayOverride]:
return await self.list(workspace_id=workspace_id)
@@ -0,0 +1,171 @@
"""SQLite implementation of LeaveWishRepository.
Handles the leave_wish table. The recurring window is stored as month/day
integers (not dates) so it can repeat yearly (see app/entities/leave_wish.py).
"""
from datetime import datetime
from uuid import UUID
import aiosqlite
from app.entities.leave_wish import LeaveWish
from app.repositories.leave_wish_repository import LeaveWishRepository
from app.utils.errors import NotFoundError
class SQLiteLeaveWishRepository(LeaveWishRepository):
def __init__(self, connection: aiosqlite.Connection) -> None:
self._conn = connection
@staticmethod
def _row_to_entity(row: aiosqlite.Row) -> LeaveWish:
return LeaveWish(
id=UUID(row["id"]),
workspace_id=UUID(row["workspace_id"]),
child_id=UUID(row["child_id"]) if row["child_id"] is not None else None,
name=row["name"],
window_start_month=row["window_start_month"],
window_start_day=row["window_start_day"],
window_end_month=row["window_end_month"],
window_end_day=row["window_end_day"],
target_weeks=row["target_weeks"],
recurrence=row["recurrence"],
first_year=row["first_year"],
last_year=row["last_year"],
priority=row["priority"],
parent_mode=row["parent_mode"],
parent_personal_data_uuid=(
UUID(row["parent_personal_data_uuid"])
if row["parent_personal_data_uuid"] is not None
else None
),
goal_override=row["goal_override"],
window_mode=row["window_mode"],
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
async def create(self, item: LeaveWish) -> LeaveWish:
now = datetime.now()
item.created_at = item.created_at or now
item.updated_at = now
await self._conn.execute(
"""
INSERT INTO leave_wish
(id, workspace_id, child_id, name,
window_start_month, window_start_day, window_end_month, window_end_day,
target_weeks, recurrence, first_year, last_year,
priority, parent_mode, parent_personal_data_uuid, goal_override,
window_mode, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(item.id),
str(item.workspace_id),
str(item.child_id) if item.child_id is not None else None,
item.name,
item.window_start_month,
item.window_start_day,
item.window_end_month,
item.window_end_day,
item.target_weeks,
item.recurrence,
item.first_year,
item.last_year,
item.priority,
item.parent_mode,
(
str(item.parent_personal_data_uuid)
if item.parent_personal_data_uuid is not None
else None
),
item.goal_override,
item.window_mode,
item.created_at.isoformat(),
item.updated_at.isoformat(),
),
)
await self._conn.commit()
return item
async def get_by_id(self, id: UUID) -> LeaveWish | None:
cursor = await self._conn.execute("SELECT * FROM leave_wish WHERE id = ?", (str(id),))
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def get_by_workspace(self, workspace_id: UUID) -> list[LeaveWish]:
cursor = await self._conn.execute(
"SELECT * FROM leave_wish WHERE workspace_id = ? "
"ORDER BY priority ASC, first_year ASC, window_start_month ASC, window_start_day ASC",
(str(workspace_id),),
)
rows = await cursor.fetchall()
return [self._row_to_entity(row) for row in rows]
async def update(self, id: UUID, item: LeaveWish) -> LeaveWish:
item.updated_at = datetime.now()
cursor = await self._conn.execute(
"""
UPDATE leave_wish
SET child_id = ?, name = ?,
window_start_month = ?, window_start_day = ?,
window_end_month = ?, window_end_day = ?,
target_weeks = ?, recurrence = ?, first_year = ?, last_year = ?,
priority = ?, parent_mode = ?, parent_personal_data_uuid = ?,
goal_override = ?, window_mode = ?, updated_at = ?
WHERE id = ?
""",
(
str(item.child_id) if item.child_id is not None else None,
item.name,
item.window_start_month,
item.window_start_day,
item.window_end_month,
item.window_end_day,
item.target_weeks,
item.recurrence,
item.first_year,
item.last_year,
item.priority,
item.parent_mode,
(
str(item.parent_personal_data_uuid)
if item.parent_personal_data_uuid is not None
else None
),
item.goal_override,
item.window_mode,
item.updated_at.isoformat(),
str(id),
),
)
await self._conn.commit()
if cursor.rowcount == 0:
raise NotFoundError(f"LeaveWish {id} not found")
refreshed = await self.get_by_id(id)
assert refreshed is not None # just updated
return refreshed
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute("DELETE FROM leave_wish WHERE id = ?", (str(id),))
await self._conn.commit()
if cursor.rowcount == 0:
raise NotFoundError(f"LeaveWish {id} not found")
async def list(self, **filters: object) -> list[LeaveWish]:
clauses: list[str] = []
params: list[object] = []
for column in ("workspace_id", "child_id"):
if column in filters and filters[column] is not None:
value = filters[column]
clauses.append(f"{column} = ?")
params.append(str(value) if isinstance(value, UUID) else value)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
cursor = await self._conn.execute(
f"SELECT * FROM leave_wish{where} "
"ORDER BY priority ASC, first_year ASC, window_start_month ASC, window_start_day ASC",
params,
)
rows = await cursor.fetchall()
return [self._row_to_entity(row) for row in rows]
@@ -26,6 +26,7 @@ class SQLitePersonalDataRepository(PersonalDataRepository):
name=row["name"],
salary_type=row["salary_type"],
employment_status=row["employment_status"],
min_vacation_days_per_year=row["min_vacation_days_per_year"],
personnummer_enc=row["personnummer_enc"],
created_at=datetime.fromisoformat(row["created_at"]),
deleted_at=datetime.fromisoformat(row["deleted_at"]) if row["deleted_at"] else None,
@@ -38,8 +39,8 @@ class SQLitePersonalDataRepository(PersonalDataRepository):
"""
INSERT INTO personal_data
(uuid, workspace_id, name, salary_type, employment_status,
personnummer_enc, created_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
min_vacation_days_per_year, personnummer_enc, created_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(item.uuid),
@@ -47,6 +48,7 @@ class SQLitePersonalDataRepository(PersonalDataRepository):
item.name,
item.salary_type,
item.employment_status,
item.min_vacation_days_per_year,
item.personnummer_enc,
item.created_at.isoformat(),
item.deleted_at.isoformat() if item.deleted_at else None,
@@ -68,10 +70,17 @@ class SQLitePersonalDataRepository(PersonalDataRepository):
"""
UPDATE personal_data
SET name = ?, salary_type = ?, employment_status = ?,
personnummer_enc = ?
min_vacation_days_per_year = ?, personnummer_enc = ?
WHERE uuid = ? AND deleted_at IS NULL
""",
(item.name, item.salary_type, item.employment_status, item.personnummer_enc, str(id)),
(
item.name,
item.salary_type,
item.employment_status,
item.min_vacation_days_per_year,
item.personnummer_enc,
str(id),
),
)
await self._conn.commit()
if cursor.rowcount == 0:
@@ -24,14 +24,24 @@ class SQLiteWorkspaceRepository(WorkspaceRepository):
id=UUID(row["id"]),
name=row["name"],
planning_goal=row["planning_goal"],
planning_mode=row["planning_mode"],
created_at=datetime.fromisoformat(row["created_at"]),
)
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, planning_goal, created_at) VALUES (?, ?, ?, ?)",
(str(item.id), item.name, item.planning_goal, item.created_at.isoformat()),
"""
INSERT INTO workspace (id, name, planning_goal, planning_mode, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(
str(item.id),
item.name,
item.planning_goal,
item.planning_mode,
item.created_at.isoformat(),
),
)
await self._conn.commit()
return item
@@ -43,8 +53,8 @@ class SQLiteWorkspaceRepository(WorkspaceRepository):
async def update(self, id: UUID, item: Workspace) -> Workspace:
cursor = await self._conn.execute(
"UPDATE workspace SET name = ?, planning_goal = ? WHERE id = ?",
(item.name, item.planning_goal, str(id)),
"UPDATE workspace SET name = ?, planning_goal = ?, planning_mode = ? WHERE id = ?",
(item.name, item.planning_goal, item.planning_mode, str(id)),
)
await self._conn.commit()
if cursor.rowcount == 0: