style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
@@ -7,6 +7,7 @@
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
@@ -37,7 +38,7 @@
- 999.5 — First-login provider setup → milestone 1.1
- 999.1 — Calendar provider abstraction → backlog/milestone 1.1 candidate
- Per-occurrence (RECURRENCE-ID) and "this and following" recurring edits → v1.x
</user_constraints>
</user_constraints>
---
@@ -58,16 +59,16 @@ Six independent fix areas, each self-contained but sharing the EventForm and cli
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| RRULE UNTIL/COUNT serialization | API / Backend (`vevent.ts`) | PWA (`client.ts` payload shape) | ical.js RRULE building lives in `buildVeventString`; PWA only passes a string |
| Recurrence bound control UI | Browser / Client (EventForm.tsx) | — | Pure form field; no server state |
| End-tracking math | Browser / Client (EventForm.tsx) | — | Duration arithmetic on local form state |
| Whole-series edit (master PUT) | API / Backend (outboxWorker + write.ts) | PWA (series-edit prompt gating) | PUT to Fastmail CalDAV is already implemented; PWA needs the detection signal |
| hasRrule exposure | API / Backend (expand.ts + events route) | Browser / Client (client.ts type) | DB has `has_rrule`; must flow through CalendarOccurrence type |
| Auth splash / session-expiry interstitial | Browser / Client (CalendarShell + client.ts) | — | OIDC guard is server-side; client handles redirect |
| Spin animation | Browser / Client (tokens.css + components) | — | CSS keyframe availability is a stylesheet concern |
| Pulse animation | Browser / Client (tokens.css) | — | Missing keyframe; needs addition |
| Capability | Primary Tier | Secondary Tier | Rationale |
| ----------------------------------------- | -------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------- |
| RRULE UNTIL/COUNT serialization | API / Backend (`vevent.ts`) | PWA (`client.ts` payload shape) | ical.js RRULE building lives in `buildVeventString`; PWA only passes a string |
| Recurrence bound control UI | Browser / Client (EventForm.tsx) | — | Pure form field; no server state |
| End-tracking math | Browser / Client (EventForm.tsx) | — | Duration arithmetic on local form state |
| Whole-series edit (master PUT) | API / Backend (outboxWorker + write.ts) | PWA (series-edit prompt gating) | PUT to Fastmail CalDAV is already implemented; PWA needs the detection signal |
| hasRrule exposure | API / Backend (expand.ts + events route) | Browser / Client (client.ts type) | DB has `has_rrule`; must flow through CalendarOccurrence type |
| Auth splash / session-expiry interstitial | Browser / Client (CalendarShell + client.ts) | — | OIDC guard is server-side; client handles redirect |
| Spin animation | Browser / Client (tokens.css + components) | — | CSS keyframe availability is a stylesheet concern |
| Pulse animation | Browser / Client (tokens.css) | — | Missing keyframe; needs addition |
---
@@ -75,14 +76,14 @@ Six independent fix areas, each self-contained but sharing the EventForm and cli
No new packages are installed in this phase. All fixes use the already-pinned stack from `CLAUDE.md`:
| Library | Version (pinned) | Role in this phase |
|---------|------------------|--------------------|
| ical.js | 2.2.1 | RRULE UNTIL/COUNT serialization in `vevent.ts` |
| React 19 | 19.x | EventForm state updates |
| TanStack Query | 5.101.0 | Global error handler for session expiry |
| Zustand | 5.0.14 | `sessionExpired` flag for session-expiry interstitial |
| Vitest | (existing) | All unit/integration tests |
| playwright-cli | `/usr/local/bin/playwright-cli` | Browser-level visual verification |
| Library | Version (pinned) | Role in this phase |
| -------------- | ------------------------------- | ----------------------------------------------------- |
| ical.js | 2.2.1 | RRULE UNTIL/COUNT serialization in `vevent.ts` |
| React 19 | 19.x | EventForm state updates |
| TanStack Query | 5.101.0 | Global error handler for session expiry |
| Zustand | 5.0.14 | `sessionExpired` flag for session-expiry interstitial |
| Vitest | (existing) | All unit/integration tests |
| playwright-cli | `/usr/local/bin/playwright-cli` | Browser-level visual verification |
**No package installations required for this phase.**
@@ -109,6 +110,7 @@ No new files/directories needed beyond what already exists. New test files follo
#### PWA side — `RecurrencePreset` extension (`client.ts:130`, `EventForm.tsx`)
**Current shape** [VERIFIED: codebase read]:
```typescript
// client.ts:130
export type RecurrencePreset = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'
@@ -126,6 +128,7 @@ recurrenceCount?: number // integer ≥ 1 — maps to RRULE COUNT; undefined
```
Rules:
- `recurrenceUntil` and `recurrenceCount` are mutually exclusive (RFC 5545 §3.3.10).
- Only sent when `recurrence !== 'none'`.
- On EDIT, same omit-if-absent rule as `recurrence` (WR-01 pattern already in place).
@@ -135,6 +138,7 @@ Rules:
#### API route validation (`events.ts:100109`)
The Zod schema `eventFieldsSchema` and `outboxPayloadSchema` in `outboxWorker.ts:7183` both need the two new fields:
```typescript
recurrenceUntil: z.string().max(10).optional(), // 'YYYY-MM-DD'
recurrenceCount: z.number().int().min(1).optional(),
@@ -158,6 +162,7 @@ ICAL.Recur.fromString('FREQ=DAILY;UNTIL=20260630T235959Z').toString()
```
**RFC 5545 §3.3.10 UNTIL value-type rule** [ASSUMED — RFC knowledge, not verified against spec text this session]:
- If DTSTART is VALUE=DATE (all-day), UNTIL MUST be a DATE (`YYYYMMDD`), not a DATETIME.
- If DTSTART is DATETIME, UNTIL MUST be a UTC DATETIME (`YYYYMMDDTHHMMSSZ`).
@@ -168,23 +173,23 @@ ICAL.Recur.fromString('FREQ=DAILY;UNTIL=20260630T235959Z').toString()
```typescript
// In outboxWorker.ts, when building rruleString for buildVeventString:
function assembleRruleString(
preset: string, // 'FREQ=WEEKLY' etc. from RRULE_PRESETS
until?: string, // 'YYYY-MM-DD'
preset: string, // 'FREQ=WEEKLY' etc. from RRULE_PRESETS
until?: string, // 'YYYY-MM-DD'
count?: number,
allDay?: boolean,
): string {
let s = preset
let s = preset;
if (count !== undefined) {
s += `;COUNT=${count}`
s += `;COUNT=${count}`;
} else if (until) {
// RFC 5545: DATE form for all-day, DATETIME UTC for timed
if (allDay) {
s += `;UNTIL=${until.replace(/-/g, '')}` // 20260630
s += `;UNTIL=${until.replace(/-/g, '')}`; // 20260630
} else {
s += `;UNTIL=${until.replace(/-/g, '')}T235959Z` // 20260630T235959Z
s += `;UNTIL=${until.replace(/-/g, '')}T235959Z`; // 20260630T235959Z
}
}
return s
return s;
}
```
@@ -195,6 +200,7 @@ function assembleRruleString(
**Verified** [VERIFIED: live node evaluation]: `expand.ts` computes each occurrence's end via `event.duration` (the DTSTART→DTEND delta parsed by ical.js), NOT from the RRULE UNTIL. This means adding `UNTIL` or `COUNT` to the RRULE does NOT affect per-occurrence duration — the motivating bug (2-month bars) was caused by the start→end span being 63 days, which became the duration for each occurrence. The fix is in D-04 (end-tracking) not in the expansion code. No changes needed in `expand.ts` for D-06.
**Expansion terminates correctly at UNTIL/COUNT** [VERIFIED: live node evaluation]:
```
COUNT=3 with weekly FREQ → RecurExpansion.next() returns 3 occurrences then marks expand.complete=true
```
@@ -231,6 +237,7 @@ COUNT=3 with weekly FREQ → RecurExpansion.next() returns 3 occurrences then ma
#### Identifying a recurring occurrence (`hasRrule`)
**Current state** [VERIFIED: codebase read]:
- `calendarEvents.hasRrule` exists in DB schema (`apps/api/src/db/schema.ts:131`) and is set correctly in `sync.ts:152,163`.
- `CalendarOccurrence` interface in `expand.ts` does NOT include `hasRrule` — it is absent from the type.
- `CalendarOccurrence` interface in `client.ts` does NOT include `hasRrule`.
@@ -250,6 +257,7 @@ The events route SQL join already fetches enough data; no DB query change needed
#### Write-back path for master VEVENT edit
**Current state** [VERIFIED: codebase read]: The edit route in `events.ts` (lines 326431) already:
1. Looks up `calendarEvents` by `uid` + caller's userId + `calendarUrl` — identifies the master VEVENT row.
2. Reads `objectUrl` (the CalDAV object URL for PUT) and the freshest `etag`.
3. Passes through to `outboxWorker` which calls `buildVeventString` + `updateCalendarEvent` PUT.
@@ -278,10 +286,10 @@ UI-SPEC.md §Surface 6 locks the pattern: bottom-sheet on phone, dialog on deskt
```typescript
// EventForm.tsx state (lines 201206)
const [startDate, setStartDate] = useState(initStart.date) // 'YYYY-MM-DD'
const [startTime, setStartTime] = useState(initStart.time) // 'HH:MM'
const [endDate, setEndDate] = useState(initEndDate) // 'YYYY-MM-DD' inclusive
const [endTime, setEndTime] = useState(initEnd.time) // 'HH:MM'
const [startDate, setStartDate] = useState(initStart.date); // 'YYYY-MM-DD'
const [startTime, setStartTime] = useState(initStart.time); // 'HH:MM'
const [endDate, setEndDate] = useState(initEndDate); // 'YYYY-MM-DD' inclusive
const [endTime, setEndTime] = useState(initEnd.time); // 'HH:MM'
```
The start date and time inputs currently have individual `onChange` handlers (`setStartDate(e.target.value)` / `setStartTime(e.target.value)` at lines 672 and 683 respectively). There is NO companion call to update `endDate`/`endTime` when start changes. This is the exact bug.
@@ -291,27 +299,29 @@ The start date and time inputs currently have individual `onChange` handlers (`s
Replace the bare `onChange` handlers on the start date input (line 672) and start time input (line 683) with handlers that also recompute end:
**Timed event — preserve delta:**
```typescript
function onStartDateChange(newStartDate: string) {
setStartDate(newStartDate)
if (allDay) return // handled in allDay branch
const oldStartMs = new Date(`${startDate}T${startTime}:00`).getTime()
const oldEndMs = new Date(`${endDate}T${endTime}:00`).getTime()
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000 // 1h floor
const newStartMs = new Date(`${newStartDate}T${startTime}:00`).getTime()
const newEndDate = new Date(newStartMs + deltaMs)
setEndDate(dateToISO(newEndDate)) // 'YYYY-MM-DD' via local accessors
setEndTime(timeToHHMM(newEndDate)) // 'HH:MM' via local accessors
setStartDate(newStartDate);
if (allDay) return; // handled in allDay branch
const oldStartMs = new Date(`${startDate}T${startTime}:00`).getTime();
const oldEndMs = new Date(`${endDate}T${endTime}:00`).getTime();
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000; // 1h floor
const newStartMs = new Date(`${newStartDate}T${startTime}:00`).getTime();
const newEndDate = new Date(newStartMs + deltaMs);
setEndDate(dateToISO(newEndDate)); // 'YYYY-MM-DD' via local accessors
setEndTime(timeToHHMM(newEndDate)); // 'HH:MM' via local accessors
}
```
**All-day — preserve day span:**
```typescript
function onStartDateChange(newStartDate: string) {
setStartDate(newStartDate)
const oldSpanDays = Math.max(0, dateDiffDays(startDate, endDate))
const newEnd = addDays(newStartDate, oldSpanDays) // pure date arithmetic
setEndDate(newEnd)
setStartDate(newStartDate);
const oldSpanDays = Math.max(0, dateDiffDays(startDate, endDate));
const newEnd = addDays(newStartDate, oldSpanDays); // pure date arithmetic
setEndDate(newEnd);
}
```
@@ -330,10 +340,15 @@ These are pure functions with defined I/O — **TDD-eligible.** Extract to `apps
#### Actual state of `@keyframes spin` [VERIFIED: codebase read]
`apps/pwa/src/styles/tokens.css` lines 140147:
```css
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
```
@@ -346,6 +361,7 @@ The `@keyframes spin` global definition IS present and IS loaded before any comp
#### Actual bugs
**Bug 1 — Redundant local redefine** [VERIFIED: codebase read]: `PushPermissionPrompt.tsx:358363` contains:
```tsx
<style>{`
@keyframes spin {
@@ -354,17 +370,26 @@ The `@keyframes spin` global definition IS present and IS loaded before any comp
}
`}</style>
```
This is redundant (the global definition already covers it) and slightly noisy but not the cause of animation failures. Remove it.
**Bug 2 — Missing `@keyframes pulse`** [VERIFIED: codebase read]: `LiveSyncIndicator.tsx:69` uses:
```tsx
animation: 'pulse 1.4s ease-in-out infinite'
animation: 'pulse 1.4s ease-in-out infinite';
```
`@keyframes pulse` does NOT exist in `tokens.css` or `index.css`. The reconnecting dot never animates. Must add to `tokens.css` per UI-SPEC.md:
```css
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
```
@@ -379,12 +404,14 @@ animation: 'pulse 1.4s ease-in-out infinite'
#### CalendarShell cold-load flash — D-10 [VERIFIED: codebase read]
**Current render path:**
1. `CalendarShell` renders immediately regardless of `meQuery` state.
2. `meQuery` starts loading (status: `isLoading`).
3. `isInitialLoading = meQuery.isLoading || (eventsQuery.isLoading && !eventsQuery.data)``SkeletonCalendar` renders.
4. On `meQuery.isError`: the component returns the `<div role="alert">Sign-in required</div>` fragment (line 220235), then the `useEffect` at line 197 calls `maybeRedirectToLogin()`.
**The flash:** Between initial render and the `meQuery.isError` settlement, the user sees:
- On fast networks: SkeletonCalendar briefly (acceptable).
- On first cold load with no session: SkeletonCalendar → "Sign-in required" alert → browser navigates to `/api/login`. The "Sign-in required" alert is the flash (it renders before `maybeRedirectToLogin()` fires, since the redirect is triggered by a `useEffect` which runs after paint).
@@ -393,11 +420,11 @@ animation: 'pulse 1.4s ease-in-out infinite'
```tsx
// In CalendarShell, before the main render tree:
if (meQuery.isLoading) {
return <AuthSplash state="loading" />
return <AuthSplash state="loading" />;
}
if (meQuery.isError) {
// useEffect handles maybeRedirectToLogin() — splash shows while redirect fires
return <AuthSplash state="redirecting" />
return <AuthSplash state="redirecting" />;
}
```
@@ -406,6 +433,7 @@ The `useEffect` for `maybeRedirectToLogin()` (already at line 197) fires after t
#### Session expiry mid-use — D-11 [VERIFIED: codebase read]
**Current state:**
- `fetchMe` in `client.ts` (lines 3653): uses `redirect: 'manual'`, detects `opaqueredirect`/401, throws `new Error('GET /api/me: authentication required')`.
- ALL other fetch calls (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`, etc.) do NOT use `redirect: 'manual'` and do NOT detect `opaqueredirect`. They only `throw new Error(...)` on non-ok HTTP status, but a 302 to Authelia would be followed with CORS block producing a network error (or hang), not a typed auth error.
- `maybeRedirectToLogin()` is only called from `CalendarShell`'s `meQuery.isError` effect.
@@ -413,23 +441,27 @@ The `useEffect` for `maybeRedirectToLogin()` (already at line 197) fires after t
**D-11 fix — two parts:**
**Part 1 — typed error + consistent `redirect:'manual'` in client.ts:**
```typescript
export class SessionExpiredError extends Error {
constructor() { super('Session expired — re-authentication required') }
constructor() {
super('Session expired — re-authentication required');
}
}
// Helper used in all fetch calls:
function handleAuthResponse(res: Response): void {
if (res.type === 'opaqueredirect' || res.status === 401) {
throw new SessionExpiredError()
throw new SessionExpiredError();
}
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}
```
All fetch functions (`fetchEvents`, `createEvent`, `updateEvent`, `deleteEvent`, `fetchSyncStatus`, `fetchWritableCalendars`) gain `redirect: 'manual'` + `handleAuthResponse(res)`.
**Part 2 — global TanStack Query error handler:**
```typescript
// In App.tsx (or where QueryClient is created):
const queryClient = new QueryClient({
@@ -437,19 +469,19 @@ const queryClient = new QueryClient({
queries: {
onError: (error) => {
if (error instanceof SessionExpiredError) {
setSessionExpiredFlag() // Zustand flag
setSessionExpiredFlag(); // Zustand flag
}
},
},
mutations: {
onError: (error) => {
if (error instanceof SessionExpiredError) {
setSessionExpiredFlag()
setSessionExpiredFlag();
}
},
},
},
})
});
```
A Zustand `sessionExpired: boolean` flag triggers the session-expiry interstitial above the app tree. The interstitial calls `maybeRedirectToLogin()` (clears the one-shot guard first via `clearLoginRedirect()`).
@@ -464,12 +496,12 @@ A Zustand `sessionExpired: boolean` flag triggers the session-expiry interstitia
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| RRULE UNTIL/COUNT serialization | Custom string concatenation | `ICAL.Recur.fromString(rruleString)` + existing `rruleProp.setValue(recur)` pattern | Already in use in vevent.ts; handles escaping, value type |
| Date arithmetic for end-tracking | Custom date math | Pure JS `Date` arithmetic via local accessors (already the pattern in eventDateTime.ts) | The project already has `serializeEventDateTime` as the pattern; extend it |
| Global error handling for auth | Per-query try/catch | TanStack Query cache subscription | Centralized, doesn't require touching every query |
| CSS keyframe animation | Per-component `<style>` blocks | `tokens.css` global keyframes | Already the pattern; PushPermissionPrompt redundancy should be removed |
| Problem | Don't Build | Use Instead | Why |
| -------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| RRULE UNTIL/COUNT serialization | Custom string concatenation | `ICAL.Recur.fromString(rruleString)` + existing `rruleProp.setValue(recur)` pattern | Already in use in vevent.ts; handles escaping, value type |
| Date arithmetic for end-tracking | Custom date math | Pure JS `Date` arithmetic via local accessors (already the pattern in eventDateTime.ts) | The project already has `serializeEventDateTime` as the pattern; extend it |
| Global error handling for auth | Per-query try/catch | TanStack Query cache subscription | Centralized, doesn't require touching every query |
| CSS keyframe animation | Per-component `<style>` blocks | `tokens.css` global keyframes | Already the pattern; PushPermissionPrompt redundancy should be removed |
---
@@ -516,18 +548,18 @@ A Zustand `sessionExpired: boolean` flag triggers the session-expiry interstitia
// Source: verified against ical.js 2.2.1 in project node_modules
// COUNT — existing pattern works directly:
const recur = ICAL.Recur.fromString('FREQ=WEEKLY;COUNT=10')
const rruleProp = new ICAL.Property('rrule')
rruleProp.setValue(recur)
vevent.addProperty(rruleProp)
const recur = ICAL.Recur.fromString('FREQ=WEEKLY;COUNT=10');
const rruleProp = new ICAL.Property('rrule');
rruleProp.setValue(recur);
vevent.addProperty(rruleProp);
// produces: RRULE:FREQ=WEEKLY;COUNT=10
// UNTIL (all-day event, DATE form):
const recur = ICAL.Recur.fromString('FREQ=WEEKLY;UNTIL=20260630')
const recur = ICAL.Recur.fromString('FREQ=WEEKLY;UNTIL=20260630');
// produces: RRULE:FREQ=WEEKLY;UNTIL=20260630
// UNTIL (timed event, DATETIME UTC form):
const recur = ICAL.Recur.fromString('FREQ=WEEKLY;UNTIL=20260630T235959Z')
const recur = ICAL.Recur.fromString('FREQ=WEEKLY;UNTIL=20260630T235959Z');
// produces: RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z
```
@@ -545,14 +577,14 @@ export function computeNewTimedEnd(
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
const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs)
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;
const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs);
return {
endDate: localDateISO(newEndDate),
endTime: localTimeHHMM(newEndDate),
}
};
}
/** Preserve all-day day-span on start date change. Returns new endDate string. */
@@ -561,8 +593,8 @@ export function computeNewAllDayEnd(
oldStartDate: string,
oldEndDate: string, // inclusive
): string {
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))
return addDaysISO(newStartDate, span)
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate));
return addDaysISO(newStartDate, span);
}
```
@@ -572,30 +604,30 @@ export function computeNewAllDayEnd(
// Source: design from CONTEXT.md D-11 + codebase analysis
export class SessionExpiredError extends Error {
readonly name = 'SessionExpiredError'
readonly name = 'SessionExpiredError';
constructor() {
super('Session expired — re-authentication required')
Object.setPrototypeOf(this, SessionExpiredError.prototype)
super('Session expired — re-authentication required');
Object.setPrototypeOf(this, SessionExpiredError.prototype);
}
}
// Add to all fetch wrappers (fetchEvents, createEvent, etc.):
const res = await fetch('/api/events', { credentials: 'include', redirect: 'manual' })
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError()
if (!res.ok) throw new Error(`GET /api/events failed: ${res.status}`)
const res = await fetch('/api/events', { credentials: 'include', redirect: 'manual' });
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (!res.ok) throw new Error(`GET /api/events failed: ${res.status}`);
```
---
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | RFC 5545 §3.3.10 requires UNTIL value-type to match DTSTART value-type (DATE vs DATETIME) | Focus 1, Pitfall 1 | Fastmail may silently accept mismatched types, reducing impact; or Fastmail strictly rejects — causes failed sync for bounded recurring events |
| A2 | Using `UNTIL=YYYYMMDDTHHMMSSz` (end of UTC day) as universal safe choice for timed events | Focus 1, Pitfall 2 | Users in UTC+N>1 may lose the final occurrence; acceptable trade-off for v1 |
| A3 | TanStack Query v5 global error handler uses cache subscription, not `defaultOptions.onError` | Focus 6, Pitfall 5 | If wrong, the session expiry handler silently does nothing |
| A4 | FREQ-persistence bug (daily → weekly) was a one-time or stale-state issue, not a reproducible code bug | Focus 2 | If it is reproducible, the root cause is not identified — regression test will catch it |
| A5 | `CalendarOccurrence.hasRrule` addition requires no DB query changes (already available via `event.isRecurring()`) | Focus 3 | If the API route does not pass hasRrule through the expand call correctly, the PWA always sees false |
| # | Claim | Section | Risk if Wrong |
| --- | ----------------------------------------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| A1 | RFC 5545 §3.3.10 requires UNTIL value-type to match DTSTART value-type (DATE vs DATETIME) | Focus 1, Pitfall 1 | Fastmail may silently accept mismatched types, reducing impact; or Fastmail strictly rejects — causes failed sync for bounded recurring events |
| A2 | Using `UNTIL=YYYYMMDDTHHMMSSz` (end of UTC day) as universal safe choice for timed events | Focus 1, Pitfall 2 | Users in UTC+N>1 may lose the final occurrence; acceptable trade-off for v1 |
| A3 | TanStack Query v5 global error handler uses cache subscription, not `defaultOptions.onError` | Focus 6, Pitfall 5 | If wrong, the session expiry handler silently does nothing |
| A4 | FREQ-persistence bug (daily → weekly) was a one-time or stale-state issue, not a reproducible code bug | Focus 2 | If it is reproducible, the root cause is not identified — regression test will catch it |
| A5 | `CalendarOccurrence.hasRrule` addition requires no DB query changes (already available via `event.isRecurring()`) | Focus 3 | If the API route does not pass hasRrule through the expand call correctly, the PWA always sees false |
---
@@ -621,12 +653,12 @@ if (!res.ok) throw new Error(`GET /api/events failed: ${res.status}`)
Step 2.6: No new external dependencies required. All tooling (Node.js 22, pnpm, MariaDB, Redis, playwright-cli) is available from prior phases. `playwright-cli` confirmed at `/usr/local/bin/playwright-cli`.
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| playwright-cli | Browser-level verification | ✓ | (global binary) | Human checkpoint |
| ical.js 2.2.1 | RRULE serialization | ✓ | 2.2.1 | — |
| Vitest | Unit + integration tests | ✓ | (existing) | — |
| MariaDB | API integration tests | ✓ | (dev stack) | — |
| Dependency | Required By | Available | Version | Fallback |
| -------------- | -------------------------- | --------- | --------------- | ---------------- |
| playwright-cli | Browser-level verification | ✓ | (global binary) | Human checkpoint |
| ical.js 2.2.1 | RRULE serialization | ✓ | 2.2.1 | — |
| Vitest | Unit + integration tests | ✓ | (existing) | — |
| MariaDB | API integration tests | ✓ | (dev stack) | — |
---
@@ -636,39 +668,40 @@ Step 2.6: No new external dependencies required. All tooling (Node.js 22, pnpm,
### Test Framework
| Property | Value |
|----------|-------|
| API framework | Vitest, `apps/api/vitest.config.ts`, `environment: 'node'` |
| PWA framework | Vitest, `apps/pwa/vitest.config.ts`, `environment: 'jsdom'` |
| API quick run | `cd apps/api && pnpm test` |
| PWA quick run | `cd apps/pwa && pnpm test` |
| Full suite | `pnpm test` (root — runs API only; planner should add `pnpm --filter @familysync/pwa test` to full gate) |
| Browser-level | `playwright-cli` (global binary at `/usr/local/bin/playwright-cli`) |
| Property | Value |
| ------------- | -------------------------------------------------------------------------------------------------------- |
| API framework | Vitest, `apps/api/vitest.config.ts`, `environment: 'node'` |
| PWA framework | Vitest, `apps/pwa/vitest.config.ts`, `environment: 'jsdom'` |
| API quick run | `cd apps/api && pnpm test` |
| PWA quick run | `cd apps/pwa && pnpm test` |
| Full suite | `pnpm test` (root — runs API only; planner should add `pnpm --filter @familysync/pwa test` to full gate) |
| Browser-level | `playwright-cli` (global binary at `/usr/local/bin/playwright-cli`) |
### Phase Requirements → Test Map
| Fix | Behavior | Test Type | Automated Command | File Exists? |
|-----|----------|-----------|-------------------|-------------|
| D-04: duration preserve (timed) | `computeNewTimedEnd` returns correct date/time given old start/end + new start | unit | `cd apps/pwa && pnpm test -- lib/eventDateTime` | ❌ Wave 0 — extend `eventDateTime.test.ts` |
| D-04: duration preserve (all-day) | `computeNewAllDayEnd` returns correct date given day-span + new start | unit | `cd apps/pwa && pnpm test -- lib/eventDateTime` | ❌ Wave 0 |
| D-04: floor rule | end never strands behind start for both timed and all-day | unit | `cd apps/pwa && pnpm test -- lib/eventDateTime` | ❌ Wave 0 |
| D-06: RRULE COUNT serialize | `buildVeventString({rruleString:'FREQ=WEEKLY;COUNT=5'})` produces correct ICS | unit | `cd apps/api && pnpm test -- broker/vevent` | ✅ `vevent.test.ts` — add cases |
| D-06: RRULE UNTIL DATE | all-day UNTIL serializes as `YYYYMMDD` not `YYYYMMDDTHHMMSSZ` | unit | `cd apps/api && pnpm test -- broker/vevent` | ✅ add cases |
| D-06: RRULE UNTIL DATETIME | timed UNTIL serializes as `YYYYMMDDTHHMMSSZ` | unit | `cd apps/api && pnpm test -- broker/vevent` | ✅ add cases |
| D-06: per-occurrence duration independent of UNTIL | expand with bounded RRULE; each occurrence has duration from DTSTART→DTEND | unit | `cd apps/api && pnpm test -- broker/expand` | ✅ `expand.test.ts` — add case |
| D-07: FREQ regression | create daily event → outbox carries `recurrence:'daily'` → RRULE is `FREQ=DAILY` | unit | `cd apps/api && pnpm test -- broker/outboxWorker` | ✅ add snapshot case |
| D-08: hasRrule in occurrence | `expandOccurrences` sets `hasRrule:true` for recurring events | unit | `cd apps/api && pnpm test -- broker/expand` | ✅ add assertion |
| D-10: no calendar flash | App shows spinner not skeleton/alert on cold unauthenticated load | browser | `playwright-cli` against dev server | — human only on iOS |
| D-11: SessionExpiredError from 401 | `fetchEvents(...)` with mocked 401 response throws `SessionExpiredError` | unit | `cd apps/pwa && pnpm test -- api/client` | ✅ extend `client.test.ts` |
| D-11: SessionExpiredError from opaqueredirect | `fetchEvents(...)` with mocked opaqueredirect throws `SessionExpiredError` | unit | `cd apps/pwa && pnpm test -- api/client` | ✅ extend `client.test.ts` |
| D-11: global handler triggers interstitial | Session-expiry interstitial appears on mid-use 401 | browser | `playwright-cli` | ✗ (no mock-auth tooling) |
| D-13: spin animation visible | Loader2 in SyncStateToast actually rotates | browser | `playwright-cli` | — |
| D-13: pulse animation visible | LiveSyncIndicator reconnecting dot actually pulses | browser | `playwright-cli` | — |
| D-13: pulse keyframe in CSS | `@keyframes pulse` present in tokens.css | unit | grep check or CSS parse | ❌ Wave 0 |
| Fix | Behavior | Test Type | Automated Command | File Exists? |
| -------------------------------------------------- | -------------------------------------------------------------------------------- | --------- | ------------------------------------------------- | ------------------------------------------ |
| D-04: duration preserve (timed) | `computeNewTimedEnd` returns correct date/time given old start/end + new start | unit | `cd apps/pwa && pnpm test -- lib/eventDateTime` | ❌ Wave 0 — extend `eventDateTime.test.ts` |
| D-04: duration preserve (all-day) | `computeNewAllDayEnd` returns correct date given day-span + new start | unit | `cd apps/pwa && pnpm test -- lib/eventDateTime` | ❌ Wave 0 |
| D-04: floor rule | end never strands behind start for both timed and all-day | unit | `cd apps/pwa && pnpm test -- lib/eventDateTime` | ❌ Wave 0 |
| D-06: RRULE COUNT serialize | `buildVeventString({rruleString:'FREQ=WEEKLY;COUNT=5'})` produces correct ICS | unit | `cd apps/api && pnpm test -- broker/vevent` | ✅ `vevent.test.ts` — add cases |
| D-06: RRULE UNTIL DATE | all-day UNTIL serializes as `YYYYMMDD` not `YYYYMMDDTHHMMSSZ` | unit | `cd apps/api && pnpm test -- broker/vevent` | ✅ add cases |
| D-06: RRULE UNTIL DATETIME | timed UNTIL serializes as `YYYYMMDDTHHMMSSZ` | unit | `cd apps/api && pnpm test -- broker/vevent` | ✅ add cases |
| D-06: per-occurrence duration independent of UNTIL | expand with bounded RRULE; each occurrence has duration from DTSTART→DTEND | unit | `cd apps/api && pnpm test -- broker/expand` | ✅ `expand.test.ts` — add case |
| D-07: FREQ regression | create daily event → outbox carries `recurrence:'daily'` → RRULE is `FREQ=DAILY` | unit | `cd apps/api && pnpm test -- broker/outboxWorker` | ✅ add snapshot case |
| D-08: hasRrule in occurrence | `expandOccurrences` sets `hasRrule:true` for recurring events | unit | `cd apps/api && pnpm test -- broker/expand` | ✅ add assertion |
| D-10: no calendar flash | App shows spinner not skeleton/alert on cold unauthenticated load | browser | `playwright-cli` against dev server | — human only on iOS |
| D-11: SessionExpiredError from 401 | `fetchEvents(...)` with mocked 401 response throws `SessionExpiredError` | unit | `cd apps/pwa && pnpm test -- api/client` | ✅ extend `client.test.ts` |
| D-11: SessionExpiredError from opaqueredirect | `fetchEvents(...)` with mocked opaqueredirect throws `SessionExpiredError` | unit | `cd apps/pwa && pnpm test -- api/client` | ✅ extend `client.test.ts` |
| D-11: global handler triggers interstitial | Session-expiry interstitial appears on mid-use 401 | browser | `playwright-cli` | ✗ (no mock-auth tooling) |
| D-13: spin animation visible | Loader2 in SyncStateToast actually rotates | browser | `playwright-cli` | — |
| D-13: pulse animation visible | LiveSyncIndicator reconnecting dot actually pulses | browser | `playwright-cli` | — |
| D-13: pulse keyframe in CSS | `@keyframes pulse` present in tokens.css | unit | grep check or CSS parse | ❌ Wave 0 |
### TDD-Eligible Items (pure functions with defined I/O)
Write tests FIRST for these:
- `computeNewTimedEnd` / `computeNewAllDayEnd` (D-04) — deterministic duration math
- `buildVeventString` with COUNT / UNTIL variants (D-06) — deterministic ICS output
- `expandOccurrences` with bounded RRULE (D-06) — deterministic occurrence count
@@ -683,48 +716,50 @@ Write tests FIRST for these:
### Playwright-cli scope (desktop-Chromium drivable vs iOS-only)
| Check | Desktop-Chromium OK | iOS-Safari Required |
|-------|--------------------|--------------------|
| No auth flash on cold load | ✓ playwright-cli (simulate no session cookie) | Informative but not required |
| "Signing you in…" splash renders | ✓ playwright-cli | — |
| Session-expiry interstitial renders | ✓ playwright-cli (intercept with 401) | — |
| Spinner actually animates in SyncStateToast | ✓ playwright-cli | — |
| Pulse dot animates in LiveSyncIndicator | ✓ playwright-cli | — |
| All-day event visual distinction | ✓ playwright-cli | — |
| iOS PWA standalone push behaviour | ✗ human checkpoint | ✓ required |
| Check | Desktop-Chromium OK | iOS-Safari Required |
| ------------------------------------------- | --------------------------------------------- | ---------------------------- |
| No auth flash on cold load | ✓ playwright-cli (simulate no session cookie) | Informative but not required |
| "Signing you in…" splash renders | ✓ playwright-cli | — |
| Session-expiry interstitial renders | ✓ playwright-cli (intercept with 401) | — |
| Spinner actually animates in SyncStateToast | ✓ playwright-cli | — |
| Pulse dot animates in LiveSyncIndicator | ✓ playwright-cli | — |
| All-day event visual distinction | ✓ playwright-cli | — |
| iOS PWA standalone push behaviour | ✗ human checkpoint | ✓ required |
### Sampling Rate
- **Per task commit:** Run the relevant test file for the changed module.
- **Per wave merge:** `cd apps/api && pnpm test && cd ../pwa && pnpm test` (full suites).
- **Phase gate:** Full suite green + playwright-cli checks complete before `/gsd-verify-work`.
### Wave 0 Gaps
- [ ] `apps/pwa/src/lib/eventDateTime.test.ts` — extend with `computeNewTimedEnd`, `computeNewAllDayEnd`, floor-rule cases (D-04)
- [ ] `apps/api/tests/broker/vevent.test.ts` — add UNTIL (DATE), UNTIL (DATETIME), COUNT cases (D-06)
- [ ] `apps/api/tests/broker/expand.test.ts` — add bounded RRULE + `hasRrule` cases (D-06/D-08)
- [ ] `apps/pwa/src/api/client.test.ts` — add `SessionExpiredError` detection cases for all fetch functions (D-11)
*(If no new test files are needed — all gaps are extensions to existing files.)*
_(If no new test files are needed — all gaps are extensions to existing files.)_
---
## Security Domain
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes — D-10/D-11 auth gating | `@hono/oidc-auth` + PKCE (existing); session expiry redirect |
| V3 Session Management | yes — D-11 session expiry detection | `redirect:'manual'` + `SessionExpiredError`; no token storage in client |
| V4 Access Control | no — no new endpoints | — |
| V5 Input Validation | yes — D-06 UNTIL date input | Zod validation on `recurrenceUntil` (date format) + `recurrenceCount` (integer ≥ 1) |
| V6 Cryptography | no | — |
| ASVS Category | Applies | Standard Control |
| --------------------- | ----------------------------------- | ----------------------------------------------------------------------------------- |
| V2 Authentication | yes — D-10/D-11 auth gating | `@hono/oidc-auth` + PKCE (existing); session expiry redirect |
| V3 Session Management | yes — D-11 session expiry detection | `redirect:'manual'` + `SessionExpiredError`; no token storage in client |
| V4 Access Control | no — no new endpoints | — |
| V5 Input Validation | yes — D-06 UNTIL date input | Zod validation on `recurrenceUntil` (date format) + `recurrenceCount` (integer ≥ 1) |
| V6 Cryptography | no | — |
### Known Threat Patterns for this phase
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| RRULE injection via `recurrenceUntil` | Tampering | Zod `.string().max(10)` + date format regex on API schema; `ICAL.Recur.fromString` sanitizes via parsing |
| Session cookie theft (not new, but D-11 surfaces the expiry path) | Spoofing | Same-origin cookie, `httpOnly`, existing Authelia session contract |
| Flash of authenticated content before auth check | Info Disclosure | D-10 fix: gate render on `meQuery.isSuccess` not `meQuery.isLoading` |
| Pattern | STRIDE | Standard Mitigation |
| ----------------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------- |
| RRULE injection via `recurrenceUntil` | Tampering | Zod `.string().max(10)` + date format regex on API schema; `ICAL.Recur.fromString` sanitizes via parsing |
| Session cookie theft (not new, but D-11 surfaces the expiry path) | Spoofing | Same-origin cookie, `httpOnly`, existing Authelia session contract |
| Flash of authenticated content before auth check | Info Disclosure | D-10 fix: gate render on `meQuery.isSuccess` not `meQuery.isLoading` |
---
@@ -760,6 +795,7 @@ Write tests FIRST for these:
## Metadata
**Confidence breakdown:**
- Code-verified findings (file:line): HIGH — read directly from source files
- ical.js UNTIL/COUNT API: HIGH — verified via live `node -e` evaluation against project node_modules
- Auth gating approach: HIGH — code-verified root cause, fix approach is well-established pattern