docs(03): revise gap plans 03-10/11/12 per checker feedback

This commit is contained in:
Lucas Berger
2026-06-05 19:27:19 -04:00
parent d1658bd1db
commit 941c4d621f
3 changed files with 117 additions and 71 deletions
@@ -16,7 +16,7 @@ must_haves:
truths: truths:
- "The worker parses the stored form JSON and PUTs a real VCALENDAR string built by buildVeventString — never raw {\"title\":...} JSON" - "The worker parses the stored form JSON and PUTs a real VCALENDAR string built by buildVeventString — never raw {\"title\":...} JSON"
- "The PUT body begins with 'BEGIN:VCALENDAR' for both create and update operations" - "The PUT body begins with 'BEGIN:VCALENDAR' for both create and update operations"
- "A single-day all-day event produces DTEND = DTSTART + 1 day (RFC 5545 exclusive end)" - "A single-day all-day event produces DTEND = DTSTART + 1 day (RFC 5545 exclusive end), proven by a DIRECT buildVeventString unit test against the D-13 contract"
- "A credential-load failure leaves the row pending for retry — the worker never PUTs with empty Basic-auth" - "A credential-load failure leaves the row pending for retry — the worker never PUTs with empty Basic-auth"
- "The first transient failure waits 15s (BACKOFF_SECONDS[0]), not 60s" - "The first transient failure waits 15s (BACKOFF_SECONDS[0]), not 60s"
artifacts: artifacts:
@@ -24,7 +24,7 @@ must_haves:
provides: "ICS-building dispatch path + removed empty-cred fallback + corrected backoff index + explicit randomUUID import" provides: "ICS-building dispatch path + removed empty-cred fallback + corrected backoff index + explicit randomUUID import"
contains: "buildVeventString" contains: "buildVeventString"
- path: apps/api/src/broker/vevent.ts - path: apps/api/src/broker/vevent.ts
provides: "All-day DTEND+1-day exclusivity fix" provides: "All-day DTEND+1-day exclusivity fix (the WR-04 owning boundary)"
key_links: key_links:
- from: "apps/api/src/broker/outboxWorker.ts" - from: "apps/api/src/broker/outboxWorker.ts"
to: "apps/api/src/broker/vevent.ts" to: "apps/api/src/broker/vevent.ts"
@@ -42,7 +42,9 @@ raw form JSON (`{"title":...}`) to Fastmail — `buildVeventString` (the whole D
DATE/DATETIME contract) is dead code (CR-02). It also silently authenticates with DATE/DATETIME contract) is dead code (CR-02). It also silently authenticates with
empty credentials on any credential-load error (CR-03), skips its first backoff empty credentials on any credential-load error (CR-03), skips its first backoff
delay (WR-01), and mishandles the all-day exclusive DTEND (WR-04). This plan wires delay (WR-01), and mishandles the all-day exclusive DTEND (WR-04). This plan wires
the VEVENT builder into the dispatch path and fixes those correctness defects. the VEVENT builder into the dispatch path, adds a direct unit test that pins the
D-13 DATE-vs-DATETIME / RFC-5545 contract independent of the worker, and fixes
those correctness defects.
Purpose: a queued write becomes a real, RFC-5545-valid VEVENT on the correct calendar. Purpose: a queued write becomes a real, RFC-5545-valid VEVENT on the correct calendar.
Output: a worker that builds ICS from the stored form fields and fails closed on bad credentials. Output: a worker that builds ICS from the stored form fields and fails closed on bad credentials.
@@ -72,20 +74,24 @@ an internal `JSON.parse(row.payload)` → `buildVeventString` step.
<tasks> <tasks>
<task type="tdd" tdd="true"> <task type="tdd" tdd="true">
<name>Task 1: RED+GREEN — worker builds and PUTs a real VEVENT (CR-02, WR-04)</name> <name>Task 1: RED+GREEN — worker builds and PUTs a real VEVENT (CR-02) + direct D-13 contract unit test + all-day DTEND+1 (WR-04)</name>
<files>apps/api/src/broker/outboxWorker.ts, apps/api/src/broker/vevent.ts, apps/api/tests/broker/outboxWorker.test.ts, apps/api/tests/broker/vevent.test.ts</files> <files>apps/api/src/broker/outboxWorker.ts, apps/api/src/broker/vevent.ts, apps/api/tests/broker/outboxWorker.test.ts, apps/api/tests/broker/vevent.test.ts</files>
<read_first> <read_first>
- apps/api/src/broker/outboxWorker.ts (dispatchRow lines 131-188 — the create/update branches that pass row.payload straight through) - apps/api/src/broker/outboxWorker.ts (dispatchRow lines 131-188 — the create/update branches that pass row.payload straight through)
- apps/api/src/broker/vevent.ts (buildVeventString signature lines 52-118; NewEventParams lines 21-33; RRULE_PRESETS lines 39-44; all-day DATE handling lines 69-88) - apps/api/src/broker/vevent.ts (buildVeventString signature lines 52-118; NewEventParams lines 21-33; RRULE_PRESETS lines 39-44; all-day DATE handling lines 69-88 — WR-04 lives in THIS branch)
- apps/api/tests/broker/vevent.test.ts (existing direct unit tests: note the all-day test at lines 53-68 asserts DTSTART format but NOT DTEND+1 — the new contract block extends this)
- apps/api/src/routes/events.ts (the route stores payload: JSON.stringify(payload) with the new title/start/end fields from plan 03-09) - apps/api/src/routes/events.ts (the route stores payload: JSON.stringify(payload) with the new title/start/end fields from plan 03-09)
- apps/api/tests/broker/outboxWorker.test.ts (line ~85 hardcodes payload:'BEGIN:VCALENDAR' and mocks write.js — the wrong boundary; the new test must stop mocking the ICS string and assert the worker BUILDS it) - apps/api/tests/broker/outboxWorker.test.ts (line ~85 hardcodes payload:'BEGIN:VCALENDAR' and mocks write.js — the wrong boundary; the new worker test must stop mocking the ICS string and assert the worker BUILDS it; match the existing vi.hoisted DB-mock + makeRow + makeResponse patterns)
- .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (CR-02, WR-04) - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (CR-02, WR-04)
</read_first> </read_first>
<behavior> <behavior>
- RED (worker): with `write.js` NOT mocking away the payload — i.e. spy on `createCalendarEvent` and capture its 4th arg `icsString` — enqueue a create row whose `payload` is `JSON.stringify({title:'Lunch',allDay:false,start:'2026-06-10T12:00:00',end:'2026-06-10T13:00:00',recurrence:'none'})`. Assert the captured icsString `.startsWith('BEGIN:VCALENDAR')` and contains `SUMMARY:Lunch`. Fails today (raw JSON is passed). - RED (vevent UNIT — D-13 contract, the authoritative regression guard): in tests/broker/vevent.test.ts add a `describe('buildVeventString — D-13 form-parsed contract')` block that calls buildVeventString DIRECTLY (no worker in the loop) with the SAME field shape the worker parses from form JSON. Two cases:
• timed: `{uid:'u1@familysync', summary:'Lunch', allDay:false, dtstart:new Date('2026-06-10T12:00:00Z'), dtend:new Date('2026-06-10T13:00:00Z')}` (recurrence omitted) → assert icsString contains `BEGIN:VCALENDAR`, `SUMMARY:Lunch`, `UID:u1@familysync`, a DTSTART line WITH a time component (matches `/DTSTART:\d{8}T\d{6}Z/`), and a DTEND line present (matches `/DTEND:\d{8}T\d{6}Z/`).
• all-day single-day: `{summary:'Birthday', allDay:true, dtstart:'2026-06-10', dtend:'2026-06-10'}` → assert DTSTART is DATE format (matches `/DTSTART[^:]*:20260610/` and does NOT match `/DTSTART[^:]*:20260610T/` — no time), and DTEND = DTSTART + 1 day (matches `/DTEND[^:]*:20260611/`, RFC-5545 exclusive end), and the DTEND date string is NOT equal to the DTSTART date string.
This unit test is the regression the worker integration test cannot catch: a vevent.ts regression would still pass the worker spy if both used the same broken builder. Fails today — the current all-day branch emits DTEND == DTSTART (no +1), so the `20260611` assertion fails.
- RED (worker INTEGRATION — wiring, complementary to the unit test): with `write.js` NOT mocking away the payload — i.e. spy on `createCalendarEvent` and capture its 4th arg `icsString` — enqueue a create row whose `payload` is `JSON.stringify({title:'Lunch',allDay:false,start:'2026-06-10T12:00:00',end:'2026-06-10T13:00:00',recurrence:'none'})`. Assert the captured icsString `.startsWith('BEGIN:VCALENDAR')` and contains `SUMMARY:Lunch`. Fails today (raw JSON is passed).
- RED (worker): an update row likewise yields an icsString starting with `BEGIN:VCALENDAR` passed to `updateCalendarEvent`. - RED (worker): an update row likewise yields an icsString starting with `BEGIN:VCALENDAR` passed to `updateCalendarEvent`.
- RED (worker): a row whose `payload` is not valid JSON marks the row `failed` (hard fail, no retry). - RED (worker): a row whose `payload` is not valid JSON marks the row `failed` (hard fail, no retry).
- RED (vevent): a single-day all-day event `{allDay:true, start:'2026-06-10', end:'2026-06-10'}` produces an icsString whose DTEND is `2026-06-11` (start + 1 day), not equal to DTSTART.
</behavior> </behavior>
<action> <action>
In `dispatchRow`, for `operation === 'create'` and `operation === 'update'`: In `dispatchRow`, for `operation === 'create'` and `operation === 'update'`:
@@ -102,10 +108,15 @@ an internal `JSON.parse(row.payload)` → `buildVeventString` step.
and to `updateCalendarEvent(client, row.calendarObjectUrl, icsString, row.etag ?? null)`. and to `updateCalendarEvent(client, row.calendarObjectUrl, icsString, row.etag ?? null)`.
Import `{ buildVeventString, RRULE_PRESETS }` from `./vevent.js`. Delete operations are unchanged (no payload). Import `{ buildVeventString, RRULE_PRESETS }` from `./vevent.js`. Delete operations are unchanged (no payload).
WR-04 in vevent.ts: in the all-day branch (lines 69-88), after computing the end DATE WR-04 — ONE owning boundary: the all-day DTEND+1 exclusivity fix lives in vevent.ts ONLY,
components, advance the end DATE by one calendar day before constructing `endTime` NOT in form/route validation. Rationale: vevent.ts is the single serialization point for every
(use a Date built from ey/em/ed, `setUTCDate(getUTCDate()+1)`, re-read y/m/d) so a one-day write path, so fixing it there covers all callers; the form/route should keep passing the
all-day event serializes DTEND = DTSTART + 1 (RFC 5545 exclusive). Keep the timed branch untouched. user-entered inclusive end date unchanged. In the all-day branch (vevent.ts lines 69-88), after
parsing the end DATE components (ey/em/ed), advance the end DATE by one calendar day before
constructing `endTime`: build a Date from ey/em/ed, `setUTCDate(getUTCDate()+1)`, re-read the
rolled-over y/m/d, and use those for `endTime`. A one-day all-day event then serializes
DTEND = DTSTART + 1. Keep the timed branch untouched. The acceptance test for WR-04 is the
DIRECT vevent unit-test case above (the owning boundary), not the worker integration path.
Update the existing outbox test that fed a pre-built ICS string so it instead feeds Update the existing outbox test that fed a pre-built ICS string so it instead feeds
form JSON and asserts the built ICS (it was testing the wrong boundary). Commit RED then GREEN. form JSON and asserts the built ICS (it was testing the wrong boundary). Commit RED then GREEN.
@@ -114,21 +125,23 @@ an internal `JSON.parse(row.payload)` → `buildVeventString` step.
<automated>cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts</automated> <automated>cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts</automated>
</verify> </verify>
<acceptance_criteria> <acceptance_criteria>
- behavior: the icsString passed to createCalendarEvent starts with 'BEGIN:VCALENDAR' and contains the summary. - behavior (unit, owning boundary): a DIRECT buildVeventString call on a single-day all-day event yields DTEND = start + 1 day (`20260611`) and DTEND != DTSTART.
- behavior (unit): a DIRECT buildVeventString call on a timed form-shaped event yields icsString containing BEGIN:VCALENDAR, SUMMARY:, UID:, a timed DTSTART (`/DTSTART:\d{8}T\d{6}Z/`), and a DTEND line.
- behavior (integration): the icsString passed to createCalendarEvent starts with 'BEGIN:VCALENDAR' and contains the summary.
- behavior: an unparseable payload marks the row failed with no retry. - behavior: an unparseable payload marks the row failed with no retry.
- behavior: single-day all-day event yields DTEND = start + 1 day.
- source: `grep -c 'buildVeventString' apps/api/src/broker/outboxWorker.ts` returns >= 1. - source: `grep -c 'buildVeventString' apps/api/src/broker/outboxWorker.ts` returns >= 1.
- source: `grep -c 'D-13 form-parsed contract' apps/api/tests/broker/vevent.test.ts` returns 1 (the new direct unit-test block exists).
- test-command: `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts` passes. - test-command: `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts` passes.
</acceptance_criteria> </acceptance_criteria>
<done>Every create/update PUTs a real RFC-5545 VCALENDAR built from the stored form fields, honoring D-13 DATE-vs-DATETIME and the exclusive all-day DTEND.</done> <done>Every create/update PUTs a real RFC-5545 VCALENDAR built from the stored form fields; the D-13 DATE-vs-DATETIME contract and the exclusive all-day DTEND are pinned by a direct buildVeventString unit test that a worker-only test could not catch.</done>
</task> </task>
<task type="tdd" tdd="true"> <task type="tdd" tdd="true">
<name>Task 2: RED+GREEN — fail closed on bad credentials, fix backoff index, explicit randomUUID (CR-03, WR-01, WR-08)</name> <name>Task 2: RED+GREEN — fail closed on bad credentials, fix backoff index, explicit randomUUID (CR-03, WR-01, WR-08)</name>
<files>apps/api/src/broker/outboxWorker.ts, apps/api/tests/broker/outboxWorker.test.ts</files> <files>apps/api/src/broker/outboxWorker.ts, apps/api/src/routes/events.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
<read_first> <read_first>
- apps/api/src/broker/outboxWorker.ts (dispatchRow try/catch fallback lines 135-143; backoff math lines 316-341; BACKOFF_SECONDS lines 40-44) - apps/api/src/broker/outboxWorker.ts (dispatchRow try/catch fallback lines 135-143; backoff math lines 316-341; BACKOFF_SECONDS lines 40-44)
- apps/api/src/routes/events.ts (uses bare `crypto.randomUUID()` at lines 241,317,318 — WR-08 is the route-side instance; vevent.ts already imports from 'crypto') - apps/api/src/routes/events.ts (uses bare `crypto.randomUUID()` at line 241 and the edit/move handlers — WR-08 is the route-side instance; vevent.ts already imports `randomUUID` from 'crypto')
- .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (CR-03, WR-01, WR-08) - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (CR-03, WR-01, WR-08)
</read_first> </read_first>
<behavior> <behavior>
@@ -147,39 +160,47 @@ an internal `JSON.parse(row.payload)` → `buildVeventString` step.
`const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000`. Keep `nextAttemptCount = row.attemptCount + 1` `const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000`. Keep `nextAttemptCount = row.attemptCount + 1`
for the persisted `attemptCount` and the `>= MAX_ATTEMPTS` dead-letter check. This makes the first retry wait 15s. for the persisted `attemptCount` and the `>= MAX_ATTEMPTS` dead-letter check. This makes the first retry wait 15s.
WR-08: in events.ts replace the three bare `crypto.randomUUID()` calls (lines 241,317,318) with `randomUUID()` WR-08: in events.ts replace every bare `crypto.randomUUID()` call (the create handler at line ~241 plus the
imported via `import { randomUUID } from 'node:crypto'`, matching vevent.ts. (events.ts is also edited by plan 03-09; edit/move handlers) with `randomUUID()` imported via `import { randomUUID } from 'node:crypto'`, matching
this plan runs after it in a later wave so there is no concurrent edit.) vevent.ts. Confirm with grep that no bare `crypto.randomUUID(` remains. (events.ts is also edited by plan 03-09;
this plan runs in a later wave so there is no concurrent edit.) Because this task edits events.ts but its vitest
command only runs broker tests, the route edit is proven to COMPILE via the `npm run build` (tsc) assertion in
this plan's <verification> and the acceptance criterion below — this closes Warning 5.
Commit RED then GREEN. Commit RED then GREEN.
</action> </action>
<verify> <verify>
<automated>cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts</automated> <automated>cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts && cd apps/api && npm run build</automated>
</verify> </verify>
<acceptance_criteria> <acceptance_criteria>
- behavior: a credential-load failure leaves the row pending and never calls createFastmailClient('', ''). - behavior: a credential-load failure leaves the row pending and never calls createFastmailClient('', '').
- behavior: first transient retry delay equals BACKOFF_SECONDS[0] (15s). - behavior: first transient retry delay equals BACKOFF_SECONDS[0] (15s).
- source: `grep -c "createFastmailClient('', '')" apps/api/src/broker/outboxWorker.ts` returns 0. - source: `grep -c "createFastmailClient('', '')" apps/api/src/broker/outboxWorker.ts` returns 0.
- source: `grep -c "import { randomUUID } from 'node:crypto'" apps/api/src/routes/events.ts` returns 1. - source: `grep -c "import { randomUUID } from 'node:crypto'" apps/api/src/routes/events.ts` returns 1.
- source: `grep -c 'crypto.randomUUID(' apps/api/src/routes/events.ts` returns 0 (no bare calls remain).
- test-command: `cd apps/api && npm run build` (tsc) succeeds — proves the edited events.ts route compiles (Warning 5 closed).
- test-command: `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts` passes. - test-command: `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts` passes.
</acceptance_criteria> </acceptance_criteria>
<done>The worker fails closed on credential errors (retryable), uses the documented 15s-first backoff schedule, and uses an explicitly-imported randomUUID.</done> <done>The worker fails closed on credential errors (retryable), uses the documented 15s-first backoff schedule, and uses an explicitly-imported randomUUID; the edited route is proven to compile via tsc.</done>
</task> </task>
</tasks> </tasks>
<verification> <verification>
- `cd apps/api && npx vitest run tests/broker/` green. - `cd apps/api && npx vitest run tests/broker/` green.
- `cd apps/api && npm run build` succeeds. - `cd apps/api && npm run build` succeeds (also proves the WR-08 events.ts edit compiles — Warning 5).
- `grep -rn buildVeventString apps/api/src` shows a live call site outside vevent.ts (IN-01 closed). - `grep -rn buildVeventString apps/api/src` shows a live call site outside vevent.ts (IN-01 closed).
</verification> </verification>
<success_criteria> <success_criteria>
Queued writes serialize to valid VCALENDAR via buildVeventString, all-day DTEND is exclusive, Queued writes serialize to valid VCALENDAR via buildVeventString, the D-13 DATE-vs-DATETIME contract and
credential failures retry instead of writing with empty auth, and the backoff schedule matches its docs. exclusive all-day DTEND are pinned by a direct unit test, credential failures retry instead of writing with
empty auth, the backoff schedule matches its docs, and the edited route compiles.
CR-02, CR-03, WR-01, WR-04, WR-08, IN-01 closed. CR-02, CR-03, WR-01, WR-04, WR-08, IN-01 closed.
</success_criteria> </success_criteria>
<output> <output>
Create `.planning/phases/03-event-write-back-pwa-install/03-10-SUMMARY.md` when done. Create `.planning/phases/03-event-write-back-pwa-install/03-10-SUMMARY.md` when done.
</output> </output>
</content>
</invoke>
@@ -17,7 +17,7 @@ must_haves:
- "A same-calendar update re-reads the freshest etag from calendarEvents just before PUT, so rapid successive edits do not spuriously 412" - "A same-calendar update re-reads the freshest etag from calendarEvents just before PUT, so rapid successive edits do not spuriously 412"
artifacts: artifacts:
- path: apps/api/src/broker/outboxWorker.ts - path: apps/api/src/broker/outboxWorker.ts
provides: "Durable create-before-delete gating, drain concurrency guard, fresh-etag-before-PUT" provides: "Durable create-before-delete gating, drain concurrency guard (single-process), fresh-etag-before-PUT"
contains: "isDraining" contains: "isDraining"
key_links: key_links:
- from: "runOutboxDrain" - from: "runOutboxDrain"
@@ -67,29 +67,42 @@ gated by querying its sibling create's status, not enqueued as a new enum value)
<name>Task 1: RED+GREEN — durable create-before-delete + drain concurrency guard (CR-04, CR-05)</name> <name>Task 1: RED+GREEN — durable create-before-delete + drain concurrency guard (CR-04, CR-05)</name>
<files>apps/api/src/broker/outboxWorker.ts, apps/api/tests/broker/outboxWorker.test.ts</files> <files>apps/api/src/broker/outboxWorker.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
<read_first> <read_first>
- apps/api/src/broker/outboxWorker.ts (failedCreateGroups Set lines 271-296; per-batch sort lines 263-269; startOutboxWorker schedule lines 359-365; runOutboxDrain entry line 247) - apps/api/src/broker/outboxWorker.ts (failedCreateGroups Set lines 271-296; per-batch sort lines 263-269; the pending-rows select at lines 249-257; startOutboxWorker schedule lines 359-365; runOutboxDrain entry line 247)
- apps/api/src/db/schema.ts (calendarOutbox: status enum pending|done|failed|dead, groupId, lines 125-154) - apps/api/src/db/schema.ts (calendarOutbox: status enum pending|done|failed|dead, groupId, lines 125-154)
- apps/api/tests/broker/outboxWorker.test.ts (the vi.hoisted DB mock: `mockSelectFn → mockFromFn → mockWherePending`; today EVERY `db.select().from().where()` resolves to the single `mockPendingRows` array. To return DIFFERENT results for the pending-rows select vs the sibling-status select, give `mockWherePending` a per-call implementation via `.mockImplementationOnce(...)` queued in call order, OR branch on the `where(...)` condition arg. Match the existing `beforeEach` chain-restore style at lines 99-109.)
- .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (CR-04, CR-05) - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (CR-04, CR-05)
</read_first> </read_first>
<behavior> <behavior>
- RED (CR-04 cross-batch): simulate a move pair where the create row and the delete row are returned in SEPARATE drain calls. Drain 1 returns only the delete row (create not yet done). Assert the delete is NOT dispatched (deleteCalendarEvent not called) because the paired create (queried by groupId) is not `done`. Drain 2, after the create has reached `done`, dispatches the delete. Fails today (the in-memory Set is empty in drain 1, so the delete proceeds). - RED (CR-04 cross-batch) — CONCRETE setup, two separate `await runOutboxDrain()` calls:
- RED (CR-05): invoke `runOutboxDrain` twice concurrently (do not await the first before starting the second) against the same pending row; assert the dispatch wrapper (createCalendarEvent) is invoked exactly once across both calls. Build a move pair sharing `groupId='edit-move-group-001'`: a create row (id 3, operation 'create', status 'pending') and a delete row (id 2, operation 'delete', calendarObjectUrl set, etag set, payload null).
DRAIN 1: mock the pending-rows select to return ONLY the delete row (the create is not yet due/returned). Mock the sibling-status select (the query for `groupId='edit-move-group-001' AND operation='create'`) to return `[{ status: 'pending' }]`. Assert after drain 1: `deleteCalendarEvent` was NOT called, and the delete row's status update was NOT set to 'done'/'failed' (it is left pending for a later cycle). This FAILS today: the in-memory `failedCreateGroups` Set is empty in this batch, so the delete proceeds and `deleteCalendarEvent` IS called.
DRAIN 2: now mock the pending-rows select to return the delete row again, and mock the sibling-status select to return `[{ status: 'done' }]` (the create succeeded in a prior cycle). Assert after drain 2: `deleteCalendarEvent` WAS called exactly once. State each assertion explicitly so the test cannot pass trivially: drain-1 asserts `expect(deleteCalendarEvent).not.toHaveBeenCalled()`; drain-2 asserts `expect(deleteCalendarEvent).toHaveBeenCalledTimes(1)`.
- RED (CR-04 paired-create-failed): with the same pair, mock the sibling-status select to return `[{ status: 'failed' }]`; assert `deleteCalendarEvent` is NOT called and the delete row is marked `failed` with a lastError mentioning the paired create (original event preserved per D-04).
- RED (CR-05): invoke `runOutboxDrain` twice concurrently (start the second WITHOUT awaiting the first) against the same single pending create row; assert `createCalendarEvent` is invoked exactly once across both calls (`expect(createCalendarEvent).toHaveBeenCalledTimes(1)`).
</behavior> </behavior>
<action> <action>
CR-04 — make the ordering durable. For a `delete` row that has a `groupId`, before dispatching, CR-04 — make the ordering durable. For a `delete` row that has a `groupId`, BEFORE dispatching,
query calendarOutbox for the sibling row with the same `groupId` and `operation='create'`: query calendarOutbox for the sibling row with the same `groupId` and `operation='create'`
- if that sibling is not yet `done`, SKIP this delete this cycle (leave it `pending` for a later (a `db.select(...).from(calendarOutbox).where(and(eq(groupId, row.groupId), eq(operation,'create')))`):
drain) — do not rely on `failedCreateGroups` co-occurring in the batch; - if that sibling create is not yet `done` (e.g. still `pending`), SKIP this delete this cycle —
- if the sibling create is `failed`/`dead`, skip the delete permanently per D-04 (mark it `failed` leave the delete row `pending` (do not update its status) so a later drain re-evaluates it.
with a "paired create did not succeed" lastError so the original event is preserved). Do NOT rely on `failedCreateGroups` co-occurring in the batch.
- if the sibling create is `failed` or `dead`, skip the delete PERMANENTLY per D-04: mark the
delete row `failed` with lastError `'paired create did not succeed — original preserved'` so the
original event is not lost.
- if the sibling create is `done`, dispatch the delete normally.
Keep the within-batch create-before-delete sort as a fast path, but the DB sibling-status query is Keep the within-batch create-before-delete sort as a fast path, but the DB sibling-status query is
the authoritative gate. Remove reliance on `failedCreateGroups` as the sole cross-cycle mechanism. the authoritative gate. Remove reliance on `failedCreateGroups` as the sole cross-cycle mechanism.
CR-05 — add a module-level `let isDraining = false`. At the top of `runOutboxDrain`, if `isDraining` CR-05 — add a module-level `let isDraining = false`. At the top of `runOutboxDrain`, if `isDraining`
return immediately; else set `isDraining = true` and wrap the whole drain body in try/finally that is true return immediately; else set `isDraining = true` and wrap the whole drain body in a
resets `isDraining = false`. The 15s scheduler in `startOutboxWorker` already calls runOutboxDrain; `try { ... } finally { isDraining = false }`. The 15s scheduler in `startOutboxWorker` already calls
the guard makes an overlapping invocation a no-op. (Document that a more robust DB row-claim is a runOutboxDrain; the guard makes an overlapping invocation a no-op.
future option, but the in-process guard is sufficient for the single-worker two-user deployment.) Add an EXPLICIT code comment next to the guard (and restate in <done>) that this in-process guard is
valid ONLY for the single-process Unraid deployment of this two-user app; a multi-process or
multi-replica deployment would require a DB row-claim (e.g. `UPDATE ... SET status='processing'
WHERE id=? AND status='pending'` with affected-rows check) instead. Document the limitation; do not
silently rely on it.
Commit RED then GREEN. Commit RED then GREEN.
</action> </action>
@@ -97,13 +110,14 @@ gated by querying its sibling create's status, not enqueued as a new enum value)
<automated>cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts</automated> <automated>cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts</automated>
</verify> </verify>
<acceptance_criteria> <acceptance_criteria>
- behavior: a delete whose paired create is not `done` is not dispatched, even when they arrive in different drain cycles. - behavior: drain 1 (sibling create 'pending') leaves the delete pending and does NOT call deleteCalendarEvent; drain 2 (sibling create 'done') calls deleteCalendarEvent exactly once.
- behavior: a paired create that ends failed/dead causes the delete to be skipped (original event preserved). - behavior: a paired create that is 'failed'/'dead' causes the delete to be marked failed and never dispatched (original event preserved).
- behavior: two overlapping runOutboxDrain calls dispatch each row exactly once. - behavior: two overlapping runOutboxDrain calls invoke createCalendarEvent exactly once.
- source: `grep -c 'isDraining' apps/api/src/broker/outboxWorker.ts` returns >= 2. - source: `grep -c 'isDraining' apps/api/src/broker/outboxWorker.ts` returns >= 2.
- source: `grep -c 'single-process' apps/api/src/broker/outboxWorker.ts` returns >= 1 (the documented-limitation comment exists).
- test-command: `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts` passes. - test-command: `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts` passes.
</acceptance_criteria> </acceptance_criteria>
<done>The create-before-delete invariant holds across drain cycles and overlapping cycles never double-apply a row.</done> <done>The create-before-delete invariant holds across drain cycles (proven by a two-drain sibling-status test) and overlapping cycles never double-apply a row. The isDraining guard carries an explicit comment that it is single-process-only and that multi-process needs a DB row-claim.</done>
</task> </task>
<task type="tdd" tdd="true"> <task type="tdd" tdd="true">
@@ -112,10 +126,12 @@ gated by querying its sibling create's status, not enqueued as a new enum value)
<read_first> <read_first>
- apps/api/src/broker/outboxWorker.ts (update dispatch lines 158-173; the etag comes from row.etag captured at enqueue time) - apps/api/src/broker/outboxWorker.ts (update dispatch lines 158-173; the etag comes from row.etag captured at enqueue time)
- apps/api/src/db/schema.ts (calendarEvents.etag line 95; calendarEvents.uid line 94) - apps/api/src/db/schema.ts (calendarEvents.etag line 95; calendarEvents.uid line 94)
- apps/api/tests/broker/outboxWorker.test.ts (to make the calendarEvents etag select return 'new-etag' while the pending-rows select returns the update row, use the same per-call `mockImplementationOnce` / where-condition-branch technique introduced in Task 1)
- .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (WR-02) - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (WR-02)
</read_first> </read_first>
<behavior> <behavior>
- RED: an update row carries a stale `etag` ('old-etag'), but calendarEvents has been re-synced to 'new-etag'. Assert updateCalendarEvent is called with 'new-etag' (the freshest value read from calendarEvents at dispatch time), not the row's stale 'old-etag'. Fails today (row.etag is used verbatim). - RED: an update row carries a stale `etag` ('old-etag'), but calendarEvents has been re-synced to 'new-etag'. Mock the calendarEvents etag select to return `[{ etag: 'new-etag' }]`. Assert updateCalendarEvent is called with 'new-etag' (the freshest value read from calendarEvents at dispatch time), not the row's stale 'old-etag'. Fails today (row.etag is used verbatim).
- RED: when the calendarEvents select returns `[]` for the uid, assert updateCalendarEvent falls back to `row.etag`.
</behavior> </behavior>
<action> <action>
In the `operation === 'update'` branch of `dispatchRow`, before calling `updateCalendarEvent`, In the `operation === 'update'` branch of `dispatchRow`, before calling `updateCalendarEvent`,
@@ -149,10 +165,12 @@ gated by querying its sibling create's status, not enqueued as a new enum value)
</verification> </verification>
<success_criteria> <success_criteria>
The outbox is durable (create-before-delete across cycles), non-duplicating (concurrency guard), The outbox is durable (create-before-delete across cycles, proven by a two-drain sibling-status test),
and avoids spurious conflicts (fresh-etag). CR-04, CR-05, WR-02 closed. non-duplicating (concurrency guard, documented single-process-only), and avoids spurious conflicts
(fresh-etag). CR-04, CR-05, WR-02 closed.
</success_criteria> </success_criteria>
<output> <output>
Create `.planning/phases/03-event-write-back-pwa-install/03-11-SUMMARY.md` when done. Create `.planning/phases/03-event-write-back-pwa-install/03-11-SUMMARY.md` when done.
</output> </output>
</content>
@@ -10,11 +10,12 @@ requirements: [CAL-05, CAL-07, PWA-01, PWA-02]
files_modified: files_modified:
- apps/pwa/src/components/EventForm.tsx - apps/pwa/src/components/EventForm.tsx
- apps/pwa/src/components/EventForm.test.tsx - apps/pwa/src/components/EventForm.test.tsx
- apps/pwa/src/store/calendarStore.ts
must_haves: must_haves:
truths: truths:
- "Opening the form in edit mode populates Title/Start/End from the cached occurrence even when the form opens before the occurrence is resolved (no blank edit form)" - "Opening the form in edit mode populates Title/Start/End from the cached occurrence even when the form opens before the occurrence is resolved (no blank edit form)"
- "Editing a recurring event preselects its existing recurrence preset instead of resetting to 'none'" - "Editing a recurring event preselects its existing recurrence preset instead of resetting to 'none'"
- "The edit form shows the event's original date/time consistently (no UTC-date / local-time mismatch that shifts the day)" - "The edit form shows the event's original date/time consistently (no UTC-date / local-time mismatch that shifts the day), proven by a test that pins TZ so it cannot pass by coincidence on an EDT runner"
- "Tab and Shift+Tab cycle focus within the open dialog and never reach background controls" - "Tab and Shift+Tab cycle focus within the open dialog and never reach background controls"
- "The PWA install assets (icon-192/512, apple-touch-icon) exist so Add-to-Home-Screen installs with a real icon (PWA-01/PWA-02)" - "The PWA install assets (icon-192/512, apple-touch-icon) exist so Add-to-Home-Screen installs with a real icon (PWA-01/PWA-02)"
artifacts: artifacts:
@@ -56,47 +57,50 @@ Output: an EventForm that round-trips an existing event's fields and traps focus
</context> </context>
<artifacts_this_phase_produces> <artifacts_this_phase_produces>
No new exported symbols. Internal changes to EventForm: reset effect deps gain No new exported symbols beyond exporting the existing `todayIso` from calendarStore.ts
`occurrence`, a recurrence-deriving initializer, a zone-consistent `parseDateTime`, (see IN-03 below — it is currently a private module function, NOT yet exported). Internal
and a real Tab/Shift+Tab focus-cycle handler. IN-03 (collapse the duplicate changes to EventForm: reset effect deps gain `occurrence`, a recurrence-deriving initializer,
getDefaultStartDate/getDefaultEndDate to one helper) is folded in as a cheap adjacent cleanup. a zone-consistent `parseDateTime`, and a real Tab/Shift+Tab focus-cycle handler.
</artifacts_this_phase_produces> </artifacts_this_phase_produces>
<tasks> <tasks>
<task type="tdd" tdd="true"> <task type="tdd" tdd="true">
<name>Task 1: RED+GREEN — edit-mode population, recurrence derivation, zone-consistent dates (WR-03, WR-05, IN-03)</name> <name>Task 1: RED+GREEN — edit-mode population, recurrence derivation, zone-consistent dates (WR-03, WR-05, IN-03)</name>
<files>apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx</files> <files>apps/pwa/src/components/EventForm.tsx, apps/pwa/src/components/EventForm.test.tsx, apps/pwa/src/store/calendarStore.ts</files>
<read_first> <read_first>
- apps/pwa/src/components/EventForm.tsx (occurrence IIFE lines 113-124; reset effect lines 167-181 with deps [eventFormOpen,eventFormMode,eventFormUid]; parseDateTime lines 84-101; getDefaultStartDate/EndDate lines 47-53) - apps/pwa/src/components/EventForm.tsx (occurrence IIFE lines 113-124; reset effect deps `[eventFormOpen,eventFormMode,eventFormUid]`; parseDateTime lines 84-101 — note it mixes `d.toISOString().slice(0,10)` (UTC date) with `d.getHours()/getMinutes()` (local time): THIS is the WR-05 bug; getDefaultStartDate/getDefaultEndDate lines 47-53)
- apps/pwa/src/api/client.ts (CalendarOccurrence.start/end format note lines 69-73: 'YYYY-MM-DD' for allDay, ISO 8601 with IANA tz for timed) - apps/pwa/src/api/client.ts (CalendarOccurrence.start/end format note lines 69-73: 'YYYY-MM-DD' for allDay, ISO 8601 with IANA tz for timed)
- apps/pwa/src/store/calendarStore.ts (setEventForm/eventFormMode/eventFormUid lines 43-44,162-163; a todayIso helper exists per IN-03) - apps/pwa/src/store/calendarStore.ts (todayIso at lines 121-124 is a PRIVATE module function — it is NOT currently exported; IN-03 requires adding `export` to it before EventForm can import it)
- .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (WR-03, WR-05, IN-03) - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (WR-03, WR-05, IN-03)
</read_first> </read_first>
<behavior> <behavior>
- RED (WR-03 blank): render EventForm in edit mode where the occurrence becomes available in the ['events'] cache AFTER the form opens; assert the Title input value equals the occurrence title (not empty). Fails today because the reset effect deps exclude `occurrence`. - RED (WR-03 blank): render EventForm in edit mode where the occurrence becomes available in the ['events'] cache AFTER the form opens; assert the Title input value equals the occurrence title (not empty). Fails today because the reset effect deps exclude `occurrence`.
- RED (WR-03 recurrence): edit an occurrence whose recurrence is 'weekly'; assert the Repeat select value is 'weekly', not 'none'. - RED (WR-03 recurrence): edit an occurrence whose recurrence is 'weekly'; assert the Repeat select value is 'weekly', not 'none'.
- RED (WR-05 zone): a timed occurrence start of '2026-06-10T23:30:00-04:00[America/Toronto]' renders Start date '2026-06-10' and time '23:30' (the event's own wall time), not a UTC-shifted '2026-06-11'/'03:30'. - RED (WR-05 zone — DETERMINISTIC, TZ-pinned so it cannot pass by coincidence): pin the test runner timezone to UTC for this test file. Use the top-of-file `// @vitest-environment jsdom` already in place, and add `process.env.TZ = 'UTC'` in a `beforeAll` (set BEFORE any Date is constructed in the test) — OR, preferred, add `env: { TZ: 'UTC' }` to the pwa vitest config's `test` block so the runner zone is fixed for the whole suite. State which approach you used in a comment. With TZ pinned to UTC, feed a timed occurrence start of `'2026-06-10T23:30:00-04:00'` (i.e. UTC instant `2026-06-11T03:30:00Z`) and assert the rendered Start date and time equal the event's OWN wall-clock as derived by the fixed extraction rule (see <action>): the test must assert the exact strings the corrected `parseDateTime` produces for that input under TZ=UTC, and document why those values are correct regardless of the developer's machine zone. The point: the assertion is stable on a UTC CI runner AND would fail loudly if `parseDateTime` reverted to the toISOString/getHours mismatch.
</behavior> </behavior>
<action> <action>
WR-03: add `occurrence` (or `occurrence?.uid` plus `occurrence?.start`) to the reset effect dependency WR-03: add `occurrence` (or `occurrence?.uid` plus `occurrence?.start`) to the reset effect dependency
array so the form re-initializes when the occurrence resolves after open. In the reset effect, derive array so the form re-initializes when the occurrence resolves after open. In the reset effect, derive
the initial recurrence from the occurrence instead of always `setRecurrence('none')` — if the the initial recurrence from the occurrence instead of always `setRecurrence('none')` — if the
CalendarOccurrence carries a recurrence preset use it; if the occurrence shape does not expose one, CalendarOccurrence carries a recurrence preset use it; if the occurrence shape does not expose one,
add the preset to the occurrence/expand contract is OUT OF SCOPE — instead read it from the cached extending the occurrence/expand contract is OUT OF SCOPE — read it from the cached raw recurrence if
raw recurrence if present and default to 'none' only when genuinely absent (document the limitation in present and default to 'none' only when genuinely absent (add a comment citing WR-03 documenting that
a comment citing WR-03). Guard against opening edit mode before the cache is populated: keep fields occurrence edits whose recurrence is not present in the cache default to 'none' in v1). Guard against
blank-safe but re-run on arrival. opening edit mode before the cache is populated: keep fields blank-safe but re-run on arrival.
WR-05: rewrite `parseDateTime` so date and time are derived in ONE consistent frame. For a timed ISO WR-05 (the owning fix): rewrite `parseDateTime` so date and time are derived in ONE consistent frame.
with an offset/IANA suffix, compute the local wall-clock components with `getFullYear/getMonth/getDate/ For a timed ISO with an offset/IANA suffix, build the JS Date, then extract BOTH the date and time from
getHours/getMinutes` together (or reuse the Temporal-based conversion the calendar render path already the SAME accessor family — use local accessors together (`getFullYear/getMonth/getDate/getHours/getMinutes`,
uses) — never mix `toISOString().slice(0,10)` (UTC date) with `getHours()` (local time). The all-day zero-padded) so the date string and the time string describe the same wall clock. NEVER mix
`^\d{4}-\d{2}-\d{2}$` branch is unchanged. `toISOString().slice(0,10)` (UTC date) with `getHours()` (local time). Because the WR-05 test pins TZ=UTC,
"local" == UTC in the test and the extracted wall clock is deterministic; in production the user's own
zone yields their own wall clock consistently. The all-day `^\d{4}-\d{2}-\d{2}$` branch is unchanged.
IN-03: collapse `getDefaultStartDate`/`getDefaultEndDate` into one `todayIso()` helper (reuse the IN-03: export the existing `todayIso` from calendarStore.ts (add the `export` keyword to the function at
existing one in calendarStore.ts if exported); keep the separate '09:00'/'10:00' default times at the lines 121-124 — it is currently private), then import it into EventForm and collapse
call sites. `getDefaultStartDate`/`getDefaultEndDate` into calls to `todayIso()`; keep the separate '09:00'/'10:00'
default times at the call sites. Do not duplicate the helper — there must be exactly one `todayIso`.
Commit RED then GREEN. Commit RED then GREEN.
</action> </action>
@@ -106,11 +110,13 @@ getDefaultStartDate/getDefaultEndDate to one helper) is folded in as a cheap adj
<acceptance_criteria> <acceptance_criteria>
- behavior: edit form Title is populated even when occurrence resolves after open. - behavior: edit form Title is populated even when occurrence resolves after open.
- behavior: editing a recurring event preselects its recurrence preset. - behavior: editing a recurring event preselects its recurrence preset.
- behavior: a timed occurrence renders its own wall-clock date and time (no UTC/local day shift). - behavior (deterministic): with the runner TZ pinned to UTC, a timed occurrence `'2026-06-10T23:30:00-04:00'` renders the wall-clock date/time the corrected parseDateTime yields under UTC, and the assertion is hard-coded to those exact strings (cannot pass by a coincidentally-EDT runner).
- source: the reset effect dependency array in EventForm.tsx includes occurrence (grep for occurrence in the deps line). - source: the reset effect dependency array in EventForm.tsx includes occurrence (grep for occurrence in the deps line).
- source: `grep -c 'export function todayIso' apps/pwa/src/store/calendarStore.ts` returns 1 (todayIso is now exported; IN-03).
- source: parseDateTime no longer mixes UTC and local accessors — `grep -c 'toISOString' apps/pwa/src/components/EventForm.tsx` does not appear inside parseDateTime's timed branch (verify by reading the function).
- test-command: `cd apps/pwa && npx vitest run src/components/EventForm.test.tsx` passes. - test-command: `cd apps/pwa && npx vitest run src/components/EventForm.test.tsx` passes.
</acceptance_criteria> </acceptance_criteria>
<done>Edit mode pre-populates correctly (fields, recurrence, correct zone); duplicate date helpers collapsed.</done> <done>Edit mode pre-populates correctly (fields, recurrence, correct zone proven by a TZ-pinned deterministic test); duplicate date helpers collapsed to one exported todayIso.</done>
</task> </task>
<task type="tdd" tdd="true"> <task type="tdd" tdd="true">
@@ -160,11 +166,12 @@ getDefaultStartDate/getDefaultEndDate to one helper) is folded in as a cheap adj
</verification> </verification>
<success_criteria> <success_criteria>
Edit mode pre-populates fields/recurrence in the correct zone, the dialog traps focus, Edit mode pre-populates fields/recurrence in the correct zone (proven by a TZ-pinned deterministic test),
and the PWA install assets are confirmed present. WR-03, WR-05, WR-07, IN-03, IN-04 closed; the dialog traps focus, and the PWA install assets are confirmed present. WR-03, WR-05, WR-07, IN-03, IN-04 closed;
PWA-01/PWA-02 verified. PWA-01/PWA-02 verified.
</success_criteria> </success_criteria>
<output> <output>
Create `.planning/phases/03-event-write-back-pwa-install/03-12-SUMMARY.md` when done. Create `.planning/phases/03-event-write-back-pwa-install/03-12-SUMMARY.md` when done.
</output> </output>
</content>