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:
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user