docs(06): add phase verification report, patterns map, and iter2 review/fix
This commit is contained in:
@@ -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:33–51`):
|
||||||
|
```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:59–61`):
|
||||||
|
```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 107–133 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 18–53.
|
||||||
|
|
||||||
|
**Test structure to mirror** (`eventDateTime.test.ts:18–53`):
|
||||||
|
```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:201–207`):
|
||||||
|
```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:232–258`):
|
||||||
|
The effect at lines 232–262 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:136–148`):
|
||||||
|
```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:71–91`):
|
||||||
|
```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:36–53` — `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 220–235) 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:220–235`):
|
||||||
|
```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:196–208`):
|
||||||
|
```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:44–56`):
|
||||||
|
```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:28–45`):
|
||||||
|
```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 140–147 stays).
|
||||||
|
|
||||||
|
**Existing `@keyframes spin` to copy the CSS pattern from** (`tokens.css:140–147`):
|
||||||
|
```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 131–138. 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 358–363. No other changes.
|
||||||
|
|
||||||
|
**Block to delete** (`PushPermissionPrompt.tsx:357–363`):
|
||||||
|
```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:37–67`):
|
||||||
|
```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 241–256 — add:
|
||||||
|
occurrences.push({
|
||||||
|
// ... existing fields ...
|
||||||
|
hasRrule: isRecurring, // always false here — non-recurring branch
|
||||||
|
})
|
||||||
|
|
||||||
|
// In the recurring push at lines 287–302 — add:
|
||||||
|
occurrences.push({
|
||||||
|
// ... existing fields ...
|
||||||
|
hasRrule: isRecurring, // always true here — recurring branch
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Existing push pattern to mirror** (`expand.ts:241–257`):
|
||||||
|
```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:143–148`):
|
||||||
|
```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:49–54`):
|
||||||
|
```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:71–83`):
|
||||||
|
```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:255–308`):
|
||||||
|
```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:22–60`):
|
||||||
|
```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 36–53 (`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 28–64
|
||||||
|
**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 71–83 (`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 143–148
|
||||||
|
**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:37–67` (server) + `apps/pwa/src/api/client.ts:71–91` (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 15–53
|
||||||
|
**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)_
|
||||||
Reference in New Issue
Block a user