chore: archive phase directories from completed milestones
This commit is contained in:
@@ -0,0 +1,726 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user