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