Separate user intent from planner output via day assertions

The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.

Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.

On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.

Bugs found and fixed along the way:

- The plan erased its own history. Fixed windows clip to today, so an elapsed
  auto-placed day fell into `stale_keys` and was deleted on the next sync —
  and a background sync runs on every workspace load. Past rows are now frozen
  and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
  (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
  exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
  itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
  with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
  without a transaction; a crash mid-write diverged the tables or half-deleted
  a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
  edited occurrence's outcome, so a fully unfunded later year rendered as an
  empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
  proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
  have failed the whole suite before its first test.

Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.

Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.

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:20:14 +02:00
parent fc90cf9a5f
commit 782415122d
101 changed files with 9542 additions and 3930 deletions
+98 -112
View File
@@ -617,81 +617,102 @@ describe('API response mappers (snake_case → camelCase)', () => {
})
})
describe('planned-day exclusions', () => {
it('posts snake_case and maps the created exclusion back to camelCase', async () => {
describe('day assertions', () => {
it('posts snake_case and maps the created assertion back to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 201,
json: async () => ({
id: 'ex-1',
id: 'da-1',
workspace_id: 'ws1',
child_id: 'child-1',
parent_personal_data_uuid: 'parent-1',
date: '2027-02-03',
day_type: 'vacation',
is_dubbeldag: false,
wish_id: null,
created_at: '2027-01-01T00:00:00',
updated_at: '2027-01-01T00:00:00',
}),
})
const result = await api.createPlannedDayExclusion({
const result = await api.assertDay({
workspaceId: 'ws1',
childId: 'child-1',
parentPersonalDataUuid: 'parent-1',
date: '2027-02-03',
dayType: 'vacation',
})
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/planned-day-exclusions')
expect(url).toContain('/day-assertions')
expect(options.method).toBe('POST')
expect(JSON.parse(options.body as string)).toEqual({
workspace_id: 'ws1',
child_id: 'child-1',
parent_personal_data_uuid: 'parent-1',
date: '2027-02-03',
day_type: 'vacation',
is_dubbeldag: false,
wish_id: null,
})
expect(result).toEqual({
id: 'ex-1',
id: 'da-1',
workspaceId: 'ws1',
childId: 'child-1',
parentPersonalDataUuid: 'parent-1',
date: '2027-02-03',
dayType: 'vacation',
isDubbeldag: false,
wishId: null,
createdAt: '2027-01-01T00:00:00',
updatedAt: '2027-01-01T00:00:00',
})
})
it('maps a listed exclusion to camelCase', async () => {
it('maps a listed assertion to camelCase', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => [
{
id: 'ex-2',
id: 'da-2',
workspace_id: 'ws1',
child_id: 'child-1',
parent_personal_data_uuid: 'parent-2',
date: '2027-02-04',
day_type: 'none',
is_dubbeldag: false,
wish_id: null,
created_at: null,
updated_at: null,
},
],
})
const result = await api.listPlannedDayExclusionsByWorkspace('ws1')
const result = await api.listDayAssertionsByWorkspace('ws1')
const [url] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/planned-day-exclusions/workspace/ws1')
expect(url).toContain('/day-assertions/workspace/ws1')
expect(result[0]!.parentPersonalDataUuid).toBe('parent-2')
expect(result[0]!.dayType).toBe('none')
expect(result[0]!.createdAt).toBeNull()
})
it('deletes an exclusion by id (the way back)', async () => {
it('clears an assertion by natural key (the way back)', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => null })
await api.deletePlannedDayExclusion('ex-1')
await api.clearDayAssertion({
workspaceId: 'ws1',
childId: 'child-1',
parentPersonalDataUuid: 'parent-1',
date: '2027-02-03',
})
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/planned-day-exclusions/ex-1')
expect(url).toContain('/day-assertions?')
expect(url).toContain('workspace_id=ws1')
expect(url).toContain('date=2027-02-03')
expect(options.method).toBe('DELETE')
})
})
@@ -735,7 +756,12 @@ describe('apiFetch (shared request/response handling)', () => {
it('returns undefined for a 204 No Content response', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
const result = await api.deletePlannedDay('pd1')
const result = await api.clearDayAssertion({
workspaceId: 'ws1',
childId: 'child1',
parentPersonalDataUuid: 'parent1',
date: '2027-02-03',
})
expect(result).toBeUndefined()
})
@@ -1735,6 +1761,7 @@ describe('endpoint functions (representative coverage)', () => {
new_is_dubbeldag: false,
old_wish_id: 'lw1',
new_wish_id: 'lw1',
evicted: false,
},
{
kind: 'added',
@@ -1747,13 +1774,9 @@ describe('endpoint functions (representative coverage)', () => {
new_is_dubbeldag: false,
old_wish_id: null,
new_wish_id: 'lw1',
evicted: false,
},
],
eviction_preview: {
would_evict: false,
candidates: [],
shortages: [],
},
}),
})
@@ -1790,6 +1813,7 @@ describe('endpoint functions (representative coverage)', () => {
newIsDubbeldag: false,
oldWishId: 'lw1',
newWishId: 'lw1',
evicted: false,
})
expect(result.changes[1]).toEqual({
kind: 'added',
@@ -1802,11 +1826,11 @@ describe('endpoint functions (representative coverage)', () => {
newIsDubbeldag: false,
oldWishId: null,
newWishId: 'lw1',
evicted: false,
})
expect(result.evictionPreview).toEqual({ wouldEvict: false, candidates: [], shortages: [] })
})
it('syncPlan maps a would-evict eviction_preview with candidates and shortages', async () => {
it('syncPlan maps a removed change flagged as evicted (a different wish won a shared pool)', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
@@ -1821,74 +1845,50 @@ describe('endpoint functions (representative coverage)', () => {
age4_projections: [],
},
apply_result: {
accepted: false,
accepted: true,
created_count: 0,
skipped_identical_count: 0,
skipped_free_count: 0,
replaced_count: 0,
collisions: [],
rejections: [
{
message_key: 'planSync.rejection.evictionChoiceRequired',
data: { count: 1 },
},
],
rejections: [],
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,
},
],
},
changes: [
{
kind: 'removed',
date: '2028-06-20',
parent_personal_data_uuid: 'pd1',
child_id: 'child1',
old_day_type: 'vacation',
new_day_type: null,
old_is_dubbeldag: false,
new_is_dubbeldag: null,
old_wish_id: 'lw1',
new_wish_id: null,
evicted: true,
},
],
}),
})
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,
},
],
})
expect(result.changes).toEqual([
{
kind: 'removed',
date: '2028-06-20',
parentPersonalDataUuid: 'pd1',
childId: 'child1',
oldDayType: 'vacation',
newDayType: null,
oldIsDubbeldag: false,
newIsDubbeldag: null,
oldWishId: 'lw1',
newWishId: null,
evicted: true,
},
])
})
it('listWorkspaceMembers maps snake_case members to camelCase', async () => {
@@ -1961,64 +1961,50 @@ describe('endpoint functions (representative coverage)', () => {
expect(lastRequest()).toEqual({ url: '/api/planned-days', method: undefined })
})
it('createPlannedDay posts the payload and maps the response', async () => {
it('assertDay posts the payload and maps the response', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
status: 201,
json: async () => ({
id: 'pld1',
id: 'da1',
workspace_id: 'ws1',
child_id: 'child1',
parent_personal_data_uuid: 'pd1',
date: '2026-06-01',
day_type: 'vacation',
is_dubbeldag: false,
wish_id: null,
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
const result = await api.createPlannedDay({
workspace_id: 'ws1',
child_id: 'child1',
parent_personal_data_uuid: 'pd1',
const result = await api.assertDay({
workspaceId: 'ws1',
childId: 'child1',
parentPersonalDataUuid: 'pd1',
date: '2026-06-01',
day_type: 'vacation',
dayType: 'vacation',
})
expect(lastRequest()).toEqual({ url: '/api/planned-days', method: 'POST' })
expect(lastRequest()).toEqual({ url: '/api/day-assertions', method: 'POST' })
expect(result.dayType).toBe('vacation')
})
it('updatePlannedDay PUTs a partial payload and maps the response', async () => {
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
id: 'pld1',
workspace_id: 'ws1',
child_id: 'child1',
parent_personal_data_uuid: 'pd1',
date: '2026-06-01',
day_type: 'sjukpenning',
is_dubbeldag: true,
created_at: '2026-01-01',
updated_at: '2026-01-01',
}),
})
const result = await api.updatePlannedDay('pld1', { is_dubbeldag: true })
expect(lastRequest()).toEqual({ url: '/api/planned-days/pld1', method: 'PUT' })
expect(result.isDubbeldag).toBe(true)
})
it('deletePlannedDay issues a DELETE', async () => {
it('clearDayAssertion issues a DELETE with the natural key as query params', async () => {
fetchMock.mockResolvedValue({ ok: true, status: 204, json: async () => ({}) })
await api.deletePlannedDay('pld1')
await api.clearDayAssertion({
workspaceId: 'ws1',
childId: 'child1',
parentPersonalDataUuid: 'pd1',
date: '2026-06-01',
})
expect(lastRequest()).toEqual({ url: '/api/planned-days/pld1', method: 'DELETE' })
const request = lastRequest()
expect(request.method).toBe('DELETE')
expect(request.url).toContain('/api/day-assertions?')
expect(request.url).toContain('date=2026-06-01')
})
it('startScraperSession, pollScraperSession and cancelScraperSession hit the fetch-data endpoints', async () => {
+145 -114
View File
@@ -23,6 +23,8 @@ import type {
WorkspaceUpdateRequest,
WorkspaceOverview,
PlanSyncResponse,
SyncPlanWithWishRequest,
PlanSyncWithWishResponse,
PreschoolPlanningDaysPlannedRequest,
PreschoolPlanningDaysTakenRequest,
CreateInvitationResponse,
@@ -408,6 +410,7 @@ interface RawWindowOutcome {
cost_breakdown: Record<string, number>
remedies: RawShortfallRemedy[]
shift_gain_days: number
series_id: string | null
}
interface RawPlanWarning {
@@ -500,6 +503,7 @@ function toPlanProposal(raw: RawPlanProposal): PlanProposal {
daysFreed: r.days_freed,
})),
shiftGainDays: o.shift_gain_days,
seriesId: o.series_id,
})),
warnings: raw.warnings.map((w) => ({ kind: w.kind, messageKey: w.message_key, data: w.data })),
poolBalances: raw.pool_balances.map((p) => ({
@@ -614,25 +618,29 @@ function toDayOverride(raw: RawDayOverride): import('@/types').DayOverride {
}
}
interface RawPlannedDayExclusion {
interface RawDayAssertion {
id: string
workspace_id: string
child_id: string
parent_personal_data_uuid: string
date: string
day_type: 'sjukpenning' | 'lagsta' | 'vacation' | 'none'
is_dubbeldag: boolean
wish_id: string | null
created_at: string | null
updated_at: string | null
}
function toPlannedDayExclusion(
raw: RawPlannedDayExclusion,
): import('@/types').PlannedDayExclusion {
function toDayAssertion(raw: RawDayAssertion): import('@/types').DayAssertion {
return {
id: raw.id,
workspaceId: raw.workspace_id,
childId: raw.child_id,
parentPersonalDataUuid: raw.parent_personal_data_uuid,
date: raw.date,
dayType: raw.day_type,
isDubbeldag: raw.is_dubbeldag,
wishId: raw.wish_id,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
}
@@ -1104,6 +1112,7 @@ interface RawPlanChange {
new_is_dubbeldag: boolean | null
old_wish_id: string | null
new_wish_id: string | null
evicted: boolean
}
function toPlanChange(raw: RawPlanChange): PlanChange {
@@ -1118,54 +1127,7 @@ function toPlanChange(raw: RawPlanChange): PlanChange {
newIsDubbeldag: raw.new_is_dubbeldag,
oldWishId: raw.old_wish_id,
newWishId: raw.new_wish_id,
}
}
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,
})),
evicted: raw.evicted,
}
}
@@ -1173,7 +1135,14 @@ interface RawPlanSyncResponse {
proposal: RawPlanProposal
apply_result: RawPlanApplyResponse
changes: RawPlanChange[]
eviction_preview: RawEvictionPreview
}
function toPlanSyncResponse(raw: RawPlanSyncResponse): PlanSyncResponse {
return {
proposal: toPlanProposal(raw.proposal),
applyResult: toPlanApplyResponse(raw.apply_result),
changes: raw.changes.map(toPlanChange),
}
}
/** POST /api/plan/sync — the direct-manipulation calendar's one call:
@@ -1188,11 +1157,97 @@ export async function syncPlan(workspaceId: string): Promise<PlanSyncResponse> {
method: 'POST',
body: JSON.stringify({ workspace_id: workspaceId }),
})) as RawPlanSyncResponse
return toPlanSyncResponse(raw)
}
interface RawPlanSyncWithWishResponse {
wish: RawLeaveWish
sync: RawPlanSyncResponse
}
/** POST /api/plan/sync-with-wish — one atomic backend call for the
* calendar's four create/edit-then-sync paths: create-or-update the
* `LeaveWish`, apply the day-lock/detach list, and sync, all in one unit of
* work server-side (`PlanningService.sync_with_wish`). Replaces the old
* create/patch-the-wish -> re-point-or-delete-the-days -> `syncPlan()`
* 3-phase sequence (and the client-side compensating rollback it needed) —
* a rejected sync now rolls back the wish/day-lock writes with it, so this
* call is either fully applied or leaves no trace, never half-applied.
*/
export async function syncPlanWithWish(
payload: SyncPlanWithWishRequest,
): Promise<PlanSyncWithWishResponse> {
const body: Record<string, unknown> = { workspace_id: payload.workspaceId }
if (payload.wishCreate) {
const w = payload.wishCreate
body.wish_create = {
child_id: w.childId ?? null,
name: w.name,
name_message_key: w.nameMessageKey ?? null,
window_start_month: w.windowStartMonth,
window_start_day: w.windowStartDay,
window_end_month: w.windowEndMonth,
window_end_day: w.windowEndDay,
target_weeks: w.targetWeeks,
target_days: w.targetDays ?? null,
recurrence: w.recurrence,
first_year: w.firstYear,
last_year: w.lastYear ?? null,
priority: w.priority ?? 1,
parent_mode: w.parentMode ?? 'either',
parent_personal_data_uuid: w.parentPersonalDataUuid ?? null,
window_mode: w.windowMode ?? 'fixed',
series_id: w.seriesId ?? null,
wants_dubbeldag: w.wantsDubbeldag ?? false,
}
}
if (payload.wishUpdate) {
const patch = payload.wishUpdate.patch
const patchBody: Record<string, unknown> = {}
if (patch.childId !== undefined) patchBody.child_id = patch.childId
if (patch.name !== undefined) patchBody.name = patch.name
if (patch.nameMessageKey !== undefined) patchBody.name_message_key = patch.nameMessageKey
if (patch.windowStartMonth !== undefined) patchBody.window_start_month = patch.windowStartMonth
if (patch.windowStartDay !== undefined) patchBody.window_start_day = patch.windowStartDay
if (patch.windowEndMonth !== undefined) patchBody.window_end_month = patch.windowEndMonth
if (patch.windowEndDay !== undefined) patchBody.window_end_day = patch.windowEndDay
if (patch.targetWeeks !== undefined) patchBody.target_weeks = patch.targetWeeks
if (patch.targetDays !== undefined) patchBody.target_days = patch.targetDays
if (patch.recurrence !== undefined) patchBody.recurrence = patch.recurrence
if (patch.firstYear !== undefined) patchBody.first_year = patch.firstYear
if (patch.lastYear !== undefined) patchBody.last_year = patch.lastYear
if (patch.priority !== undefined) patchBody.priority = patch.priority
if (patch.parentMode !== undefined) patchBody.parent_mode = patch.parentMode
if (patch.parentPersonalDataUuid !== undefined) {
patchBody.parent_personal_data_uuid = patch.parentPersonalDataUuid
}
if (patch.windowMode !== undefined) patchBody.window_mode = patch.windowMode
if (patch.wantsDubbeldag !== undefined) patchBody.wants_dubbeldag = patch.wantsDubbeldag
body.wish_update = {
leave_wish_id: payload.wishUpdate.leaveWishId,
scope: payload.wishUpdate.scope ?? 'occurrence',
patch: patchBody,
}
}
if (payload.dayActions && payload.dayActions.length > 0) {
body.day_actions = payload.dayActions.map((a) => ({
planned_day_id: a.plannedDayId,
action: a.action,
}))
}
const raw = (await apiFetch('/plan/sync-with-wish', {
method: 'POST',
body: JSON.stringify(body),
})) as RawPlanSyncWithWishResponse
return {
proposal: toPlanProposal(raw.proposal),
applyResult: toPlanApplyResponse(raw.apply_result),
changes: raw.changes.map(toPlanChange),
evictionPreview: toEvictionPreview(raw.eviction_preview),
wish: toLeaveWish(raw.wish),
sync: toPlanSyncResponse(raw.sync),
}
}
@@ -1266,7 +1321,10 @@ export async function getParentalLeave(
}
// ---------------------------------------------------------------------------
// Planned Days
// Planned Days — READ ONLY (restructure step 3b). `planned_leave_day` is
// purely derived output now; every write goes through `POST /api/plan/
// sync(-with-wish)` (services/api.ts's syncPlan/syncPlanWithWish) or
// `POST`/`DELETE /api/day-assertions` (assertDay/clearDayAssertion below).
// ---------------------------------------------------------------------------
export async function getPlannedDays(childId?: string): Promise<PlannedLeaveDay[]> {
@@ -1275,46 +1333,6 @@ export async function getPlannedDays(childId?: string): Promise<PlannedLeaveDay[
return raw.map(toPlannedLeaveDay)
}
export interface CreatePlannedDayPayload {
workspace_id: string
child_id: string
parent_personal_data_uuid: string
date: string
day_type: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag?: boolean
// True for a day hand-edited through the calendar's day popover — the
// planner (POST /api/plan/sync) must not silently retype it. See
// `PlannedLeaveDay.isPinned`.
is_pinned?: boolean
}
export async function createPlannedDay(payload: CreatePlannedDayPayload): Promise<PlannedLeaveDay> {
const raw = (await apiFetch(`/planned-days`, {
method: 'POST',
body: JSON.stringify(payload),
})) as RawPlannedLeaveDay
return toPlannedLeaveDay(raw)
}
export interface UpdatePlannedDayPayload {
day_type?: 'sjukpenning' | 'lagsta' | 'vacation'
is_dubbeldag?: boolean
// See `CreatePlannedDayPayload.is_pinned`.
is_pinned?: boolean
}
export async function updatePlannedDay(id: string, payload: UpdatePlannedDayPayload): Promise<PlannedLeaveDay> {
const raw = (await apiFetch(`/planned-days/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
})) as RawPlannedLeaveDay
return toPlannedLeaveDay(raw)
}
export async function deletePlannedDay(id: string): Promise<void> {
return apiFetch(`/planned-days/${id}`, { method: 'DELETE' })
}
// ---------------------------------------------------------------------------
// Day overrides (calendar exceptions)
// ---------------------------------------------------------------------------
@@ -1344,36 +1362,49 @@ export async function deleteDayOverride(id: string): Promise<void> {
}
// ---------------------------------------------------------------------------
// Planned-day exclusions — the days the user removed by hand and the planner
// must therefore leave empty on every subsequent sync.
// Day assertions — restructure step 3b. Replaces the write side of the
// legacy planned-days (hand-pin/ad-hoc) and planned-day-exclusions
// endpoints: `assertDay` expresses a pin (`dayType` concrete), an ad-hoc day
// (`wishId` omitted), or a removal (`dayType: 'none'`) — the planner must
// therefore leave that slot empty on every subsequent sync. Mirrors
// immediately into `planned_leave_day` (visible via `getPlannedDays` without
// waiting for the next sync). `clearDayAssertion` retracts by natural key —
// there is no id-based delete, see `DayAssertionKey`.
// ---------------------------------------------------------------------------
export async function createPlannedDayExclusion(
req: import('@/types').PlannedDayExclusionCreateRequest,
): Promise<import('@/types').PlannedDayExclusion> {
const raw = (await apiFetch('/planned-day-exclusions', {
export async function assertDay(
req: import('@/types').DayAssertionCreateRequest,
): Promise<import('@/types').DayAssertion> {
const raw = (await apiFetch('/day-assertions', {
method: 'POST',
body: JSON.stringify({
workspace_id: req.workspaceId,
child_id: req.childId,
parent_personal_data_uuid: req.parentPersonalDataUuid,
date: req.date,
day_type: req.dayType,
is_dubbeldag: req.isDubbeldag ?? false,
wish_id: req.wishId ?? null,
}),
})) as RawPlannedDayExclusion
return toPlannedDayExclusion(raw)
})) as RawDayAssertion
return toDayAssertion(raw)
}
export async function listPlannedDayExclusionsByWorkspace(
export async function listDayAssertionsByWorkspace(
workspaceId: string,
): Promise<import('@/types').PlannedDayExclusion[]> {
const raw = (await apiFetch(
`/planned-day-exclusions/workspace/${workspaceId}`,
)) as RawPlannedDayExclusion[]
return raw.map(toPlannedDayExclusion)
): Promise<import('@/types').DayAssertion[]> {
const raw = (await apiFetch(`/day-assertions/workspace/${workspaceId}`)) as RawDayAssertion[]
return raw.map(toDayAssertion)
}
export async function deletePlannedDayExclusion(id: string): Promise<void> {
return apiFetch(`/planned-day-exclusions/${id}`, { method: 'DELETE' })
export async function clearDayAssertion(key: import('@/types').DayAssertionKey): Promise<void> {
const query = new URLSearchParams({
workspace_id: key.workspaceId,
child_id: key.childId,
parent_personal_data_uuid: key.parentPersonalDataUuid,
date: key.date,
})
return apiFetch(`/day-assertions?${query.toString()}`, { method: 'DELETE' })
}
// ---------------------------------------------------------------------------