# 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('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 (
Sign-in required
) } // REPLACE WITH: if (meQuery.isLoading) { return } if (meQuery.isError) { // useEffect at line 197 calls maybeRedirectToLogin() — splash shows while redirect fires return } ``` **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 `; ``` 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 = { 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