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