Files
familysync/.planning/phases/06-ux-polish/06-PATTERNS.md
T

727 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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