Spend vacation on the days worth having, and name the ones about to expire

The planner knew exactly how many semesterdagar a parent was about to
forfeit and never placed a single day to prevent it. It also had no
weekend-wrap logic at all: REASON_VACATION_WRAP_WEEKEND was stamped on
every tier-3 interior vacation day, including a lone Tuesday in February.

Two changes.

Spend better. A workday adjacent to a free stretch, or in the middle of a
gap of three or fewer fenced by free days, now takes vacation --
_vacation_bridge_reason, consulted for every parent type. This departs
from spec 6.1's "wrapping suppressed for hourly": the FP-preservation
argument is salary-type independent, only the pay argument was hourly-moot.
In a both-parents window, both now take vacation on such a date rather
than one paying with FP. After funding, reallocate_vacation_by_value swaps
vacation onto higher-ranked dates 1-for-1 with FP days, so which dates hold
vacation improves while how many never drops -- greedy first, re-timed
after, never withheld. An invariant test asserts the count can only rise.

Name the losses. A new pass proposes concrete dates for vacation that would
otherwise expire, current year only, ranked red-day bridge then weekend
bridge then interior, later dates first. They ride on
PlanProposal.rescue_days, kept off `days` so nothing downstream commits
them, and surface on the Dashboard with accept-one, accept-all (one
batched /api/plan/apply, not N) and dismiss. Dismissal reuses the
tip_dismissal table, keyed on (parent, year) identity rather than the
group's dates -- those are recomputed from the whole plan's residual on
every propose, so date-hashing made dismissal self-defeating. Dismiss does
not bump the plan version; it changes no data and must not trigger a write.

Measured on the seeded demo: 12 forfeiting days for one parent now yield 12
proposed dates, all Fridays or Mondays. Total vacation placed is unchanged
at 203 days, confirming the reallocation only re-times.

Three defects here were found by driving a browser, while 2273 unit tests
passed -- all three lived in the seam between layers that unit tests mock
away. Adds API-level tests over the real request sequence. Rate limiting is
now off in tests (one file consumed the whole registration budget) with the
suite's first real 429 assertion.

PROPOSE_VS_SYNC_BUG.md documents a pre-existing, unfixed issue found along
the way: Dashboard figures swing on the first sync with no user action.

Backend 946 tests, frontend 1341, coverage above all gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm
This commit is contained in:
2026-09-21 07:00:18 +02:00
parent fc90cf9a5f
commit 014503e89b
35 changed files with 5023 additions and 99 deletions
+124
View File
@@ -1604,6 +1604,15 @@ describe('endpoint functions (representative coverage)', () => {
expiring_at_b4: 10,
},
],
rescue_days: [
{
date: '2026-12-28',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
year: 2026,
reason: 'vacation_rescue',
},
],
}),
})
@@ -1639,6 +1648,15 @@ describe('endpoint functions (representative coverage)', () => {
expect(result.age4Projections[0]!.expiringAtB4).toBe(10)
expect(result.age4Projections[0]!.fourthBirthday).toBe('2030-01-01')
expect(result.outcomes[0]!.shiftGainDays).toBe(3)
expect(result.rescueDays).toEqual([
{
date: '2026-12-28',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
year: 2026,
reason: 'vacation_rescue',
},
])
})
it('proposePlan defaults a missing cost breakdown field to 0', async () => {
@@ -1672,6 +1690,7 @@ describe('endpoint functions (representative coverage)', () => {
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
rescue_days: [],
}),
})
@@ -1699,6 +1718,7 @@ describe('endpoint functions (representative coverage)', () => {
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
rescue_days: [],
},
apply_result: {
accepted: true,
@@ -1819,6 +1839,7 @@ describe('endpoint functions (representative coverage)', () => {
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
rescue_days: [],
},
apply_result: {
accepted: false,
@@ -1891,6 +1912,109 @@ describe('endpoint functions (representative coverage)', () => {
})
})
it('applyPlan posts every day in one batch and maps the response', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
accepted: true,
created_count: 2,
skipped_identical_count: 0,
skipped_free_count: 0,
replaced_count: 0,
collisions: [],
rejections: [],
created_days: [],
}),
})
const result = await api.applyPlan('ws1', [
{
date: '2026-12-28',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
dayType: 'vacation',
wishId: null,
isPinned: true,
},
{
date: '2026-12-29',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
dayType: 'vacation',
wishId: null,
isPinned: true,
},
])
expect(lastRequest()).toEqual({ url: '/api/plan/apply', method: 'POST' })
expect(lastRequestBody()).toEqual({
workspace_id: 'ws1',
days: [
{
date: '2026-12-28',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
day_type: 'vacation',
is_dubbeldag: false,
wish_id: null,
is_pinned: true,
},
{
date: '2026-12-29',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
day_type: 'vacation',
is_dubbeldag: false,
wish_id: null,
is_pinned: true,
},
],
replace: false,
})
expect(result.accepted).toBe(true)
expect(result.createdCount).toBe(2)
})
it('applyPlan passes replace through when set', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
accepted: true,
created_count: 1,
skipped_identical_count: 0,
skipped_free_count: 0,
replaced_count: 1,
collisions: [],
rejections: [],
created_days: [],
}),
})
await api.applyPlan(
'ws1',
[{ date: '2026-12-28', parentPersonalDataUuid: 'pd1', childId: 'child1', dayType: 'vacation' }],
true,
)
expect(lastRequestBody()).toMatchObject({ replace: true })
})
it('dismissRescueDays posts the parent/year and resolves to undefined on 204', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
const result = await api.dismissRescueDays('ws1', 'pd1', 2026)
expect(lastRequest()).toEqual({ url: '/api/plan/rescue-days/dismiss', method: 'POST' })
expect(lastRequestBody()).toEqual({
workspace_id: 'ws1',
parent_personal_data_uuid: 'pd1',
year: 2026,
})
expect(result).toBeUndefined()
})
it('listWorkspaceMembers maps snake_case members to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
+69 -1
View File
@@ -26,6 +26,8 @@ import type {
PreschoolPlanningDaysPlannedRequest,
PreschoolPlanningDaysTakenRequest,
CreateInvitationResponse,
ProposedDayApplyItem,
PlanApplyResponse,
} from '@/types'
import type {
Workspace,
@@ -449,6 +451,14 @@ interface RawPlanAge4Projection {
expiring_at_b4: number
}
interface RawRescueDay {
date: string
parent_personal_data_uuid: string
child_id: string
year: number
reason: string
}
interface RawPlanProposal {
days: RawProposedDay[]
outcomes: RawWindowOutcome[]
@@ -457,6 +467,7 @@ interface RawPlanProposal {
vacation_balances: RawVacationBalance[]
surplus_advice: RawSurplusAdvice[]
age4_projections: RawPlanAge4Projection[]
rescue_days: RawRescueDay[]
}
function toCostBreakdown(raw: Record<string, number>): CostBreakdown {
@@ -531,6 +542,13 @@ function toPlanProposal(raw: RawPlanProposal): PlanProposal {
savableAfterB4: p.savable_after_b4,
expiringAtB4: p.expiring_at_b4,
})),
rescueDays: raw.rescue_days.map((r) => ({
date: r.date,
parentPersonalDataUuid: r.parent_personal_data_uuid,
childId: r.child_id,
year: r.year,
reason: r.reason,
})),
}
}
@@ -1074,7 +1092,7 @@ interface RawPlanApplyResponse {
created_days: object[]
}
function toPlanApplyResponse(raw: RawPlanApplyResponse): import('@/types').PlanApplyResponse {
function toPlanApplyResponse(raw: RawPlanApplyResponse): PlanApplyResponse {
return {
accepted: raw.accepted,
createdCount: raw.created_count,
@@ -1093,6 +1111,37 @@ function toPlanApplyResponse(raw: RawPlanApplyResponse): import('@/types').PlanA
}
}
/** Accepts a (possibly user-edited) set of proposed days as real plan —
* §4/`PlanApplyRequest`. Always resolves (200) with `accepted: false` and a
* `collisions`/`rejections` breakdown when the batch is refused; that is an
* expected outcome for the caller to render, not a thrown `ApiRequestError`.
* Sending every day in one call (rather than one call per day) matters for
* batch-scoped checks on the backend (e.g. the dubbeldagar-quota check) —
* see `PlanApplyRequest`'s docstring (backend/app/models/plan.py). */
export async function applyPlan(
workspaceId: string,
days: ProposedDayApplyItem[],
replace = false,
): Promise<PlanApplyResponse> {
const raw = (await apiFetch('/plan/apply', {
method: 'POST',
body: JSON.stringify({
workspace_id: workspaceId,
days: days.map((d) => ({
date: d.date,
parent_personal_data_uuid: d.parentPersonalDataUuid,
child_id: d.childId,
day_type: d.dayType,
is_dubbeldag: d.isDubbeldag ?? false,
wish_id: d.wishId ?? null,
is_pinned: d.isPinned ?? false,
})),
replace,
}),
})) as RawPlanApplyResponse
return toPlanApplyResponse(raw)
}
interface RawPlanChange {
kind: string
date: string
@@ -1196,6 +1245,25 @@ export async function syncPlan(workspaceId: string): Promise<PlanSyncResponse> {
}
}
/** Dismisses one (parent, year) group of suggested vacation-rescue days
* (§vacation-rescue) so it stops appearing in the next `propose`/`sync` —
* until the underlying dates change, at which point it resurfaces. 204,
* nothing to map. */
export async function dismissRescueDays(
workspaceId: string,
parentPersonalDataUuid: string,
year: number,
): Promise<void> {
return apiFetch('/plan/rescue-days/dismiss', {
method: 'POST',
body: JSON.stringify({
workspace_id: workspaceId,
parent_personal_data_uuid: parentPersonalDataUuid,
year,
}),
})
}
// ---------------------------------------------------------------------------
// Workspace members (used to resolve/select which parent a wish belongs to)
// ---------------------------------------------------------------------------