Free days, vacation forfeiture, and global auto-replanning

Free days (Sat/Sun/red days, subject to DayOverride) can never be
vacation, and can only be parental leave when the workspace opts in via
take_pl_on_red_days. The allocator already honoured this; the hand-pin
path bypassed it entirely.

- PlanningService gains optional workspace_repo/day_override_repo deps
  and _reject_illegal_free_day, enforced in add_planned_day and update.
  Reuses allocation_service.is_free_day rather than re-deriving it, and
  no-ops when the deps are unset so existing callers are unaffected.
- update_planned_day had no ValidationError handler at all, so a
  validation failure there 500'd instead of 422'ing. Fixed.
- Calendar filters the pin write loop by the day's isFree; the modal
  explains free days stay off, and that pinning opts a day out of
  automatic re-planning.

Vacation days at risk of forfeiture were computed and sent over the
wire but never rendered: an at-risk column with the age-out/over-cap
split, and a dashboard key figure.

PlanProposal.warnings was likewise parsed but rendered nowhere, for all
three kinds. Adds PlanWarningCard in a Risks section above the tips,
with parent names resolved and dates formatted.

POST /api/plan/sync now fires from the workspace layer on every
plan-input change rather than only from Calendar, so a Settings edit or
a pinned-day save re-plans day types instead of merely refetching.
Overlapping bumps coalesce into one trailing run; a rejection cannot
wedge the in-flight guard. eviction_preview was silently dropped by the
frontend despite always being on the wire, and is now surfaced through
context.

Verified: backend 890 passed (91.49% coverage), frontend 1297 passed,
ruff/eslint/tsc clean. Browser-checked in Swedish and English.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
This commit is contained in:
2026-09-20 13:07:36 +02:00
parent 16390fec50
commit 61e5487078
44 changed files with 1891 additions and 60 deletions
+91
View File
@@ -1749,6 +1749,11 @@ describe('endpoint functions (representative coverage)', () => {
new_wish_id: 'lw1',
},
],
eviction_preview: {
would_evict: false,
candidates: [],
shortages: [],
},
}),
})
@@ -1798,6 +1803,92 @@ describe('endpoint functions (representative coverage)', () => {
oldWishId: null,
newWishId: 'lw1',
})
expect(result.evictionPreview).toEqual({ wouldEvict: false, candidates: [], shortages: [] })
})
it('syncPlan maps a would-evict eviction_preview with candidates and shortages', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
proposal: {
days: [],
outcomes: [],
warnings: [],
pool_balances: [],
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
},
apply_result: {
accepted: false,
created_count: 0,
skipped_identical_count: 0,
skipped_free_count: 0,
replaced_count: 0,
collisions: [],
rejections: [
{
message_key: 'planSync.rejection.evictionChoiceRequired',
data: { count: 1 },
},
],
created_days: [],
},
changes: [],
eviction_preview: {
would_evict: true,
candidates: [
{
date: '2028-06-20',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
day_type: 'vacation',
is_dubbeldag: false,
wish_id: 'lw1',
wish_name: 'Sommar',
},
],
shortages: [
{
resource_kind: 'vacation',
parent_personal_data_uuid: 'pd1',
tier: null,
child_id: null,
year: 2028,
days_evicted: 1,
},
],
},
}),
})
const result = await api.syncPlan('ws1')
expect(result.evictionPreview).toEqual({
wouldEvict: true,
candidates: [
{
date: '2028-06-20',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
dayType: 'vacation',
isDubbeldag: false,
wishId: 'lw1',
wishName: 'Sommar',
},
],
shortages: [
{
resourceKind: 'vacation',
parentPersonalDataUuid: 'pd1',
tier: null,
childId: null,
year: 2028,
daysEvicted: 1,
},
],
})
})
it('listWorkspaceMembers maps snake_case members to camelCase', async () => {
+50
View File
@@ -1121,10 +1121,59 @@ function toPlanChange(raw: RawPlanChange): PlanChange {
}
}
interface RawEvictionCandidate {
date: string
parent_personal_data_uuid: string
child_id: string
day_type: string
is_dubbeldag: boolean
wish_id: string
wish_name: string | null
}
interface RawResourceShortage {
resource_kind: string
parent_personal_data_uuid: string
tier: string | null
child_id: string | null
year: number | null
days_evicted: number
}
interface RawEvictionPreview {
would_evict: boolean
candidates: RawEvictionCandidate[]
shortages: RawResourceShortage[]
}
function toEvictionPreview(raw: RawEvictionPreview): import('@/types').EvictionPreview {
return {
wouldEvict: raw.would_evict,
candidates: raw.candidates.map((c) => ({
date: c.date,
parentPersonalDataUuid: c.parent_personal_data_uuid,
childId: c.child_id,
dayType: c.day_type as import('@/types').EvictionCandidate['dayType'],
isDubbeldag: c.is_dubbeldag,
wishId: c.wish_id,
wishName: c.wish_name,
})),
shortages: raw.shortages.map((s) => ({
resourceKind: s.resource_kind,
parentPersonalDataUuid: s.parent_personal_data_uuid,
tier: s.tier,
childId: s.child_id,
year: s.year,
daysEvicted: s.days_evicted,
})),
}
}
interface RawPlanSyncResponse {
proposal: RawPlanProposal
apply_result: RawPlanApplyResponse
changes: RawPlanChange[]
eviction_preview: RawEvictionPreview
}
/** POST /api/plan/sync — the direct-manipulation calendar's one call:
@@ -1143,6 +1192,7 @@ export async function syncPlan(workspaceId: string): Promise<PlanSyncResponse> {
proposal: toPlanProposal(raw.proposal),
applyResult: toPlanApplyResponse(raw.apply_result),
changes: raw.changes.map(toPlanChange),
evictionPreview: toEvictionPreview(raw.eviction_preview),
}
}