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:
@@ -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:100–109`)
|
||||
|
||||
The Zod schema `eventFieldsSchema` and `outboxPayloadSchema` in `outboxWorker.ts:71–83` 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 326–431) 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 201–206)
|
||||
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 140–147:
|
||||
|
||||
```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:358–363` 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 220–235), 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 36–53): 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
|
||||
|
||||
Reference in New Issue
Block a user