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