--- phase: 03-event-write-back-pwa-install reviewed: 2026-06-09T00:00:00Z depth: standard files_reviewed: 29 files_reviewed_list: - apps/api/src/broker/outboxWorker.ts - apps/api/src/broker/sync.ts - apps/api/src/broker/vevent.ts - apps/api/src/broker/write.ts - apps/api/src/db/schema.ts - apps/api/src/index.ts - apps/api/src/routes/events.ts - apps/api/tests/broker/outboxWorker.test.ts - apps/api/tests/broker/vevent.test.ts - apps/api/tests/broker/write.test.ts - apps/api/tests/routes/events.test.ts - apps/pwa/index.html - apps/pwa/package.json - apps/pwa/src/api/client.test.ts - apps/pwa/src/api/client.ts - apps/pwa/src/components/CalendarShell.tsx - apps/pwa/src/components/DeleteConfirmationDialog.test.tsx - apps/pwa/src/components/DeleteConfirmationDialog.tsx - apps/pwa/src/components/EventDetailPopover.test.tsx - apps/pwa/src/components/EventDetailPopover.tsx - apps/pwa/src/components/EventForm.test.tsx - apps/pwa/src/components/EventForm.tsx - apps/pwa/src/components/InstallPrompt.test.tsx - apps/pwa/src/components/InstallPrompt.tsx - apps/pwa/src/components/SyncStateToast.test.tsx - apps/pwa/src/components/SyncStateToast.tsx - apps/pwa/src/store/calendarStore.ts - apps/pwa/vite.config.ts - apps/pwa/vitest.config.ts findings: critical: 0 warning: 2 info: 2 total: 4 status: issues_found --- # Phase 3: Code Review Report (Re-Review, Iteration 3 — final --auto pass) **Reviewed:** 2026-06-09 **Depth:** standard **Files Reviewed:** 29 **Status:** issues_found (no blockers — remaining items are accepted v1 limitations) ## Summary Final re-review of the event write-back + PWA-install phase after the iteration-2 fix pass. I traced each iteration-2 fix end-to-end against its implementation and tests. All iteration-2 fixes are correct and introduce no regressions. The prior BLOCKER (CR-01: edit-as-move strips the RRULE) is now **resolved and correct**. ### Iteration-2 fixes — verified - **Move-path RRULE forwarding (CR-01) — VERIFIED FIXED.** `events.ts` now selects `rawVevent` in the edit lookup (events.ts:338) and, in the move branch, extracts the source RRULE and stashes it as `_preservedRrule` on the create payload **only when the edit carried no explicit recurrence** (events.ts:388-395). The worker create branch reads it back: `hasExplicitRecurrence` is computed via `hasOwnProperty(fields,'recurrence')` (outboxWorker.ts:336), and `rruleString` resolves to `preservedRrule ?? rruleFromPayload` only when there is no explicit recurrence (outboxWorker.ts:341-353). The two sides agree: an EDIT omits `recurrence`, so `hasExplicitRecurrence=false` and the stashed RRULE is applied; an explicit `recurrence` (including `'none'`) still wins. `JSON.stringify` on the move payload drops the absent `recurrence` key, so `hasOwnProperty` is correctly `false` after the round-trip. Covered by events.test.ts:422-469 (route stashes RRULE) and outboxWorker.test.ts:311-358 (worker re-applies; explicit `'none'` still emits no RRULE). No regression to the same-calendar `update` preserve path (outboxWorker.ts:281-285). - **Outbox payload re-validation (IN-03) — VERIFIED FIXED.** Both the `update` and `create` branches parse the stored JSON, then `outboxPayloadSchema.safeParse` it (outboxWorker.ts:231-235, 323-327). A schema-invalid row is hard-failed (no retry, no CalDAV dispatch). The schema mirrors `eventFieldsSchema` and uses `.passthrough()` so `_preservedRrule` survives validation (outboxWorker.ts:70-82). Covered by outboxWorker.test.ts:288-306 (missing title → hard-fail, never dispatched). - **Sync-status failed-row ranking (WR-04) — VERIFIED FIXED.** `sync-status` orders by a status-priority CASE (`failed`/`dead`=0, `pending`=1, else=2) then `createdAt DESC` (events.ts:549-552), so an earlier failed/dead row for a uid outranks a later `done` row. Covered by events.test.ts:556-589, which also asserts the CASE expression is present in the ORDER BY chunks. - **Helper-text / all-day toggle clamp (WR-01/WR-02 UI) — VERIFIED FIXED.** The recurrence `` is hard-disabled on edit and the payload always omits `recurrence` on edit (EventForm.tsx:367). The server treats an absent `recurrence` as "preserve the stored RRULE" (both the same-calendar update and the move path). The consequence is that a user can never (a) change a recurring event's frequency, nor (b) intentionally make a recurring event non-recurring — there is no way to express "remove the RRULE" through the edit form, because "omit recurrence" is reserved to mean "unchanged." Helper text now surfaces the constraint (EventForm.tsx:789-800), which is the iteration-2 mitigation, so this is a documented v1 scope cut rather than a silent trap. Recorded because the overloaded semantics will need disentangling when recurrence editing ships (a sentinel distinct from "omitted" will be required to express "remove"). **Fix:** When recurrence editing lands, introduce an explicit "remove recurrence" signal distinct from an omitted field (e.g. `recurrence: 'none'` already overrides — wire the edit form to send it when the user clears the schedule), and parse-and-modify the stored RECUR in place rather than replacing it with a bare preset (see IN-01). ## Info ### IN-01: `RRULE_PRESETS` round-trip is lossy for any parameterized RRULE **File:** `apps/api/src/broker/vevent.ts:49-54`; `apps/api/src/broker/outboxWorker.ts:245-248, 337-340` **Issue:** `RRULE_PRESETS` maps only to bare `FREQ=DAILY|WEEKLY|MONTHLY|YEARLY`. `extractRruleString` correctly preserves the full stored RECUR (which may carry `BYDAY`/`INTERVAL`/`COUNT`/`UNTIL`), and both preserve paths keep that rich rule. But if a `recurrence` preset value is ever applied to a previously-rich rule, it collapses the rule to the bare preset — silently dropping qualifiers. This cannot happen in v1 (the picker offers only the four bare presets and is disabled on edit), so it is latent, not active. The limitation is now documented at the `RRULE_PRESETS` definition (vevent.ts:39-48). **Fix:** When recurrence editing ships, parse the existing RECUR and modify it in place instead of replacing it with a preset. ### IN-02: Move-path RRULE preservation depends silently on `rawVevent` being non-empty **File:** `apps/api/src/routes/events.ts:388-391` **Issue:** In the move branch, `preservedRrule = payload.recurrence === undefined ? extractRruleString(eventRow.rawVevent ?? '') : undefined`. If `eventRow.rawVevent` is ever null/empty (it is selected at events.ts:338 and `calendar_events.rawVevent` is `notNull` per schema.ts:106, so this is not currently reachable), `extractRruleString('')` returns `undefined` and the move silently drops the RRULE with no diagnostic. The schema NOT NULL constraint makes this safe today; the fragility is that the preserve path has no observability if that invariant ever changes (unlike write.ts:75-77 which logs the analogous no-etag gap). **Fix:** Optional — log a warning when a move with no explicit recurrence finds no extractable RRULE on a recurring-looking source, so a future schema/contract change that empties `rawVevent` is diagnosable rather than silent. --- _Reviewed: 2026-06-09_ _Reviewer: Claude (gsd-code-reviewer)_ _Depth: standard_