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