# 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
