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
-53
View File
@@ -1,53 +0,0 @@
"""API schemas (Pydantic) for BreakPeriod.
Schemas validate request bodies and shape responses. Separate from domain entities
so the wire format can evolve independently.
"""
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.entities.break_period import BreakPeriod
class BreakPeriodCreate(BaseModel):
workspace_id: UUID
name: str = Field(min_length=1, max_length=255)
start_date: date
end_date: date
target_weeks: int = Field(ge=1, le=52)
class BreakPeriodUpdate(BaseModel):
"""Full replacement — all fields required (PUT semantics). Partial updates are not supported."""
name: str = Field(min_length=1, max_length=255)
start_date: date
end_date: date
target_weeks: int = Field(ge=1, le=52)
class BreakPeriodResponse(BaseModel):
id: UUID
workspace_id: UUID
name: str
start_date: date
end_date: date
target_weeks: int
created_at: datetime | None
updated_at: datetime | None
@classmethod
def from_entity(cls, entity: BreakPeriod) -> "BreakPeriodResponse":
return cls(
id=entity.id,
workspace_id=entity.workspace_id,
name=entity.name,
start_date=entity.start_date,
end_date=entity.end_date,
target_weeks=entity.target_weeks,
created_at=entity.created_at,
updated_at=entity.updated_at,
)
+64
View File
@@ -0,0 +1,64 @@
"""API schemas (Pydantic) for the Swedish calendar endpoint (röda dagar /
klämdagar).
Wire format for `GET /api/calendar/days` — serves both §3.4 (shading the
Kalender page) and §3.8 (the per-year red-days list in Settings) from one
endpoint. Kept separate from the service's dataclasses so the wire format can
evolve independently.
"""
from __future__ import annotations
from datetime import date
from pydantic import BaseModel, Field
from app.services.calendar_service import CalendarDay, CalendarWindow, DayType
class CalendarDayResponse(BaseModel):
date: date
name: str | None
day_type: DayType
is_override: bool
override_is_working_day: bool | None
is_free: bool
@classmethod
def from_domain(cls, day: CalendarDay) -> "CalendarDayResponse":
return cls(
date=day.date,
name=day.name,
day_type=day.day_type,
is_override=day.is_override,
override_is_working_day=day.override_is_working_day,
is_free=day.is_free,
)
class CalendarDaysResponse(BaseModel):
"""A `years`-year window of calendar days (max `MAX_WINDOW_YEARS`,
currently 4), plus the full cached range so the UI can bound its
pagination controls.
"""
start_year: int
end_year: int
min_year: int | None = Field(
default=None,
description="First year covered by the cache, or None if the cache is missing.",
)
max_year: int | None = Field(
default=None, description="Last year covered by the cache, or None if the cache is missing."
)
days: list[CalendarDayResponse]
@classmethod
def from_domain(cls, window: CalendarWindow) -> "CalendarDaysResponse":
return cls(
start_year=window.start_year,
end_year=window.end_year,
min_year=window.min_year,
max_year=window.max_year,
days=[CalendarDayResponse.from_domain(d) for d in window.days],
)
+4
View File
@@ -17,6 +17,7 @@ class ChildCreate(BaseModel):
name: str = Field(min_length=1, max_length=255)
birth_date: date
preschool_start_date: date | None = None
preschool_planning_days_per_year: int = Field(default=6, ge=0, le=30)
@field_validator("preschool_start_date")
@classmethod
@@ -36,6 +37,7 @@ class ChildUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
birth_date: date | None = None
preschool_start_date: date | None = None
preschool_planning_days_per_year: int | None = Field(None, ge=0, le=30)
@field_validator("preschool_start_date")
@classmethod
@@ -55,6 +57,7 @@ class ChildResponse(BaseModel):
name: str
birth_date: date
preschool_start_date: date | None
preschool_planning_days_per_year: int
created_at: datetime | None
@classmethod
@@ -65,5 +68,6 @@ class ChildResponse(BaseModel):
name=entity.name,
birth_date=entity.birth_date,
preschool_start_date=entity.preschool_start_date,
preschool_planning_days_per_year=entity.preschool_planning_days_per_year,
created_at=entity.created_at,
)
+47
View File
@@ -0,0 +1,47 @@
"""API schemas (Pydantic) for DayOverride.
Schemas are the API boundary — they validate request bodies and shape
responses, kept separate from the domain entity so the wire format can
evolve independently.
"""
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.entities.day_override import DayOverride
class DayOverrideCreate(BaseModel):
workspace_id: UUID
date: date
is_working_day: bool
note: str | None = Field(default=None, max_length=280)
class DayOverrideUpdate(BaseModel):
is_working_day: bool
note: str | None = Field(default=None, max_length=280)
class DayOverrideResponse(BaseModel):
id: UUID
workspace_id: UUID
date: date
is_working_day: bool
note: str | None
created_at: datetime | None
updated_at: datetime | None
@classmethod
def from_entity(cls, entity: DayOverride) -> "DayOverrideResponse":
return cls(
id=entity.id,
workspace_id=entity.workspace_id,
date=entity.date,
is_working_day=entity.is_working_day,
note=entity.note,
created_at=entity.created_at,
updated_at=entity.updated_at,
)
+118
View File
@@ -0,0 +1,118 @@
"""API schemas (Pydantic) for LeaveWish.
Schemas validate request bodies and shape responses. Separate from domain entities
so the wire format can evolve independently. Business-rule validation (e.g. the
parent_mode / parent_personal_data_uuid pairing) lives in the service, not here —
these schemas only enforce shape and range.
"""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, Field
from app.entities.leave_wish import LeaveWish
class LeaveWishCreate(BaseModel):
workspace_id: UUID
child_id: UUID | None = None
name: str = Field(min_length=1, max_length=255)
window_start_month: int = Field(ge=1, le=12)
window_start_day: int = Field(ge=1, le=31)
window_end_month: int = Field(ge=1, le=12)
window_end_day: int = Field(ge=1, le=31)
target_weeks: int = Field(ge=1, le=52)
recurrence: str = "once" # 'yearly' | 'once'
first_year: int = Field(ge=2000, le=2200)
last_year: int | None = Field(default=None, ge=2000, le=2200)
priority: int = Field(default=1, ge=1)
parent_mode: str = "either" # 'either' | 'both' | 'specific'
parent_personal_data_uuid: UUID | None = None
goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None
# PLANNING_ENGINE_SPEC.md §10.1 — 'fixed': window_start_*/window_end_* are the
# exact placement (default). 'flexible': the same fields describe the
# bounding range the allocator searches within for the best-costing window.
window_mode: str = "fixed" # 'fixed' | 'flexible'
class LeaveWishUpdate(BaseModel):
"""Partial update — all fields optional; only provided fields are changed."""
child_id: UUID | None = None
name: str | None = Field(None, min_length=1, max_length=255)
window_start_month: int | None = Field(None, ge=1, le=12)
window_start_day: int | None = Field(None, ge=1, le=31)
window_end_month: int | None = Field(None, ge=1, le=12)
window_end_day: int | None = Field(None, ge=1, le=31)
target_weeks: int | None = Field(None, ge=1, le=52)
recurrence: str | None = None
first_year: int | None = Field(None, ge=2000, le=2200)
last_year: int | None = Field(None, ge=2000, le=2200)
priority: int | None = Field(None, ge=1)
parent_mode: str | None = None
parent_personal_data_uuid: UUID | None = None
goal_override: str | None = None
window_mode: str | None = None # 'fixed' | 'flexible'
class LeaveWishResponse(BaseModel):
id: UUID
workspace_id: UUID
child_id: UUID | None
name: str
window_start_month: int
window_start_day: int
window_end_month: int
window_end_day: int
target_weeks: int
recurrence: str
first_year: int
last_year: int | None
priority: int
parent_mode: str
parent_personal_data_uuid: UUID | None
goal_override: str | None
window_mode: str
created_at: datetime | None
updated_at: datetime | None
@classmethod
def from_entity(cls, entity: LeaveWish) -> "LeaveWishResponse":
return cls(
id=entity.id,
workspace_id=entity.workspace_id,
child_id=entity.child_id,
name=entity.name,
window_start_month=entity.window_start_month,
window_start_day=entity.window_start_day,
window_end_month=entity.window_end_month,
window_end_day=entity.window_end_day,
target_weeks=entity.target_weeks,
recurrence=entity.recurrence,
first_year=entity.first_year,
last_year=entity.last_year,
priority=entity.priority,
parent_mode=entity.parent_mode,
parent_personal_data_uuid=entity.parent_personal_data_uuid,
goal_override=entity.goal_override,
window_mode=entity.window_mode,
created_at=entity.created_at,
updated_at=entity.updated_at,
)
+6
View File
@@ -17,6 +17,10 @@ class PersonalDataUpdate(BaseModel):
salary_type: str | None = Field(None, pattern="^(monthly|hourly)$")
employment_status: str | None = Field(None, pattern="^(employed|jobseeking|none)$")
# Semesterlagen minimum (statutory 20; employers may set higher). Bounded
# generously (0-60) at the wire level — the service layer is the source
# of truth for the real constraint.
min_vacation_days_per_year: int | None = Field(None, ge=0, le=60)
class PersonalDataResponse(BaseModel):
@@ -25,6 +29,7 @@ class PersonalDataResponse(BaseModel):
name: str
salary_type: str
employment_status: str
min_vacation_days_per_year: int
created_at: datetime | None
deleted_at: datetime | None
@@ -36,6 +41,7 @@ class PersonalDataResponse(BaseModel):
name=entity.name,
salary_type=entity.salary_type,
employment_status=entity.employment_status,
min_vacation_days_per_year=entity.min_vacation_days_per_year,
created_at=entity.created_at,
deleted_at=entity.deleted_at,
)
+316
View File
@@ -0,0 +1,316 @@
"""API schemas (Pydantic) for the planning-engine endpoints.
See PLANNING_ENGINE_SPEC.md §4. `POST /api/plan/propose` is read-only and
persists nothing — it just runs `PlanningEngineService.propose()` and shapes
the resulting (pure) `PlanProposal` for the wire.
`POST /api/plan/apply` is the other half: it takes a (possibly user-edited)
subset of a proposal's `days` and turns it into real `planned_leave_day`
rows via `PlanningService.apply_proposed_days`. See that method's docstring
for the idempotency/collision/`replace` semantics.
"""
from datetime import date
from uuid import UUID
from pydantic import BaseModel, Field
from app.models.planned_leave import PlannedLeaveDayResponse
from app.services.allocation_service import PlanProposal
from app.services.planning_service import (
DayCollision,
PlanApplyResult,
ProposedDayInput,
Rejection,
)
class PlanProposeRequest(BaseModel):
workspace_id: UUID
class ProposedDayResponse(BaseModel):
date: date
parent_personal_data_uuid: UUID
child_id: UUID | None
day_type: str
is_dubbeldag: bool
wish_id: UUID
reason: str
class ShortfallRemedyResponse(BaseModel):
kind: str
message_key: str
data: dict[str, object]
days_freed: int
class SurplusAdviceResponse(BaseModel):
"""§9.2 — the mirror of `ShortfallRemedyResponse`: a concrete suggestion
for a parent forecast to *forfeit* FP days at the age-4 boundary, rather
than run short. Same (kind, message_key/data, day-count) shape as the
remedy; `data` mirrors `PlanWarningResponse.data` for the (parent, child)
scope.
"""
kind: str
message_key: str
days_involved: int
data: dict[str, object]
class WindowOutcomeResponse(BaseModel):
wish_id: UUID
wish_name: str
year: int
start_date: date
end_date: date
weeks_wished: int
weeks_funded: float
shortfall_days: int
strategy: str
message_key: str
data: dict[str, object]
cost_breakdown: dict[str, int]
remedies: list[ShortfallRemedyResponse]
class PlanWarningResponse(BaseModel):
kind: str
message_key: str
data: dict[str, object]
class PoolBalanceResponse(BaseModel):
parent_personal_data_uuid: UUID
child_id: UUID
sjukpenning_left: int
lagsta_left: int
dubbeldagar_left: int
class VacationBalanceResponse(BaseModel):
parent_personal_data_uuid: UUID
year: int
total_days: int
saved_days_in: int
already_used: int
# True when no `vacation_days` row existed for this (parent, year) and the
# loader defaulted `total_days` to the statutory entitlement rather than
# an entered value — see `VacationBudget.is_assumed`. The UI should use
# this to prompt the user to confirm/override the assumed year instead of
# silently treating it as a real entry.
is_assumed: bool
class Age4ProjectionResponse(BaseModel):
"""§3.3 part 2 — plan-aware age-4 projection: what this *proposed* plan
(not yet applied) implies at the child's 4th birthday, per (parent,
child). Always present, unlike `surplus_advice` which is only emitted
when there's a surplus to advise on.
"""
parent_personal_data_uuid: UUID
child_id: UUID
fourth_birthday: date
projected_left_at_b4: int
savable_after_b4: int
expiring_at_b4: int
class PlanProposalResponse(BaseModel):
days: list[ProposedDayResponse]
outcomes: list[WindowOutcomeResponse]
warnings: list[PlanWarningResponse]
pool_balances: list[PoolBalanceResponse]
vacation_balances: list[VacationBalanceResponse]
surplus_advice: list[SurplusAdviceResponse]
age4_projections: list[Age4ProjectionResponse]
@classmethod
def from_domain(cls, proposal: PlanProposal) -> "PlanProposalResponse":
return cls(
days=[
ProposedDayResponse(
date=d.date,
parent_personal_data_uuid=d.parent_personal_data_uuid,
child_id=d.child_id,
day_type=d.day_type,
is_dubbeldag=d.is_dubbeldag,
wish_id=d.wish_id,
reason=d.reason,
)
for d in proposal.days
],
outcomes=[
WindowOutcomeResponse(
wish_id=o.wish_id,
wish_name=o.wish_name,
year=o.year,
start_date=o.start_date,
end_date=o.end_date,
weeks_wished=o.weeks_wished,
weeks_funded=o.weeks_funded,
shortfall_days=o.shortfall_days,
strategy=o.strategy,
message_key=o.message_key,
data=o.data,
cost_breakdown=o.cost_breakdown,
remedies=[
ShortfallRemedyResponse(
kind=r.kind,
message_key=r.message_key,
data=r.data,
days_freed=r.days_freed,
)
for r in o.remedies
],
)
for o in proposal.outcomes
],
warnings=[
PlanWarningResponse(kind=w.kind, message_key=w.message_key, data=w.data)
for w in proposal.warnings
],
pool_balances=[
PoolBalanceResponse(
parent_personal_data_uuid=parent_id,
child_id=child_id,
sjukpenning_left=pool.sjukpenning_left,
lagsta_left=pool.lagsta_left,
dubbeldagar_left=pool.dubbeldagar_left,
)
for (parent_id, child_id), pool in proposal.balances_after.pools_after.items()
],
vacation_balances=[
VacationBalanceResponse(
parent_personal_data_uuid=parent_id,
year=year,
total_days=budget.total_days,
saved_days_in=budget.saved_days_in,
already_used=budget.already_used,
is_assumed=budget.is_assumed,
)
for (parent_id, year), budget in proposal.balances_after.vacation_after.items()
],
surplus_advice=[
SurplusAdviceResponse(
kind=a.kind,
message_key=a.message_key,
days_involved=a.days_involved,
data=a.data,
)
for a in proposal.surplus_advice
],
age4_projections=[
Age4ProjectionResponse(
parent_personal_data_uuid=p.parent_personal_data_uuid,
child_id=p.child_id,
fourth_birthday=p.fourth_birthday,
projected_left_at_b4=p.projected_left_at_b4,
savable_after_b4=p.savable_after_b4,
expiring_at_b4=p.expiring_at_b4,
)
for p in proposal.age4_projections
],
)
# ---------------------------------------------------------------------------
# POST /api/plan/apply
# ---------------------------------------------------------------------------
class ProposedDayApplyItem(BaseModel):
"""One day to persist. Same shape as `ProposedDayResponse` minus the
explain-only `wish_id`/`reason` (harmless if a caller sends them anyway —
a frontend can hand back `ProposedDayResponse` entries from `/propose`
verbatim; extra fields are ignored).
`day_type` additionally accepts 'free' (an automatic weekend/red-day
entry from a proposal) purely so a client doesn't have to filter those
out itself — they are always skipped, never persisted, see
`PlanApplyResponse.skipped_free_count`.
"""
date: date
parent_personal_data_uuid: UUID
child_id: UUID
day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation|free)$")
is_dubbeldag: bool = False
def to_domain(self) -> ProposedDayInput:
return ProposedDayInput(
date=self.date,
parent_personal_data_uuid=self.parent_personal_data_uuid,
child_id=self.child_id,
day_type=self.day_type,
is_dubbeldag=self.is_dubbeldag,
)
class PlanApplyRequest(BaseModel):
workspace_id: UUID
days: list[ProposedDayApplyItem]
replace: bool = False
class DayCollisionResponse(BaseModel):
date: date
parent_personal_data_uuid: UUID
child_id: UUID
existing_day_type: str
proposed_day_type: str
@classmethod
def from_domain(cls, collision: DayCollision) -> "DayCollisionResponse":
return cls(
date=collision.date,
parent_personal_data_uuid=collision.parent_personal_data_uuid,
child_id=collision.child_id,
existing_day_type=collision.existing_day_type,
proposed_day_type=collision.proposed_day_type,
)
class RejectionResponse(BaseModel):
"""§3.5 — a single reason a batch (or part of it) was refused, i18n-ready:
`message_key` + `data` (the `tips_service.Tip` shape) instead of a
pre-rendered string, so `PlanResults.tsx` can render it in the caller's
active locale.
"""
message_key: str
data: dict[str, object]
@classmethod
def from_domain(cls, rejection: Rejection) -> "RejectionResponse":
return cls(message_key=rejection.message_key, data=rejection.data)
class PlanApplyResponse(BaseModel):
accepted: bool
created_count: int
skipped_identical_count: int
skipped_free_count: int
replaced_count: int
collisions: list[DayCollisionResponse]
rejections: list[RejectionResponse]
created_days: list[PlannedLeaveDayResponse]
@classmethod
def from_domain(cls, result: PlanApplyResult) -> "PlanApplyResponse":
return cls(
accepted=result.accepted,
created_count=result.created_count,
skipped_identical_count=result.skipped_identical_count,
skipped_free_count=result.skipped_free_count,
replaced_count=result.replaced_count,
collisions=[DayCollisionResponse.from_domain(c) for c in result.collisions],
rejections=[RejectionResponse.from_domain(r) for r in result.rejections],
created_days=[PlannedLeaveDayResponse.from_entity(d) for d in result.created],
)
+9 -1
View File
@@ -16,17 +16,25 @@ class WorkspaceCreate(BaseModel):
class WorkspaceUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
planning_goal: str | None = None
planning_mode: str | None = None
class WorkspaceResponse(BaseModel):
id: UUID
name: str
planning_goal: str
planning_mode: str
created_at: datetime | None
@classmethod
def from_entity(cls, ws: Workspace) -> "WorkspaceResponse":
return cls(id=ws.id, name=ws.name, planning_goal=ws.planning_goal, created_at=ws.created_at)
return cls(
id=ws.id,
name=ws.name,
planning_goal=ws.planning_goal,
planning_mode=ws.planning_mode,
created_at=ws.created_at,
)
class PersonalDataResponse(BaseModel):