727 lines
26 KiB
Markdown
727 lines
26 KiB
Markdown
# 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
|