Merge branch 'vacation-rescue-and-wrap'

Vacation rescue/wrap in allocation_service, new plan models/endpoints,
VacationRescueTip, useRescueDayActions, vacationSwaps util, sv+en i18n,
Dashboard/Calendar wiring.

Conflicts came from main's "day assertions" restructure and the earlier
merges; both intents kept:

- plan.py: both import sets; main's sync-with-wish handler kept next to the
  branch's new /rescue-days/dismiss endpoint; HTTP_422_UNPROCESSABLE_CONTENT
  applied to the branch's code (and a survivor in auth.py).
- Accepting a rescue day now goes through POST /api/day-assertions (an
  ad-hoc 'vacation' assertion, no wish) instead of /plan/apply with an
  `is_pinned` flag that no longer exists: on main a pin IS a day assertion
  (see PlanningService.add_planned_day's docstring), and an assertion is
  what keeps a later sync from retyping the day. useRescueDayActions,
  its tests, VacationRescueTip's tests and the backend integration flow
  follow that contract; applyPlan stays as a client for the live endpoint
  minus the dropped field. The hook also gets the security branch's
  useLatestRequest guard with an unconditionally cleared busy flag.
- Calendar.tsx: `locale` threaded into main's applyWishSyncResult call of
  buildSyncToastMessage (the branch's new parameter); the branch's
  swap-visibility toast tests drive the preset path through syncWithWish,
  which is how main saves a wish now.
- types/api.ts: ProposedDayApplyItem kept (used by applyPlan) without
  isPinned; the Add/UpdatePlannedDayRequest types stay removed as on main.
- fixtures/plan.ts: ResourceShortage import dropped (type removed on main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
This commit is contained in:
2026-09-21 07:48:33 +02:00
42 changed files with 5531 additions and 103 deletions
+120
View File
@@ -1630,6 +1630,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',
},
],
}),
})
@@ -1665,6 +1674,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 () => {
@@ -1698,6 +1716,7 @@ describe('endpoint functions (representative coverage)', () => {
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
rescue_days: [],
}),
})
@@ -1725,6 +1744,7 @@ describe('endpoint functions (representative coverage)', () => {
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
rescue_days: [],
},
apply_result: {
accepted: true,
@@ -1843,6 +1863,7 @@ describe('endpoint functions (representative coverage)', () => {
vacation_balances: [],
surplus_advice: [],
age4_projections: [],
rescue_days: [],
},
apply_result: {
accepted: true,
@@ -1891,6 +1912,105 @@ 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,
},
{
date: '2026-12-29',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
dayType: 'vacation',
wishId: null,
},
])
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,
},
{
date: '2026-12-29',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
day_type: 'vacation',
is_dubbeldag: false,
wish_id: null,
},
],
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,
+68 -1
View File
@@ -28,6 +28,8 @@ import type {
PreschoolPlanningDaysPlannedRequest,
PreschoolPlanningDaysTakenRequest,
CreateInvitationResponse,
ProposedDayApplyItem,
PlanApplyResponse,
} from '@/types'
import type {
Workspace,
@@ -452,6 +454,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[]
@@ -460,6 +470,7 @@ interface RawPlanProposal {
vacation_balances: RawVacationBalance[]
surplus_advice: RawSurplusAdvice[]
age4_projections: RawPlanAge4Projection[]
rescue_days: RawRescueDay[]
}
function toCostBreakdown(raw: Record<string, number>): CostBreakdown {
@@ -535,6 +546,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,
})),
}
}
@@ -1086,7 +1104,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,
@@ -1105,6 +1123,36 @@ 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,
})),
replace,
}),
})) as RawPlanApplyResponse
return toPlanApplyResponse(raw)
}
interface RawPlanChange {
kind: string
date: string
@@ -1255,6 +1303,25 @@ export async function syncPlanWithWish(
}
}
/** 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)
// ---------------------------------------------------------------------------