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

26 KiB
Raw Blame History

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):

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):

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):

/** 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):

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:

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:

// 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):

// 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):

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):

// 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):

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):

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:3653fetchMe):

// 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):

// 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):

// 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):

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:

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):

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):

@keyframes spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

Add directly after it:

@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):

{/* Spin animation for loader */}
<style>{`
  @keyframes spin {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
  }
`}</style>

The tokens.css global definition (loaded via main.tsxindex.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):

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(...):

// 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):

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):

// 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):

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):

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):

// 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):

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:

  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:

// 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:

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