docs(06): add phase verification report, patterns map, and iter2 review/fix

This commit is contained in:
Lucas Berger
2026-06-10 17:22:34 -04:00
parent e805585770
commit 756e2b86ad
4 changed files with 1376 additions and 0 deletions
@@ -0,0 +1,726 @@
# Phase 6: UX Polish — Pattern Map
**Mapped:** 2026-06-10
**Files analyzed:** 14 (all modifications to existing files; 0 net-new source files)
**Analogs found:** 14 / 14 — every touch point has a direct in-repo exemplar
---
## File Classification
| Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `apps/pwa/src/lib/eventDateTime.ts` | utility | transform | self (extend existing) | exact |
| `apps/pwa/src/lib/eventDateTime.test.ts` | test | transform | self (extend existing) | exact |
| `apps/pwa/src/components/EventForm.tsx` | component | request-response | self (extend existing) | exact |
| `apps/pwa/src/api/client.ts` | service | request-response | self (extend existing) | exact |
| `apps/pwa/src/api/client.test.ts` | test | request-response | `apps/pwa/src/lib/loginRedirect.test.ts` | role-match |
| `apps/pwa/src/components/CalendarShell.tsx` | component | request-response | self (extend existing) | exact |
| `apps/pwa/src/lib/loginRedirect.ts` | utility | request-response | self (reference only) | exact |
| `apps/pwa/src/styles/tokens.css` | config | — | self (extend existing) | exact |
| `apps/pwa/src/components/PushPermissionPrompt.tsx` | component | — | self (remove redundancy) | exact |
| `apps/api/src/broker/expand.ts` | service | transform | self (extend existing) | exact |
| `apps/api/src/broker/vevent.ts` | service | transform | self (extend existing) | exact |
| `apps/api/src/broker/outboxWorker.ts` | service | CRUD | self (extend existing) | exact |
| `apps/api/tests/broker/vevent.test.ts` | test | transform | self (extend existing) | exact |
| `apps/api/tests/broker/expand.test.ts` | test | transform | self (extend existing) | exact |
---
## Pattern Assignments
### `apps/pwa/src/lib/eventDateTime.ts` (utility, transform) — D-04
**Change:** Add `computeNewTimedEnd` and `computeNewAllDayEnd` pure functions for end-tracking math.
**Analog:** same file — mirrors the existing `serializeEventDateTime` / `localWallClockToUtcIso` pattern exactly.
**Existing function signature pattern** (`eventDateTime.ts:3351`):
```typescript
export function serializeEventDateTime(
allDay: boolean,
startDate: string,
startTime: string,
endDate: string,
endTime: string,
): { start: string; end: string } {
if (allDay) {
return { start: startDate, end: endDate }
}
return {
start: localWallClockToUtcIso(startDate, startTime),
end: localWallClockToUtcIso(endDate, endTime),
}
}
```
**Local accessor pattern** (`eventDateTime.ts:5961`):
```typescript
export function localWallClockToUtcIso(date: string, time: string): string {
return new Date(`${date}T${time}:00`).toISOString()
}
```
**New functions to add** (copy the export + JSDoc style; use `new Date(...)` arithmetic inline — no third-party date lib):
```typescript
/** Preserve timed-event duration on start change. Returns new { endDate, endTime }. */
export function computeNewTimedEnd(
newStartDate: string,
newStartTime: string,
oldStartDate: string,
oldStartTime: string,
oldEndDate: string,
oldEndTime: string,
): { endDate: string; endTime: string } {
const oldStartMs = new Date(`${oldStartDate}T${oldStartTime}:00`).getTime()
const oldEndMs = new Date(`${oldEndDate}T${oldEndTime}:00`).getTime()
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000 // 1h floor
const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs)
return {
endDate: localDateISO(newEndDate), // helper below — local accessors only (WR-05 contract)
endTime: localTimeHHMM(newEndDate),
}
}
/** Preserve all-day day-span on start date change. Returns new endDate (inclusive). */
export function computeNewAllDayEnd(
newStartDate: string,
oldStartDate: string,
oldEndDate: string,
): string {
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))
return addDaysISO(newStartDate, span)
}
```
**WR-05 constraint:** Date helpers MUST use local accessors (`getFullYear/getMonth/getDate/getHours/getMinutes`), never `toISOString().slice(0,10)` — that returns UTC date not local. See `parseDateTime` lines 107133 for the established pattern.
---
### `apps/pwa/src/lib/eventDateTime.test.ts` (test, transform) — D-04
**Change:** Extend with `computeNewTimedEnd` / `computeNewAllDayEnd` / floor-rule test cases.
**Analog:** same file — copy the `describe/it/expect` Vitest structure at lines 1853.
**Test structure to mirror** (`eventDateTime.test.ts:1853`):
```typescript
import { describe, it, expect } from 'vitest'
import { serializeEventDateTime, localWallClockToUtcIso } from './eventDateTime.js'
describe('serializeEventDateTime (BUG A — write-path TZ)', () => {
it('serializes a timed start to a UTC instant (ends in Z)', () => {
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00')
expect(start.endsWith('Z')).toBe(true)
})
// ...
})
```
New `describe` block to add alongside:
```typescript
describe('computeNewTimedEnd (D-04 — end-tracking)', () => {
it('preserves a 1-hour timed delta', () => { ... })
it('preserves a multi-day timed delta', () => { ... })
it('floors to 1h when old end was already behind old start', () => { ... })
})
describe('computeNewAllDayEnd (D-04 — all-day end-tracking)', () => {
it('preserves a 0-day span (single day)', () => { ... })
it('preserves a 3-day span', () => { ... })
})
```
---
### `apps/pwa/src/components/EventForm.tsx` (component, request-response) — D-04, D-06, D-07, D-08
**Changes:**
1. Replace bare `onChange` on start date/time inputs with handlers that call `computeNewTimedEnd`/`computeNewAllDayEnd` (D-04).
2. Add `recurrenceBound: 'never'|'until'|'count'`, `recurrenceUntil: string`, `recurrenceCount: number` state and bound-type UI (D-06).
3. Add `hasRrule`-gated series-edit confirmation trigger (D-08).
**Exact bug sites (lines to replace):**
Start date `onChange` — line 671:
```typescript
// CURRENT (bug):
onChange={(e) => setStartDate(e.target.value)}
// REPLACE WITH a handler that also calls computeNewTimedEnd / computeNewAllDayEnd
onChange={(e) => {
const newStart = e.target.value
if (allDay) {
setEndDate(computeNewAllDayEnd(newStart, startDate, endDate))
} else {
const { endDate: ed, endTime: et } = computeNewTimedEnd(newStart, startTime, startDate, startTime, endDate, endTime)
setEndDate(ed)
setEndTime(et)
}
setStartDate(newStart)
}}
```
Start time `onChange` — line 683 (same pattern but updates from time change):
```typescript
// CURRENT (bug):
onChange={(e) => setStartTime(e.target.value)}
// REPLACE WITH handler that also recomputes end (timed only; allDay has no time)
```
**Existing state declaration pattern to add new recurrence state alongside** (`EventForm.tsx:201207`):
```typescript
const [startDate, setStartDate] = useState(initStart.date)
const [startTime, setStartTime] = useState(initStart.time)
const [endDate, setEndDate] = useState(initEndDate)
const [endTime, setEndTime] = useState(initEnd.time)
const [recurrence, setRecurrence] = useState<RecurrencePreset>('none')
// ADD:
const [recurrenceBound, setRecurrenceBound] = useState<'never'|'until'|'count'>('never')
const [recurrenceUntil, setRecurrenceUntil] = useState('')
const [recurrenceCount, setRecurrenceCount] = useState(1)
```
**Existing useEffect reset pattern to extend** (`EventForm.tsx:232258`):
The effect at lines 232262 already resets all state when the form opens. Extend it to reset the three new recurrence-bound state variables to their defaults. Use the same `setRecurrence(derivedRecurrence ?? 'none')` pattern at line 258 as the model.
**Payload construction pattern** (the submit handler already at bottom of file builds `CreateEventPayload`):
```typescript
// Existing pattern — extend it:
const payload: CreateEventPayload = {
title,
allDay,
start: serialized.start,
end: serialized.end,
...(isEditMode ? {} : { recurrence }),
...(location ? { location } : {}),
...(description ? { description } : {}),
...(calendarUrl ? { calendarUrl } : {}),
// ADD (D-06):
...(recurrence !== 'none' && recurrenceBound === 'until' && recurrenceUntil
? { recurrenceUntil }
: {}),
...(recurrence !== 'none' && recurrenceBound === 'count' && recurrenceCount >= 1
? { recurrenceCount }
: {}),
}
```
---
### `apps/pwa/src/api/client.ts` (service, request-response) — D-06, D-11
**Changes:**
1. Add `recurrenceUntil?` and `recurrenceCount?` to `CreateEventPayload` (D-06).
2. Add `hasRrule: boolean` to `CalendarOccurrence` (D-08).
3. Add typed `SessionExpiredError` class (D-11).
4. Add `redirect: 'manual'` + opaqueredirect/401 detection to ALL fetch functions (D-11).
**Existing `CreateEventPayload` type to extend** (`client.ts:136148`):
```typescript
export interface CreateEventPayload {
title: string
allDay: boolean
start: string
end: string
recurrence?: RecurrencePreset
location?: string
description?: string
calendarUrl?: string
// ADD (D-06):
recurrenceUntil?: string // 'YYYY-MM-DD' — maps to RRULE UNTIL; undefined = no bound
recurrenceCount?: number // integer >= 1 — maps to RRULE COUNT; undefined = no bound
}
```
**Existing `CalendarOccurrence` interface to extend** (`client.ts:7191`):
```typescript
export interface CalendarOccurrence {
// ... all existing fields ...
description: string | null
// ADD (D-08):
hasRrule: boolean // true when this occurrence belongs to a recurring series
}
```
**Existing auth detection pattern to generalize** (`client.ts:3653``fetchMe`):
```typescript
// CURRENT — only in fetchMe:
const res = await fetch('/api/me', {
credentials: 'include',
redirect: 'manual', // ← only fetchMe has this
})
if (res.type === 'opaqueredirect' || res.status === 401) {
throw new Error('GET /api/me: authentication required') // ← untyped
}
// TARGET — typed error class + helper used by ALL fetch functions:
export class SessionExpiredError extends Error {
readonly name = 'SessionExpiredError'
constructor() {
super('Session expired — re-authentication required')
Object.setPrototypeOf(this, SessionExpiredError.prototype)
}
}
function handleAuthResponse(res: Response, label: string): void {
if (res.type === 'opaqueredirect' || res.status === 401) {
throw new SessionExpiredError()
}
if (!res.ok) throw new Error(`${label} failed: ${res.status}`)
}
```
Every fetch function (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`) gains `redirect: 'manual'` + `handleAuthResponse(res, 'GET/POST/... /api/...')`. Mirror the `fetchMe` call structure exactly.
---
### `apps/pwa/src/components/CalendarShell.tsx` (component, request-response) — D-10, D-11
**Changes:**
1. Replace the `meQuery.isError` "Sign-in required" branch (lines 220235) with an `AuthSplash` component render (D-10).
2. Add `meQuery.isLoading` early-return with `AuthSplash` so no skeleton paints before auth (D-10).
3. Wire the Zustand `sessionExpired` flag to an `AuthSplash` render above the main tree (D-11).
**Current "Sign-in required" branch to replace** (`CalendarShell.tsx:220235`):
```tsx
// CURRENT — remove this entire block:
if (meQuery.isError) {
return (
<div
role="alert"
style={{
color: 'var(--color-destructive)',
padding: 'var(--space-4)',
...
}}
>
Sign-in required
</div>
)
}
// REPLACE WITH:
if (meQuery.isLoading) {
return <AuthSplash state="loading" />
}
if (meQuery.isError) {
// useEffect at line 197 calls maybeRedirectToLogin() — splash shows while redirect fires
return <AuthSplash state="redirecting" />
}
```
**Existing useEffect pattern for auth redirect to keep** (`CalendarShell.tsx:196208`):
```typescript
// Keep these two effects — they handle the one-shot guard correctly:
useEffect(() => {
if (meQuery.isError) {
maybeRedirectToLogin()
}
}, [meQuery.isError])
useEffect(() => {
if (meQuery.isSuccess) {
clearLoginRedirect()
}
}, [meQuery.isSuccess])
```
**Existing import pattern to extend** (`CalendarShell.tsx:4456`):
```typescript
import { fetchMe, fetchEvents } from '../api/client.js'
import { maybeRedirectToLogin, clearLoginRedirect } from '../lib/loginRedirect.js'
// ADD:
import { SessionExpiredError } from '../api/client.js'
import { AuthSplash } from './AuthSplash.js' // new component
```
**D-11 TanStack Query v5 global error handler** — wire in `App.tsx` (or wherever `QueryClient` is created), NOT in `CalendarShell`. Pattern is `queryClient.getQueryCache().subscribe(...)` and `queryClient.getMutationCache().subscribe(...)`. Planner must verify exact TanStack Query v5 API via Context7 before coding. The semantic intent:
```typescript
queryClient.getQueryCache().subscribe((event) => {
if (event.type === 'error' && event.error instanceof SessionExpiredError) {
setSessionExpired(true) // Zustand flag
}
})
queryClient.getMutationCache().subscribe((event) => {
if (event.type === 'error' && event.error instanceof SessionExpiredError) {
setSessionExpired(true)
}
})
```
---
### `apps/pwa/src/lib/loginRedirect.ts` (utility) — D-11
**No changes to the file itself.** The existing `maybeRedirectToLogin()` and `clearLoginRedirect()` functions are reused as-is. The D-11 session-expiry path must call `clearLoginRedirect()` BEFORE calling `maybeRedirectToLogin()` so the one-shot guard fires fresh. This is already the pattern for the `meQuery.isSuccess` path at `CalendarShell.tsx:205`.
**Reference** (`loginRedirect.ts:2845`):
```typescript
export function maybeRedirectToLogin(): boolean {
if (typeof window === 'undefined') return false
try {
if (sessionStorage.getItem(LOGIN_REDIRECT_KEY) !== null) return false
sessionStorage.setItem(LOGIN_REDIRECT_KEY, '1')
window.location.href = '/api/login'
return true
} catch { return false }
}
export function clearLoginRedirect(): void {
try { sessionStorage.removeItem(LOGIN_REDIRECT_KEY) } catch { }
}
```
---
### `apps/pwa/src/styles/tokens.css` (config) — D-13
**Change:** Add missing `@keyframes pulse`. Remove nothing (the `@keyframes spin` at lines 140147 stays).
**Existing `@keyframes spin` to copy the CSS pattern from** (`tokens.css:140147`):
```css
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
```
**Add directly after it:**
```css
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
```
**Also add `@keyframes shimmer`** is already present at lines 131138. Follow the same format (no vendor prefixes, no `animation-fill-mode` in the keyframe block itself).
---
### `apps/pwa/src/components/PushPermissionPrompt.tsx` (component) — D-13
**Change:** Remove the redundant local `<style>` block at lines 358363. No other changes.
**Block to delete** (`PushPermissionPrompt.tsx:357363`):
```tsx
{/* Spin animation for loader */}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
```
The `tokens.css` global definition (loaded via `main.tsx``index.css``@import './tokens.css'`) already covers this. The `animation: 'spin 1s linear infinite'` inline style in this component continues to work unchanged.
---
### `apps/api/src/broker/expand.ts` (service, transform) — D-08
**Change:** Add `hasRrule: boolean` to `CalendarOccurrence` interface and populate it in `expandOccurrences`.
**Interface to extend** (`expand.ts:3767`):
```typescript
export interface CalendarOccurrence {
id: string
uid: string
calendarId: number
// ... all existing fields ...
description: string | null
// ADD:
hasRrule: boolean // true when this event has an RRULE (recurring series)
}
```
**Population pattern** — in `expandOccurrences`, `event.isRecurring()` is already called at line 223 for the non-recurring branch. Capture it once before the branch, then pass to each `occurrences.push(...)`:
```typescript
// BEFORE the branch at line 223:
const isRecurring = event.isRecurring()
// In the non-recurring push at lines 241256 — add:
occurrences.push({
// ... existing fields ...
hasRrule: isRecurring, // always false here — non-recurring branch
})
// In the recurring push at lines 287302 — add:
occurrences.push({
// ... existing fields ...
hasRrule: isRecurring, // always true here — recurring branch
})
```
**Existing push pattern to mirror** (`expand.ts:241257`):
```typescript
occurrences.push({
id: makeOccurrenceId(uid, dtstart),
uid,
calendarId,
calendarName,
ownerUserId,
ownerName,
color,
isShared,
title: event.summary ?? '',
start,
end,
allDay,
location: event.location ?? null,
description: event.description ?? null,
// ADD: hasRrule: isRecurring
})
```
---
### `apps/api/src/broker/vevent.ts` (service, transform) — D-06
**Change:** Extend RRULE serialization to support `UNTIL` (DATE and DATETIME forms) and `COUNT`. No interface changes to `NewEventParams` are strictly required — callers assemble the `rruleString` before passing it. The assembly logic lives in `outboxWorker.ts`.
**Existing RRULE serialization pattern to keep unchanged** (`vevent.ts:143148`):
```typescript
// This pattern handles any valid RRULE string — UNTIL/COUNT included:
if (params.rruleString) {
const recur = ICAL.Recur.fromString(params.rruleString)
const rruleProp = new ICAL.Property('rrule')
rruleProp.setValue(recur)
vevent.addProperty(rruleProp)
}
```
**`RRULE_PRESETS` map to keep unchanged** (`vevent.ts:4954`):
```typescript
export const RRULE_PRESETS: Record<string, string> = {
daily: 'FREQ=DAILY',
weekly: 'FREQ=WEEKLY',
monthly: 'FREQ=MONTHLY',
yearly: 'FREQ=YEARLY',
}
```
The UNTIL/COUNT string assembly happens in `outboxWorker.ts` (see below). `buildVeventString` receives the complete `rruleString` and serializes it correctly via `ICAL.Recur.fromString` — verified in RESEARCH.md.
---
### `apps/api/src/broker/outboxWorker.ts` (service, CRUD) — D-06
**Changes:**
1. Read `recurrenceUntil` and `recurrenceCount` from the validated payload.
2. Add `assembleRruleString` helper that appends `;COUNT=N` or `;UNTIL=YYYYMMDD[T235959Z]` to the base preset string.
3. On series edit, when only the bound changes (no new `recurrence` preset), parse the preserved RRULE and add/replace the bound modifier.
**Existing `outboxPayloadSchema` to extend** (`outboxWorker.ts:7183`):
```typescript
const outboxPayloadSchema = z
.object({
title: z.string().min(1).max(255),
allDay: z.boolean(),
start: z.string().min(1).max(64),
end: z.string().min(1).max(64),
location: z.string().max(2000).optional(),
description: z.string().max(2000).optional(),
recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(),
calendarUrl: z.string().url().max(1024).optional(),
_preservedRrule: z.string().max(1024).optional(),
// ADD (D-06):
recurrenceUntil: z.string().max(10).optional(), // 'YYYY-MM-DD'
recurrenceCount: z.number().int().min(1).optional(),
})
.passthrough()
```
**Existing RRULE assembly site to extend** (`outboxWorker.ts:255308`):
```typescript
// Existing (keep):
const hasExplicitRecurrence = Object.prototype.hasOwnProperty.call(fields, 'recurrence')
const rruleFromPayload =
fields.recurrence && fields.recurrence !== 'none'
? RRULE_PRESETS[fields.recurrence as string]
: undefined
// ADD: assemble final rruleString with optional bound modifier
function assembleRruleString(
basePreset: string, // e.g. 'FREQ=WEEKLY' from RRULE_PRESETS
until?: string, // 'YYYY-MM-DD'
count?: number,
allDay?: boolean,
): string {
let s = basePreset
if (count !== undefined) {
s += `;COUNT=${count}`
} else if (until) {
if (allDay) {
s += `;UNTIL=${until.replace(/-/g, '')}` // DATE form: 20260630
} else {
s += `;UNTIL=${until.replace(/-/g, '')}T235959Z` // DATETIME UTC: 20260630T235959Z
}
}
return s
}
// Then pass to buildVeventString:
const finalRruleString = hasExplicitRecurrence && rruleFromPayload
? assembleRruleString(rruleFromPayload, fields.recurrenceUntil, fields.recurrenceCount, fields.allDay)
: (preservedRrule
// Series edit with bound change only: parse + modify preserved RRULE
? (fields.recurrenceUntil || fields.recurrenceCount !== undefined
? assembleRruleString(
// Strip any existing UNTIL/COUNT from the preserved rule first
preservedRrule.replace(/;(UNTIL|COUNT)=[^;]*/g, ''),
fields.recurrenceUntil,
fields.recurrenceCount,
fields.allDay,
)
: preservedRrule)
: rruleFromPayload)
```
---
### `apps/api/tests/broker/vevent.test.ts` (test, transform) — D-06
**Change:** Add test cases for UNTIL (DATE form), UNTIL (DATETIME UTC form), COUNT.
**Existing test structure to extend** (`vevent.test.ts:2260`):
```typescript
import { describe, it, expect } from 'vitest'
import { buildVeventString } from '../../src/broker/vevent.js'
describe('buildVeventString', () => {
it('produces a VCALENDAR string containing a VEVENT for a timed event', () => {
const result = buildVeventString({ summary: 'Team standup', allDay: false, ... })
expect(result.icsString).toContain('RRULE:')
})
```
Add alongside existing cases:
```typescript
it('serializes COUNT in RRULE for a timed event', () => {
const result = buildVeventString({
summary: 'Weekly',
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
rruleString: 'FREQ=WEEKLY;COUNT=5',
})
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;COUNT=5')
})
it('serializes UNTIL as DATE form for all-day events', () => {
const result = buildVeventString({
summary: 'Daily standup',
allDay: true,
dtstart: '2026-06-10',
dtend: '2026-06-11',
rruleString: 'FREQ=DAILY;UNTIL=20260630',
})
expect(result.icsString).toContain('RRULE:FREQ=DAILY;UNTIL=20260630')
expect(result.icsString).not.toContain('T235959Z')
})
it('serializes UNTIL as DATETIME UTC form for timed events', () => {
const result = buildVeventString({
summary: 'Weekly',
allDay: false,
dtstart: new Date('2026-06-10T09:00:00Z'),
dtend: new Date('2026-06-10T10:00:00Z'),
rruleString: 'FREQ=WEEKLY;UNTIL=20260630T235959Z',
})
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z')
})
```
---
### `apps/api/tests/broker/expand.test.ts` (test, transform) — D-06, D-08
**Change:** Add `hasRrule` assertions to existing recurring expansion tests; add bounded RRULE test case.
**Existing test file** (`tests/broker/expand.test.ts`) — add assertions to any test that calls `expandOccurrences` with a recurring event:
```typescript
// Pattern: each occurrence in the result must have hasRrule set
const occs = expandOccurrences(rawVevent, windowStart, windowEnd, ...)
expect(occs[0].hasRrule).toBe(true) // recurring event
// For non-recurring:
expect(occs[0].hasRrule).toBe(false)
// For bounded RRULE: correct occurrence count
const boundedRrule = 'FREQ=WEEKLY;COUNT=3'
// inject into rawVevent → expandOccurrences → length === 3 within a wide window
```
---
## Shared Patterns
### Auth detection — `redirect: 'manual'` + typed error
**Source:** `apps/pwa/src/api/client.ts` lines 3653 (`fetchMe`)
**Apply to:** All fetch functions in `client.ts` (D-11)
The existing `fetchMe` pattern is the model for generalizing:
```typescript
const res = await fetch('/api/me', {
credentials: 'include',
redirect: 'manual', // ← add to every fetch call
})
if (res.type === 'opaqueredirect' || res.status === 401) {
throw new Error('GET /api/me: authentication required') // ← replace with SessionExpiredError
}
if (!res.ok) {
throw new Error(`GET /api/me failed: ${res.status}`)
}
```
### One-shot login redirect
**Source:** `apps/pwa/src/lib/loginRedirect.ts` lines 2864
**Apply to:** `CalendarShell.tsx` auth splash (D-10), session-expiry interstitial (D-11)
Rule: always call `clearLoginRedirect()` before `maybeRedirectToLogin()` in the D-11 (session-expiry) path so the guard fires fresh. The D-10 (cold load) path keeps the existing `useEffect` pattern unchanged.
### Zod schema extension
**Source:** `apps/api/src/broker/outboxWorker.ts` lines 7183 (`outboxPayloadSchema`)
**Apply to:** `outboxPayloadSchema` (D-06) AND `eventFieldsSchema` in `apps/api/src/routes/events.ts` (same two new fields must be added to the route-level schema)
Mirror the `.optional()` pattern already used for `location`, `description`, `calendarUrl`.
### ICS RRULE serialization — `ICAL.Recur.fromString` + `rruleProp.setValue`
**Source:** `apps/api/src/broker/vevent.ts` lines 143148
**Apply to:** All RRULE assembly in `outboxWorker.ts` (D-06)
Never concatenate raw RRULE strings into the ICS via `addPropertyWithValue('rrule', string)` — that serializes character-by-character. Always go through `ICAL.Recur.fromString(rruleString)` + `rruleProp.setValue(recur)`.
### CalendarOccurrence interface atomicity
**Source:** `apps/api/src/broker/expand.ts:3767` (server) + `apps/pwa/src/api/client.ts:7191` (client)
**Apply to:** `hasRrule` addition (D-08)
Both interfaces are mirrored manually (no codegen). Update them in the same commit. The server `expand.ts` type is the source of truth; `client.ts` is the consumer mirror. Pitfall 4 in RESEARCH.md documents this.
### Pure-function test structure
**Source:** `apps/pwa/src/lib/eventDateTime.test.ts` lines 1553
**Apply to:** New `computeNewTimedEnd` / `computeNewAllDayEnd` tests (D-04)
Copy the `import { describe, it, expect } from 'vitest'` header and `describe('...', () => { it('...', () => { ... }) })` structure exactly. Tests run with `cd apps/pwa && pnpm test -- lib/eventDateTime`.
---
## No Analog Found
All touched files have direct in-repo analogs. One net-new component is implied:
| Implied New File | Role | Data Flow | Reason |
|---|---|---|---|
| `apps/pwa/src/components/AuthSplash.tsx` | component | request-response | No existing full-screen auth splash component; closest analog is `SkeletonCalendar.tsx` (full-screen centered loading state) |
**AuthSplash analog:** `apps/pwa/src/components/SkeletonCalendar.tsx` — a full-screen centered loading component. Copy its layout structure and inline-style approach. The `AuthSplash` variant renders "Signing you in…" (state="loading") or "Your session expired — signing you back in…" (state="redirecting") with the existing `spin` animation token.
---
## Metadata
**Analog search scope:** `apps/pwa/src/`, `apps/api/src/broker/`, `apps/api/tests/broker/`
**Files read:** 14 source files + 2 test files
**Pattern extraction date:** 2026-06-10
@@ -0,0 +1,123 @@
---
phase: 06-ux-polish
fixed_at: 2026-06-10T20:56:11Z
review_path: .planning/phases/06-ux-polish/06-REVIEW.md
iteration: 1
findings_in_scope: 15
fixed: 13
skipped: 2
status: partial
---
# Phase 6: Code Review Fix Report
**Fixed at:** 2026-06-10T20:56:11Z
**Source review:** .planning/phases/06-ux-polish/06-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (fix_scope=all — CR + WR + IN)
- Fixed: 13
- Skipped: 2
Two findings (WR-06, WR-08) are committed but flagged **requires human verification** — they change runtime behavior (a timeout race / a parse-acceptance predicate) that syntax checks cannot confirm semantically.
Verification note: the isolated worktree has no `node_modules`, so a full `tsc --noEmit` was not possible. Each edited TS/TSX file was syntax-validated with the TypeScript compiler API (`ts.transpileModule`, transpile-only) using the main repo's typescript@5.9.3. The ICS fixture (IN-06) was validated by parsing it through ical.js@2.2.1 and confirming the unfolded DESCRIPTION matches the original and the RRULE still parses.
## Fixed Issues
### CR-01: `recurrenceUntil` not validated as a date before RRULE splice
**Files modified:** `apps/api/src/routes/events.ts`, `apps/api/src/broker/outboxWorker.ts`
**Commit:** d101aa8
**Applied fix:** Replaced `z.string().max(10).optional()` with `z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()` in both `eventFieldsSchema` (route ingress) and `outboxPayloadSchema` (re-parse from stored JSON). With the regex enforced, `until.replace(/-/g,'')` is guaranteed digits-only, closing the `;`-delimited RRULE-part injection vector. Defense-in-depth applied at both boundaries.
### WR-01: All-day non-recurring events over-selected by the date-window SQL filter
**Files modified:** `apps/api/src/routes/events.ts`
**Commit:** eb00ec7
**Applied fix:** Added `sql\`${calendarEvents.hasRrule} = 0\`` to the all-day `and(...)` branch so a recurring all-day master whose `dtstartDate` lands in the window is no longer matched twice (it is already carried by the recurring branch), eliminating duplicate on-the-wire occurrences.
### WR-02: EventForm silently creates an unbounded series when "On date" is selected but blank / WR-07: fragile string comparison
**Files modified:** `apps/pwa/src/components/EventForm.tsx`
**Commit:** 5724fe8
**Applied fix:** Restructured the `recurrenceBound === 'until'` validation: a blank `recurrenceUntil` now sets `newErrors.recurrenceBound = 'Choose an end date'` (WR-02), and the bound-before-start lexicographic compare is now guarded on a non-empty `startDate` so `recurrenceUntil < ''` can no longer silently skip the check (WR-07). Both findings live in the same conditional, so they were fixed and committed together.
### WR-03: `recurrenceCount` number input can produce NaN/0 state
**Files modified:** `apps/pwa/src/components/EventForm.tsx`
**Commit:** ac0f8d2
**Applied fix:** `onChange` now uses `parseInt(value, 10)` + `Number.isFinite` guard, coercing non-finite intermediates to `0`. The validate guard was hardened to `!Number.isInteger(recurrenceCount) || recurrenceCount < 1` so a `NaN` count is caught instead of bypassing both the validation gate and the payload spread.
### WR-04: `calendarStore` uses the banned `toISOString().slice(0,10)` UTC-slice anti-pattern
**Files modified:** `apps/pwa/src/lib/eventDateTime.ts`, `apps/pwa/src/store/calendarStore.ts`
**Commit:** 746c3c7
**Applied fix:** Exported the existing private `localDateISO(d)` helper from `eventDateTime.ts` and used it in `initialCalendarRange()` and `todayIso()` in place of `.toISOString().slice(0,10)`. `eventDateTime.ts` is a leaf module (no imports), so no circular dependency is introduced.
### WR-05: `SessionExpiredError` instanceof check fragile across module-reload boundaries
**Files modified:** `apps/pwa/src/main.tsx`
**Commit:** 9f88068
**Applied fix:** `onGlobalError` now also matches `(error as { name?: string })?.name === 'SessionExpiredError'` so the session-expiry interstitial still arms when `client.ts` is loaded through two module graphs (the class carries a fixed `name` precisely for identity stability).
### WR-06: `triggerTargetedResync` runs before marking a row done — a hang stalls the outbox (requires human verification)
**Files modified:** `apps/api/src/broker/outboxWorker.ts`
**Commit:** 0511a23
**Applied fix:** Wrapped the success-path re-sync in `Promise.race([triggerTargetedResync(...), timeout(RESYNC_TIMEOUT_MS=10s)])`. On timeout the worker proceeds to mark `done` and lets the PWA's next poll reconcile. `triggerTargetedResync` already swallows its own errors, so the timed-out promise running on in the background is safe.
**Human verification needed:** confirm 10s is the right cap, and that letting the row reach `done` after a timed-out re-sync (relying on the PWA refetch to reconcile) is acceptable for the deletion/edit stale-cache case the eager re-sync was originally protecting against.
### WR-07: `recurrenceUntil < startDate` string comparison
See WR-02 above — fixed in the same commit (5724fe8).
### WR-08: `parseDateTime` cannot distinguish all-day DATE from malformed partial dates (requires human verification)
**Files modified:** `apps/pwa/src/components/EventForm.tsx`
**Commit:** d4a0ed7
**Applied fix:** Added a `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/` prefix guard before `new Date(clean)` in the timed branch, so a truncated value like `'2026-06'` (which V8 parses as a valid UTC instant) now throws and is reported `ok:false` instead of silently resolving to an unintended day. Verified the existing test inputs (`'2026-06-15T10:00:00-04:00'`, `'2026-06-10T23:30:00-04:00'`) still match the regex and that `'2026-06'` / `'2026-13-45'` / `'garbage'` are rejected.
**Human verification needed:** confirm no legitimate cached `start`/`end` shape feeding the EDIT path lacks the `T HH:MM` prefix (e.g. a stored bare-second or comma-separated variant) that this would now reject and surface as a blank edit field.
### IN-01: Stale `CalendarOccurrence.id` doc comment in client.ts
**Files modified:** `apps/pwa/src/api/client.ts`
**Commit:** 8b79d49
**Applied fix:** Updated the comment from `` `${uid}::${dtstart_iso}` `` to `ev-<sanitized-uid>-<epochMs>` to match the server's `makeOccurrenceId` (expand.ts).
### IN-02: `resolveDefaultView` indirection
**Files modified:** `apps/pwa/src/components/CalendarShell.tsx`
**Commit:** a570135
**Applied fix:** Documented that the function is purely an SSR guard (returns a stable `'month-grid'` when `window` is undefined) and that the D-05 breakpoint default actually lives in the store's `readPersistedView()`. Behavior preserved; the SSR guard was intentionally kept rather than inlined away.
### IN-05: Duplicated focus-trap implementation across two dialogs
**Files modified:** `apps/pwa/src/hooks/useFocusTrap.ts` (new), `apps/pwa/src/components/EventForm.tsx`, `apps/pwa/src/components/SeriesEditPrompt.tsx`
**Commit:** 1ab9710
**Applied fix:** Created `useFocusTrap(dialogRef)` hook returning the keydown handler; replaced the verbatim-duplicated `handleDialogKeyDown` in both components with a call to the hook. New file created because the fix explicitly requires shared extraction.
### IN-06: `weekly-count3.ics` DESCRIPTION line exceeds 75 octets without folding
**Files modified:** `apps/api/tests/fixtures/weekly-count3.ics`
**Commit:** 7ac4c29
**Applied fix:** Folded the DESCRIPTION onto a continuation line (RFC 5545 §3.1, leading-space continuation). Verified via ical.js@2.2.1 that the unfolded value exactly matches the original (single space and multibyte ``/`` preserved) and the RRULE still parses to `FREQ=WEEKLY;COUNT=3`.
## Skipped Issues
### IN-03: `members` list in CalendarShell is always length-1
**File:** `apps/pwa/src/components/CalendarShell.tsx:129-138`
**Reason:** skipped — scope-confirmation question, not an actionable defect. The reviewer explicitly says "This may be intended for the current milestone... Confirm scope." Rendering the other member's color band requires sourcing the other member's identity/colour (a feature/data-flow decision, not a localized fix) and would be speculative. Flagged for a human scope decision.
### IN-04: `recurrenceCount` default of `1` is send-eligible the instant bound flips to "count"
**File:** `apps/pwa/src/components/EventForm.tsx:216`
**Reason:** skipped — the reviewer states "Not a bug, but a confusing default." Changing the default to empty/placeholder is a UX-design choice that interacts with the WR-03 sanitisation just landed (an empty field now coerces to `0`, which validate() rejects with "Must be at least 1 occurrence"). Left as-is to avoid coupling a cosmetic default change to the validation fix; flagged for a human UX decision.
---
_Fixed: 2026-06-10T20:56:11Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,343 @@
---
phase: 06-ux-polish
reviewed: 2026-06-10T00:00:00Z
depth: standard
files_reviewed: 22
files_reviewed_list:
- apps/api/src/broker/expand.ts
- apps/api/src/broker/outboxWorker.ts
- apps/api/src/routes/events.ts
- apps/api/tests/broker/expand.test.ts
- apps/api/tests/broker/outboxWorker.test.ts
- apps/api/tests/broker/vevent.test.ts
- apps/api/tests/fixtures/weekly-count3.ics
- apps/pwa/src/api/client.test.ts
- apps/pwa/src/api/client.ts
- apps/pwa/src/components/AuthSplash.tsx
- apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/components/EventForm.test.tsx
- apps/pwa/src/components/EventForm.tsx
- apps/pwa/src/components/PushPermissionPrompt.tsx
- apps/pwa/src/components/SeriesEditPrompt.tsx
- apps/pwa/src/lib/eventDateTime.test.ts
- apps/pwa/src/lib/eventDateTime.ts
- apps/pwa/src/main.tsx
- apps/pwa/src/store/calendarStore.ts
- apps/pwa/src/styles/index.css
- apps/pwa/src/styles/tokens.css
findings:
critical: 1
warning: 8
info: 6
total: 15
status: issues_found
---
# Phase 6: Code Review Report
**Reviewed:** 2026-06-10
**Depth:** standard
**Files Reviewed:** 22
**Status:** issues_found
## Summary
Reviewed the Phase 6 UX-polish source set: server-side recurrence expansion, the
outbox worker write-path, the events route, the PWA event form / auth-splash /
push-prompt components, the calendar store, and supporting tests + CSS.
The code is heavily commented and carries a clear audit trail of prior fixes. The
adversarial pass focused on the gaps *between* those documented fixes. The one
Critical finding is a security-relevant injection vector in the RRULE `UNTIL`
assembly (the route validates the date-window query params and write-body lengths,
but `recurrenceUntil` is NOT validated as a date before being spliced into an RRULE
string and PUT to Fastmail). The remaining findings are correctness/robustness gaps:
a search-query SQL filter that over-returns all-day events, an unbounded-recurrence
silent fallthrough in the form, a NaN-able count input, a fragile `instanceof`
session-error check across module-reload boundaries, and the calendar store using
the exact UTC-slice anti-pattern the rest of the codebase explicitly bans.
## Critical Issues
### CR-01: `recurrenceUntil` is not validated as a date before being spliced into an RRULE and written to Fastmail
**File:** `apps/api/src/routes/events.ts:111`, `apps/api/src/broker/outboxWorker.ts:124-132`
**Issue:**
The route validates `recurrenceUntil` only as `z.string().max(10).optional()` — any
≤10-char string passes. The outbox worker then does:
```js
const dateDigits = until.replace(/-/g, '')
s += `;UNTIL=${dateDigits}` // all-day
s += `;UNTIL=${dateDigits}T235959Z` // timed
```
`until.replace(/-/g,'')` strips hyphens but leaves every other character. A payload
of `recurrenceUntil: "A;FREQ=DA"` (10 chars, no hyphens) yields
`;UNTIL=A;FREQ=DA` — i.e. an injected extra RRULE part. The worker comment claims
"The fixed `;UNTIL=` template prevents injection of extra `;`-delimited RRULE parts"
and "ICAL.Recur.fromString rejects malformed values" — but the value itself can
contain `;`, and `ICAL.Recur.fromString` is lenient about unknown parts. Even in the
benign case, a non-date string like `"notadate"` produces `;UNTIL=notadate`, which
either silently corrupts the series bound or throws deep in `buildVeventString`
(burning the outbox attempt budget) rather than being rejected at the boundary.
This is the same class the route's own header comment claims to defend against
(T-02b / T-03-08 input validation). `recurrenceCount` is correctly bounded
(`z.number().int().min(1)`); `recurrenceUntil` is not.
**Fix:** Validate the format at the zod boundary in both `eventFieldsSchema`
(events.ts) and `outboxPayloadSchema` (outboxWorker.ts):
```ts
recurrenceUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
```
The existing windowed-GET schema already uses exactly this regex
(`events.ts:88-89`) — reuse it. With the regex in place the `.replace(/-/g,'')`
output is guaranteed digits-only and the injection vector closes.
## Warnings
### WR-01: All-day non-recurring events are over-selected by the date-window SQL filter
**File:** `apps/api/src/routes/events.ts:199-203`
**Issue:**
The third `or()` branch selects *any* row with `dtstartDate` in `[start, end)`
**without** gating on `hasRrule = 0`:
```js
and(
sql`${calendarEvents.dtstartDate} IS NOT NULL`,
sql`${calendarEvents.dtstartDate} >= ${start}`,
sql`${calendarEvents.dtstartDate} < ${end}`,
)
```
The timed branch above it explicitly gates `hasRrule = 0`, but this all-day branch
does not. Any recurring all-day master whose `dtstartDate` happens to fall inside the
window is matched twice (once by the recurring branch at :184, once here). Because
both branches feed the same `flatMap(expandOccurrences)`, the same master is expanded
twice and every occurrence is duplicated in the response. `expandOccurrences` builds a
stable `makeOccurrenceId`, so Schedule-X dedups on render — but the duplication is
real on the wire and any consumer that counts occurrences (or a future view that does
not dedup) sees doubles. Add the missing `hasRrule = 0` gate to the all-day branch.
**Fix:**
```js
and(
sql`${calendarEvents.hasRrule} = 0`,
sql`${calendarEvents.dtstartDate} IS NOT NULL`,
sql`${calendarEvents.dtstartDate} >= ${start}`,
sql`${calendarEvents.dtstartDate} < ${end}`,
)
```
### WR-02: EventForm silently creates an unbounded series when "On date" is selected but no date entered
**File:** `apps/pwa/src/components/EventForm.tsx:355-359, 399-401`
**Issue:**
When `recurrenceBound === 'until'`, `validate()` only flags an error when
`recurrenceUntil` is truthy AND before the start:
```js
} else if (recurrenceBound === 'until' && recurrenceUntil) {
if (recurrenceUntil < startDate) { ... }
}
```
If the user picks "On date" but leaves the date blank, validation passes. The payload
builder then omits `recurrenceUntil` (the spread is guarded by
`... && recurrenceBound === 'until' && recurrenceUntil`), so the event is created as
an **unbounded** recurring series — the opposite of the user's stated intent ("Ends:
On date"). Treat a blank `recurrenceUntil` while `bound === 'until'` as a validation
error.
**Fix:** Add to the `recurrence !== 'none'` block:
```js
if (recurrenceBound === 'until' && !recurrenceUntil) {
newErrors.recurrenceBound = 'Choose an end date'
}
```
### WR-03: `recurrenceCount` number input can produce `NaN` / 0 state and an empty-string-driven 0
**File:** `apps/pwa/src/components/EventForm.tsx:932`
**Issue:**
`onChange={(e) => setRecurrenceCount(Number(e.target.value))}`. Clearing the field
yields `e.target.value === ''``Number('') === 0`; certain intermediate inputs
(`"-"`, `"e"`) yield `NaN`. `NaN < 1` is `false`, so the count-validation guard
(`recurrenceCount < 1`) does NOT fire for `NaN`, and the payload spread
(`recurrenceCount >= 1``NaN >= 1` is `false`) silently drops the count, again
yielding an unbounded series. The 0 case is caught by validation, but the NaN case
bypasses both the validation gate and the payload gate.
**Fix:** Sanitize on change and validate explicitly:
```js
onChange={(e) => {
const n = parseInt(e.target.value, 10)
setRecurrenceCount(Number.isFinite(n) ? n : 0)
}}
// and in validate():
if (recurrenceBound === 'count' && (!Number.isInteger(recurrenceCount) || recurrenceCount < 1)) {
newErrors.recurrenceBound = 'Must be at least 1 occurrence'
}
```
### WR-04: `calendarStore` uses `toISOString().slice(0,10)` — the exact UTC-slice anti-pattern the codebase bans
**File:** `apps/pwa/src/store/calendarStore.ts:130-131, 137`
**Issue:**
`initialCalendarRange()` and `todayIso()` both build date strings with
`.toISOString().slice(0, 10)`. `eventDateTime.ts:64-65` and `EventForm.tsx:108-110`
explicitly document this as forbidden ("NEVER use toISOString().slice(0,10) — that
returns the UTC date, not the local date"). For a user west of UTC (the project's
primary zones are Toronto/Detroit/New_York/Edmonton — all negative offsets) after
~20:00 local, `todayIso()` returns *tomorrow's* date. This is the default
`selectedDate` and seeds the initial fetch window — so a late-evening cold load can
center the calendar on the wrong day and the EventForm create default (`todayIso()`
at EventForm.tsx:153/158) pre-fills tomorrow. The fix already exists as the private
`localDateISO` helper in `eventDateTime.ts`; export and reuse it.
**Fix:** Export `localDateISO` from `eventDateTime.ts` and use it in
`initialCalendarRange()`/`todayIso()`:
```js
function localDateISO(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth()+1)}-${pad2(d.getDate())}`
}
```
### WR-05: `SessionExpiredError` instanceof check is fragile across the test's dynamic re-imports / module duplication
**File:** `apps/pwa/src/main.tsx:30-34`, `apps/pwa/src/api/client.ts:33-39`
**Issue:**
The global error handler routes on `error instanceof SessionExpiredError`. The class is
defined in `client.ts` and re-imported in `main.tsx`. This works in the prod bundle
(single module instance), but it is a known footgun: if `client.ts` is ever loaded
through two module graphs (Vite SSR, a duplicated chunk, or — as the tests already do
— repeated `await import('./client.js')`), `instanceof` fails and the session-expiry
interstitial never arms, leaving the user on a hung query. The class uses a fixed
`readonly name = 'SessionExpiredError'` precisely to be identity-stable; the handler
should defensively also check `name`.
**Fix:**
```js
function onGlobalError(error: unknown): void {
if (error instanceof SessionExpiredError ||
(error as { name?: string })?.name === 'SessionExpiredError') {
useCalendarStore.getState().setSessionExpired(true)
}
}
```
### WR-06: `triggerTargetedResync` runs before marking a row `done`, so a sync hang stalls the outbox cycle and the optimistic toast
**File:** `apps/api/src/broker/outboxWorker.ts:683-694`
**Issue:**
On success the worker awaits `triggerTargetedResync(...)` BEFORE writing
`status='done'`. The comment justifies this (avoid the PWA refetch racing stale
cache). But `triggerTargetedResync` performs `client.fetchCalendars()` +
`syncCalendar()` — unbounded network I/O against Fastmail with no timeout. If that
hangs or is slow, the row stays `pending` from the DB's perspective for the full
duration, the 15s `isDraining` guard keeps the next cycle a no-op, and the PWA polls
`sync-status` seeing `pending` indefinitely. A single slow re-sync therefore blocks
the entire single-process outbox. Consider bounding the re-sync with a timeout, or
marking `done` and accepting the documented race (the PWA already re-polls). At
minimum, the unbounded-I/O-before-commit tradeoff should be a deliberate, time-boxed
decision rather than open-ended.
**Fix:** Wrap the resync in a timeout (e.g. `Promise.race` with a 10s cap) so a stalled
Fastmail connection cannot wedge the drain loop; on timeout, proceed to mark `done`
and let the next poll reconcile.
### WR-07: `recurrenceUntil < startDate` string comparison is only valid for same-format DATE strings
**File:** `apps/pwa/src/components/EventForm.tsx:356`
**Issue:**
`if (recurrenceUntil < startDate)` compares two strings lexicographically.
`recurrenceUntil` comes from a `type="date"` input (`YYYY-MM-DD`) and `startDate` is
also `YYYY-MM-DD`, so this works *today*. But it is silently coupled to both values
always being zero-padded ISO dates. If `startDate` is ever blank (the IN-02 edit
parse-failure path sets it to `''`), `recurrenceUntil < ''` is always `false`, so the
bound-before-start guard is skipped exactly when the start is unknown. Low impact
(create-mode only shows the bound control, and create-mode start is never blank), but
the implicit format coupling is fragile. Compare parsed dates or assert non-empty
`startDate` first.
**Fix:** Guard on non-empty operands or compare via `Date`/`Temporal.PlainDate`.
### WR-08: `parseDateTime` swallows all errors and cannot distinguish "all-day DATE" from "malformed" in some inputs
**File:** `apps/pwa/src/components/EventForm.tsx:112-139`
**Issue:**
`new Date(clean)` for a string like `'2026-13-45'` returns an `Invalid Date`, caught
and returned as `ok:false` — correct. But `new Date('2026-06')` (a partial date) is
parsed as a *valid* UTC instant in V8, so a truncated/garbled cached value would parse
"successfully" to an unintended day/time and be saved on edit without tripping the
IN-02 blank-field guard. The function trusts `new Date()`'s permissive parsing.
Tighten the accepted timed-format (e.g. require a `T` and `:` before calling
`new Date`) so only genuinely well-formed ISO datetimes parse as `ok:true`.
**Fix:** Pre-validate the timed branch shape, e.g.
`if (!/T\d{2}:\d{2}/.test(clean)) return { ...today, ok: false }` before
`new Date(clean)`.
## Info
### IN-01: Dead/misleading interface doc comment in `client.ts` CalendarOccurrence
**File:** `apps/pwa/src/api/client.ts:107`
**Issue:** The `id` field comment says ``` `${uid}::${dtstart_iso}` — stable identity ```
but the server (`expand.ts:101-104` `makeOccurrenceId`) now emits
`ev-<sanitized-uid>-<epochMs>`. The `::`-format comment is stale and contradicts the
actual wire contract (and the server-side comment that explains why `::` was
abandoned). Update the comment to the `ev-…` form to avoid misleading future readers.
### IN-02: `resolveDefaultView` ignores its only branch's intent
**File:** `apps/pwa/src/components/CalendarShell.tsx:65-68`
**Issue:** `resolveDefaultView(persistedView)` returns `'month-grid'` for SSR and
otherwise returns `persistedView` verbatim — the function adds nothing over reading
`selectedView` directly, and the D-05 phone/desktop default logic it appears to
promise actually lives in the store's `readPersistedView()`. Harmless, but the
indirection invites a future reader to expect breakpoint logic here that isn't
present. Inline it or move the default resolution here for real.
### IN-03: `members` list in CalendarShell is always length-1 (only the current user)
**File:** `apps/pwa/src/components/CalendarShell.tsx:129-138`
**Issue:** `members` is built solely from `meQuery.data.user`, so `ColorLegend` and
`buildCalendarConfig` only ever see the current member. Given MEMORY notes the app is
designed to be "member-count-agnostic" and to render the other member's calendar as a
read-only overlay, a single-member legend will mislabel/omit the other member's color
band. This may be intended for the current milestone, but it contradicts the
multi-member intent and the ColorLegend's plural framing. Confirm scope.
### IN-04: `recurrenceCount` default of `1` is sent-eligible the instant bound flips to "count"
**File:** `apps/pwa/src/components/EventForm.tsx:216`
**Issue:** `recurrenceCount` defaults to `1`. If a user selects "After N times" and
submits without touching the field, a 1-occurrence "recurring" event is created
(effectively non-recurring). Not a bug, but a confusing default; consider an empty
initial value with a placeholder (the placeholder `"e.g. 10"` already implies blank).
### IN-05: Duplicated focus-trap implementation across two dialogs
**File:** `apps/pwa/src/components/EventForm.tsx:445-472`, `apps/pwa/src/components/SeriesEditPrompt.tsx:59-84`
**Issue:** The Tab/Shift+Tab focus-trap `handleDialogKeyDown` is copy-pasted verbatim
into both components. Extract to a shared hook (`useFocusTrap(ref)`) so a future fix
(e.g. handling `disabled`/`hidden` elements, or radio-group focus) lands in one place.
### IN-06: `weekly-count3.ics` fixture DESCRIPTION exceeds the typical 75-octet ICS line without folding
**File:** `apps/api/tests/fixtures/weekly-count3.ics:10`
**Issue:** The `DESCRIPTION:` line is a single long unfolded line. `ICAL.parse` tolerates
it, so the test passes, but a hand-authored fixture that violates RFC 5545 line-folding
can mask folding-related regressions. Cosmetic; fold the line if the fixture is meant to
mirror real Fastmail output.
---
_Reviewed: 2026-06-10_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: standard_
@@ -0,0 +1,184 @@
---
phase: 06-ux-polish
verified: 2026-06-10T20:37:54Z
status: human_needed
score: 12/12
overrides_applied: 0
human_verification:
- test: "iOS/standalone cold-load and OIDC redirect (D-10/D-11)"
expected: "PWA installed to iOS Home Screen cold-loads to the AuthSplash 'Signing you in' splash; Authelia redirects correctly in standalone mode; session-expiry interstitial fires and navigates back to /api/login without a hang."
why_human: "iOS Safari standalone OIDC redirect behavior is explicitly excluded from playwright-cli scope (CLAUDE.md convention; cannot simulate Safari standalone mode in desktop Chromium). Per 06-VALIDATION.md Manual-Only table."
- test: "PushPermissionPrompt spinner on iOS device (CP-04.3)"
expected: "The Loader2 spinner in PushPermissionPrompt rotates using the global @keyframes spin from tokens.css after the local redundant redefinition was removed."
why_human: "PushPermissionPrompt only renders inside an installed iOS/standalone PWA. Desktop Chromium never surfaces the component. The global keyframe resolves correctly per code inspection but a real device spot-check was not run (documented residual in 06-04-SUMMARY.md)."
---
# Phase 06: UX Polish Verification Report
**Phase Goal:** Smooth the rough edges surfaced during live use — clearer all-day events, saner event-form date/recurrence behavior, recurring-series editing, and auth-flow polish — so the app feels slick for the non-technical Apple member (hard UX constraint).
**Verified:** 2026-06-10T20:37:54Z
**Status:** human_needed
**Re-verification:** No — initial verification
---
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Moving an event's start moves its end preserving duration; end never strands behind start (D-03/D-04) | VERIFIED | `computeNewTimedEnd` + `computeNewAllDayEnd` exported from `eventDateTime.ts`; wired in `EventForm.tsx` start `onChange` handlers at lines 729-760; 6 unit tests green; EventForm tests D-04 timed + all-day pass |
| 2 | A recurring series can be bounded via "Ends: Never / On date / After N times" (D-06) | VERIFIED | `assembleRruleString` in `outboxWorker.ts`; `recurrenceUntil`/`recurrenceCount` Zod fields in both `events.ts` and `outboxWorker.ts`; "Ends" control in `EventForm.tsx` (state at lines 214-216, rendered at line 888+); 7 assembleRruleString tests green; playwright-cli verified |
| 3 | Editing a recurring occurrence prompts "Edit recurring series" before saving (D-08/D-09) | VERIFIED | `SeriesEditPrompt.tsx` created with `role="dialog"`, `aria-modal`, focus trap, Escape=cancel, correct copy; `EventForm.tsx` gates Save on `occurrence?.hasRrule === true` at line 419; `hasRrule` populated in `expand.ts` + mirrored in `client.ts`; playwright-cli verified |
| 4 | All-day events visually distinct from timed events at a glance (999.6/D-12) | VERIFIED | `.sx__date-grid .sx__date-grid-event` and `.sx__month-grid-day__events .sx__month-grid-event:not(:has(.sx__month-grid-event-time))` CSS rules in `index.css` (lines 125-140) with `border-radius:4px`, `font-weight:600`, `border-inline-start:none`; real Schedule-X v4.6.0 selectors (not the non-existent `.sx__all-day-event`) verified correct after follow-up fix 6dbb166; playwright-cli verified |
| 5 | All-day edit off-by-one stays fixed — re-editing does not grow event by a day (D-05) | VERIFIED | `exclusiveEndToInclusiveDate` pre-fill at EventForm reset line intact; D-05 round-trip test in EventForm.test.tsx passes; playwright-cli verified |
| 6 | Unauthenticated cold load shows only the neutral "Signing you in" splash — no calendar/skeleton/alert flash (D-10) | VERIFIED | `CalendarShell.tsx` returns `<AuthSplash state="loading" />` on `meQuery.isLoading` before any calendar content (line 269); `AuthSplash.tsx` created with `role="status"`, correct copy, full-screen centered layout; playwright-cli checkpoint PASS for desktop Chromium |
| 7 | A session that expires mid-use shows "Session expired" interstitial and cleanly redirects (D-11) | VERIFIED | `SessionExpiredError` class in `client.ts`; `handleAuthResponse` covers all 7 fetch wrappers; `QueryCache`/`MutationCache` `onError` in `main.tsx` (not `defaultOptions.onError`); `sessionExpired` flag in `calendarStore.ts`; CalendarShell renders `<AuthSplash state="redirecting" />` on `sessionExpired=true` with 1.5s redirect; dead-end state reachable when guard exhausted (follow-up fix e392c69); playwright-cli checkpoint PASS for desktop Chromium |
| 8 | Sync indicators actually animate — SyncStateToast spinner spins and LiveSyncIndicator reconnecting dot pulses (D-13) | VERIFIED | `@keyframes pulse` added to `tokens.css` at line 149 (0%,100% opacity:1; 50% opacity:0.4); redundant local `@keyframes spin` block removed from `PushPermissionPrompt.tsx` (confirmed absent); playwright-cli checkpoint PASS — both `animationName` values non-'none' in desktop Chromium |
| 9 | Nav chrome persists on /lists — BottomTabBar does not overlap Settings on desktop (UAT fixes FIX-3/FIX-4) | VERIFIED | `AppNav` lifted to `App.tsx` as a persistent sibling of `<Routes>` (outside any Route, line 112); `BottomTabBar` returns `null` on desktop via `isPhone()` guard (line 56); AppNav persistence test and BottomTabBar hidden-on-desktop test both green |
| 10 | D-04 floor rule: end snaps to newStart+1h (timed) / same day (all-day) when old end was already behind start | VERIFIED | `deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60*60*1000` in `computeNewTimedEnd`; `Math.max(0, dateDiffDays(...))` in `computeNewAllDayEnd`; two floor-rule unit tests green |
| 11 | RRULE UNTIL value-type matches DTSTART — DATE form for all-day, DATETIME UTC for timed (D-06, RFC 5545) | VERIFIED | `assembleRruleString`: all-day emits `UNTIL=YYYYMMDD`, timed emits `UNTIL=YYYYMMDDTHHMMSSZ (T235959Z)`; three vevent.test.ts serialization assertions + five assembleRruleString unit tests green |
| 12 | FREQ=DAILY regression locked (D-07) | VERIFIED | `FREQ persistence (D-07 regression)` test in `outboxWorker.test.ts` asserts daily-recurrence payload emits `RRULE:FREQ=DAILY`; green |
**Score:** 12/12 truths verified
---
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/pwa/src/lib/eventDateTime.ts` | `computeNewTimedEnd` + `computeNewAllDayEnd` exports with floor rules; no `toISOString().slice` | VERIFIED | Both functions exported at lines 114/142; WR-05 compliance confirmed — no `toISOString().slice` in helper code |
| `apps/pwa/src/lib/eventDateTime.test.ts` | 6 new tests: 3 timed + 3 all-day end-tracking, RED→GREEN | VERIFIED | 6 tests present in two describe blocks; `computeNewTimedEnd` + `computeNewAllDayEnd` imported; all pass |
| `apps/api/src/broker/outboxWorker.ts` | `assembleRruleString` exported; `recurrenceUntil`/`recurrenceCount` in `outboxPayloadSchema` | VERIFIED | `assembleRruleString` exported at line 114; both fields at lines 83-84 |
| `apps/api/src/routes/events.ts` | `eventFieldsSchema` accepts `recurrenceUntil` + `recurrenceCount` | VERIFIED | Both fields at lines 111-112 |
| `apps/api/tests/broker/vevent.test.ts` | UNTIL-DATE, UNTIL-DATETIME, COUNT serialization assertions | VERIFIED | 3 assertions match verified ical.js 2.2.1 output strings |
| `apps/api/tests/broker/outboxWorker.test.ts` | assembleRruleString describe + FREQ persistence test | VERIFIED | Both describe blocks present; 7+1 tests pass |
| `apps/api/src/broker/expand.ts` | `hasRrule: boolean` on `CalendarOccurrence`; populated from `event.isRecurring()` in both push sites | VERIFIED | Field at line 68; `const isRecurring` capture at line 224; both push sites at lines 261/308 |
| `apps/api/tests/broker/expand.test.ts` | `hasRrule` true/false assertions + bounded COUNT=3 invariant | VERIFIED | `hasRrule` describe with 2 tests + `Bounded RRULE` describe with 3 tests; all pass |
| `apps/api/tests/fixtures/weekly-count3.ics` | Bounded fixture for COUNT=3 test | VERIFIED | File exists at `apps/api/tests/fixtures/weekly-count3.ics` |
| `apps/pwa/src/styles/tokens.css` | `@keyframes pulse` added globally | VERIFIED | Present at line 149; exactly once |
| `apps/pwa/src/components/PushPermissionPrompt.tsx` | Redundant `@keyframes spin` `<style>` block removed | VERIFIED | `grep -q '@keyframes spin'` returns nothing |
| `apps/pwa/src/api/client.ts` | `SessionExpiredError`; `handleAuthResponse`; `redirect:'manual'` on all wrappers; `hasRrule` on `CalendarOccurrence`; `recurrenceUntil`/`recurrenceCount` on `CreateEventPayload` | VERIFIED | All present: `class SessionExpiredError` at line 33; `handleAuthResponse` at line 51; 7 `handleAuthResponse` call sites; `hasRrule` at line 131; `recurrenceUntil` at line 190 |
| `apps/pwa/src/api/client.test.ts` | SessionExpiredError detection tests (opaqueredirect + 401 per wrapper; 500 = generic Error) | VERIFIED | 40 tests pass; opaqueredirect/401/500 cases for fetchEvents, createEvent, updateEvent, deleteEvent, fetchMe |
| `apps/pwa/src/components/AuthSplash.tsx` | Full-screen interstitial; loading/redirecting/dead-end states; `role="status"` | VERIFIED | File created; `AuthSplashState` type at line 27; `role="status"` at line 61; all three states handled |
| `apps/pwa/src/components/CalendarShell.tsx` | `meQuery.isLoading` → AuthSplash loading; `meQuery.isError` → AuthSplash redirecting/dead-end; content only on `isSuccess`; `sessionExpired` interstitial wiring | VERIFIED | Lines 269-281 gate render; `sessionExpired` effect at lines 231-243; `enabled: meQuery.isSuccess` at line 120 |
| `apps/pwa/src/main.tsx` | `QueryCache`/`MutationCache` `onError` (NOT `defaultOptions.onError`) routing `SessionExpiredError` to `setSessionExpired` | VERIFIED | `QueryCache` at line 37; `MutationCache` at line 38; no `defaultOptions.onError` in file |
| `apps/pwa/src/store/calendarStore.ts` | `sessionExpired: boolean` + `setSessionExpired` action | VERIFIED | `sessionExpired: false` default at line 159; `setSessionExpired` at line 187 |
| `apps/pwa/src/components/EventForm.tsx` | Start onChange handlers call `computeNewTimedEnd`/`computeNewAllDayEnd`; recurrenceBound state + "Ends" control; hasRrule gates SeriesEditPrompt; payload sends `recurrenceUntil`/`recurrenceCount` | VERIFIED | `computeNewTimedEnd` wired at lines 732/755; `computeNewAllDayEnd` at line 730; recurrenceBound state at line 214; "Ends" control at line 888; `hasRrule` gate at line 419; payload spread at lines 399-403 |
| `apps/pwa/src/components/SeriesEditPrompt.tsx` | Bottom-sheet/dialog; focus trap; Escape=cancel; exact UI-SPEC copy; accent-filled "Update series"; ghost "Cancel" | VERIFIED | File created; `role="dialog"`, `aria-modal` at lines 135-136; "Edit recurring series" at line 154; "Update series" at line 217 |
| `apps/pwa/src/styles/index.css` | `.sx__date-grid-event` + `.sx__month-grid-event:not(:has(.sx__month-grid-event-time))` all-day pill overrides (real v4.6.0 selectors) | VERIFIED | Both rules at lines 125/134 with `border-radius:4px`, `font-weight:600`, `border-inline-start:none` |
| `apps/pwa/src/App.tsx` | AppNav as persistent sibling of `<Routes>` (FIX 3) | VERIFIED | `<AppNav>` rendered at line 112, outside `<Routes>` which starts at line 121 |
| `apps/pwa/src/components/BottomTabBar.tsx` | Returns `null` on desktop (FIX 4) | VERIFIED | `if (!isPhone()) return null` at line 56 |
---
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `EventForm.tsx` | `eventDateTime.ts` | start onChange → `computeNewTimedEnd` / `computeNewAllDayEnd` | WIRED | Both imports at lines 45-46; both calls in onChange handlers at lines 730, 732, 755 |
| `EventForm.tsx` | `client.ts` | payload carries `recurrenceUntil`/`recurrenceCount`; `occurrence.hasRrule` gates prompt | WIRED | `recurrenceUntil` spread at line 399; `recurrenceCount` spread at line 402; `hasRrule` check at line 419 |
| `main.tsx` | `calendarStore.ts` | `QueryCache`/`MutationCache` `onError``setSessionExpired(true)` on `SessionExpiredError` | WIRED | `useCalendarStore.getState().setSessionExpired(true)` at line 32; imperative store access confirmed |
| `CalendarShell.tsx` | `AuthSplash.tsx` | `meQuery.isLoading`/`isError` and `sessionExpired` flag render AuthSplash | WIRED | Imports at line 50; `<AuthSplash state="loading" />` at line 270; `<AuthSplash state="redirecting" />` at lines 281/291 |
| `outboxWorker.ts` | `vevent.ts` | `assembleRruleString` result passed to `buildVeventString` | WIRED | `assembleRruleString` called at lines 360/371/451/462; result flows as `rruleString` into the dispatch path |
| `events.ts` | `outboxWorker.ts` | `recurrenceUntil`/`recurrenceCount` in enqueued payload | WIRED | Zod schema accepts fields in both `eventFieldsSchema` (events.ts:111-112) and `outboxPayloadSchema` (outboxWorker.ts:83-84) |
| `expand.ts` | `client.ts` (mirror) | `CalendarOccurrence.hasRrule` server source-of-truth mirrored | WIRED | `hasRrule: boolean` at expand.ts line 68 (authoritative); mirrored at client.ts line 131 with explicit comment |
---
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `EventForm.tsx` | `computeNewTimedEnd` result → `endDate`/`endTime` state | `eventDateTime.ts` pure functions over form state (no network) | Yes — deterministic math, no network fetch, no empty source | FLOWING |
| `EventForm.tsx` | `recurrenceUntil`/`recurrenceCount` → submit payload | User input (controlled form state) | Yes — user input flows directly to payload spread | FLOWING |
| `EventForm.tsx` | `occurrence.hasRrule` gate | `CalendarOccurrence` from parent prop (occurrence fetched from API via `fetchEvents`) | Yes — `hasRrule` populated server-side in `expandOccurrences` from `event.isRecurring()` | FLOWING |
| `CalendarShell.tsx` | `meQuery.isLoading`/`isError` | TanStack Query `['me']` query → `fetchMe()``/api/me` | Yes — real API call with `redirect:'manual'`; auth gating is live | FLOWING |
| `CalendarShell.tsx` | `sessionExpired` | Zustand store, set by `QueryCache`/`MutationCache` `onError` on real `SessionExpiredError` | Yes — fires on real 401/opaqueredirect from any query/mutation | FLOWING |
---
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| `computeNewTimedEnd` + `computeNewAllDayEnd` exported | `node -e "import('./src/lib/eventDateTime.ts').then(m => console.log(typeof m.computeNewTimedEnd, typeof m.computeNewAllDayEnd))"` | `function function` | PASS |
| D-04 end-tracking unit tests pass | `pnpm --filter @familysync/pwa test -- run lib/eventDateTime` | 191/191 pass | PASS |
| SessionExpiredError detection tests pass | `npx vitest run src/api/client.test.ts` (apps/pwa) | 40/40 pass | PASS |
| assembleRruleString + FREQ persistence tests pass | `npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts` (apps/api) | 39/39 pass | PASS |
| hasRrule + bounded RRULE tests pass | `npx vitest run tests/broker/expand.test.ts` (apps/api) | 10/10 pass | PASS |
| Full PWA test suite green | `pnpm --filter @familysync/pwa test -- run` | 191/191 pass (17 files) | PASS |
| All phase-06 API broker + events tests green | `npx vitest run tests/broker/ tests/routes/events.test.ts` (apps/api) | 114/114 pass (9 files) | PASS |
Note: `tests/routes/lists.test.ts` and `tests/routes/push.test.ts` fail with `ER_ACCESS_DENIED_ERROR` (MariaDB not running with password in current dev environment). These are pre-existing integration-test DB-connectivity failures, not regressions introduced by phase 06. All broker tests that phase 06 modified or created are green.
---
### Probe Execution
No probe scripts found (`scripts/*/tests/probe-*.sh` absent). Step 7c: SKIPPED.
---
### Requirements Coverage
No `v1` REQ-IDs were assigned to this phase (confirmed by phase description and plan frontmatter — `requirements: []` in all plans). Phase is tracked against backlog items 999.2/3/6/7/8/9 and locked decisions D-01..D-13. All backlog items verified via truth/artifact checks above.
---
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| `SeriesEditPrompt.tsx` | 86 | `if (!open) return null` | Info | Correct conditional render guard — component is fully substantive when `open === true`; not a stub |
No TBD, FIXME, or XXX markers found in any phase-06-modified file. No unreferenced debt markers.
---
### Human Verification Required
#### 1. iOS/Standalone Cold-Load and OIDC Redirect (D-10/D-11)
**Test:** Install the PWA to iOS Home Screen. Cold-load with no session cookie. Confirm the first painted frame is the neutral "Signing you in" splash (not the calendar shell or "Sign-in required" alert), and that Authelia redirect completes correctly in standalone mode. Then simulate a session expiry to confirm the "Session expired / Signing you back in..." interstitial appears and redirects to /api/login without hanging.
**Expected:** Splash shown on cold-load; Authelia round-trip succeeds; mid-use 401 shows interstitial then redirects within ~2s; no redirect loop.
**Why human:** iOS Safari standalone OIDC redirect behavior cannot be driven by playwright-cli. This is the documented CLAUDE.md exception (standalone-mode OIDC redirect, `window.location.href` cross-origin fallback behavior). Recorded in 06-VALIDATION.md Manual-Only table. Desktop Chromium checkpoints already PASS (playwright-cli verified in 06-05).
#### 2. PushPermissionPrompt Spinner on iOS Device (CP-04.3)
**Test:** On a real iOS device with the PWA installed as a standalone app, trigger the push permission prompt and confirm the Loader2 spinner rotates.
**Expected:** Spinner rotates using the global `@keyframes spin` from `tokens.css` (the redundant local redefinition was removed in commit `81f2678`).
**Why human:** `PushPermissionPrompt` only surfaces inside an installed iOS/standalone PWA. No desktop Chromium path to the component. Code-confirmed: the inline `animation: 'spin 1s linear infinite'` is still present on the Loader2 element and resolves to the global keyframe. A real-device spot-check is required for confidence. Documented residual in 06-04-SUMMARY.md.
---
### Notable Deviations from Plan (Not Gaps)
The following deviations were auto-fixed during execution and do not constitute gaps:
1. **Schedule-X CSS selector correction (06-06):** Plan assumed `.sx__all-day-event` but Schedule-X v4.6.0 does not emit that class. Executor discovered and fixed in commits `6dbb166` + `5620261` using real v4.6.0 selectors. playwright-cli re-verified PASS.
2. **AuthSplash dead-end state + redirect guard persistence (06-05):** Initial implementation had the dead-end state unreachable and the one-shot guard cleared prematurely. Found during playwright-cli checkpoint; fixed in commits `36ef7a0` + `e392c69`. Re-verified PASS.
3. **`hasExplicitRecurrence` precedence bug (06-02):** `recurrence:'none'` with `_preservedRrule` present incorrectly fell through to emit an RRULE. Fixed in GREEN commit `d2abb91`. Regression test `CR-01` confirms the fix.
4. **`EventDetailPopover.test.tsx` fixture update (06-06):** Existing test fixtures omitted the new required `hasRrule` field. Mechanical fix in commit `69e5ae8`.
5. **Dev-seed gap:** The dev-bypass user (id 1) has no CalDAV credential/calendars (those belong to user 2), so live event-create-via-form could not be exercised end-to-end against Fastmail. Server logic and form UI verified via route-mocks and direct DB occurrence inserts. Not a code defect.
---
### Gaps Summary
No gaps. All 12 truths are VERIFIED. The two human verification items are device-only constraints (iOS/standalone behavior) that were explicitly pre-classified as manual checkpoints in 06-VALIDATION.md before execution began.
---
_Verified: 2026-06-10T20:37:54Z_
_Verifier: Claude (gsd-verifier)_