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
@@ -21,6 +21,7 @@ One uncommitted file: `.planning/config.json` (this session's settings changes).
<completed_work>
This session:
- Phase 2 RESEARCH.md + Nyquist VALIDATION.md written and committed (a707f8d)
- PATTERNS.md written — 23 files classified, 19 analogs from Phase 1 code (5e14413)
- 5 PLAN.md files created in 4 waves; plan-checker PASSED iteration 2 (fc4cc2c)
@@ -28,7 +29,7 @@ This session:
- Backlog item 999.1 added — "treat Fastmail as a calendar provider, support more" (8bd52c6)
- GSD config changed via /gsd-config: Adaptive profile, TDD on, per-milestone branching,
auto-advance on; saved as global defaults (~/.gsd/defaults.json). **config.json uncommitted.**
</completed_work>
</completed_work>
<remaining_work>
@@ -39,7 +40,7 @@ This session:
foundation: tokens, colorUtils, calendarConfig, hydrateEvents, Zustand store) — no file overlap
- Wave 3: 02-04 — CalendarShell renders REAL windowed Fastmail events, color-coded, 4 views
- Wave 4: 02-05 — EventDetailPopover + ColorLegend + nav/toolbar + skeleton/empty/error + human verify
</remaining_work>
</remaining_work>
<decisions_made>
@@ -51,13 +52,14 @@ This session:
- Shared-family calendar identified via a `calendars.is_shared` column + operator checkpoint
(chosen over fragile displayName matching).
- This-session GSD config: Adaptive profile, TDD on, per-milestone branching, auto-advance on.
</decisions_made>
</decisions_made>
<blockers>
- None. Clean pause between plan and execute.
</blockers>
## Required Reading (in order)
1. `.planning/phases/02-calendar-display/02-01-PLAN.md``02-05-PLAN.md` — the plans to execute
2. `.planning/phases/02-calendar-display/02-RESEARCH.md` — DST/VTIMEZONE, Schedule-X Temporal,
firstDayOfWeek 0→7, has_rrule pre-filter (the landmines)
@@ -65,6 +67,7 @@ This session:
4. `.planning/phases/02-calendar-display/02-PATTERNS.md` — analog files in the Phase 1 codebase
## Critical Anti-Patterns (do NOT repeat these)
- Do NOT skip the `[BLOCKING] npx drizzle-kit push` task in Wave 1 (02-01). Build/types pass
without it because TS types come from config, not the live DB → false-positive verification.
- Do NOT stamp `String(occ.calendarId)` as the Schedule-X calendarId — use isShared/ownerUserId.
@@ -72,6 +75,7 @@ This session:
UTC (keep them as 'YYYY-MM-DD' / Temporal.PlainDate).
## Infrastructure State
- Branch: `main`. git.branching_strategy is now `milestone` — execute may create a milestone branch.
- Phase 1 shipped: apps/api + apps/pwa, MariaDB via docker-compose. No background processes running.
- TDD is ON globally now, but Phase 2 plans were written PRE-TDD — they carry no TDD gates.
@@ -26,32 +26,32 @@ user_setup: []
must_haves:
truths:
- "Drizzle schema has an indexed hasRrule boolean on calendar_events and an isShared boolean on calendars, both pushed to the live MariaDB"
- "PWA test runner (vitest + jsdom + @testing-library/react) executes and a smoke test passes"
- "Dev-auth bypass middleware injects a fixed dev user only when DEV_AUTH_BYPASS=true AND NODE_ENV!=production"
- "Failing-but-present test stubs exist for expand, events route, hydrateEvents, and calendarConfig (Wave 0 RED state) with concrete behavioral assertions, not bare failing imports"
- 'Drizzle schema has an indexed hasRrule boolean on calendar_events and an isShared boolean on calendars, both pushed to the live MariaDB'
- 'PWA test runner (vitest + jsdom + @testing-library/react) executes and a smoke test passes'
- 'Dev-auth bypass middleware injects a fixed dev user only when DEV_AUTH_BYPASS=true AND NODE_ENV!=production'
- 'Failing-but-present test stubs exist for expand, events route, hydrateEvents, and calendarConfig (Wave 0 RED state) with concrete behavioral assertions, not bare failing imports'
artifacts:
- path: "apps/api/src/db/schema.ts"
provides: "hasRrule + isShared columns + idx_calendar_events_has_rrule index"
contains: "has_rrule"
- path: "apps/api/src/auth/devBypass.ts"
provides: "devAuthBypass() middleware with hard production guard"
exports: ["devAuthBypass"]
- path: "apps/pwa/vitest.config.ts"
provides: "jsdom-environment vitest config for PWA"
contains: "jsdom"
- path: "apps/api/tests/fixtures/weekly-dst.ics"
provides: "DST-spanning weekly RRULE fixture for CAL-07 tests"
- path: 'apps/api/src/db/schema.ts'
provides: 'hasRrule + isShared columns + idx_calendar_events_has_rrule index'
contains: 'has_rrule'
- path: 'apps/api/src/auth/devBypass.ts'
provides: 'devAuthBypass() middleware with hard production guard'
exports: ['devAuthBypass']
- path: 'apps/pwa/vitest.config.ts'
provides: 'jsdom-environment vitest config for PWA'
contains: 'jsdom'
- path: 'apps/api/tests/fixtures/weekly-dst.ics'
provides: 'DST-spanning weekly RRULE fixture for CAL-07 tests'
min_lines: 10
key_links:
- from: "apps/api/src/index.ts"
to: "apps/api/src/auth/devBypass.ts"
- from: 'apps/api/src/index.ts'
to: 'apps/api/src/auth/devBypass.ts'
via: "app.use('/api/*', devAuthBypass()) before oidcAuthMiddleware"
pattern: "devAuthBypass"
- from: "apps/pwa/package.json"
to: "vitest"
via: "test script + devDependencies"
pattern: "\"test\".*vitest"
pattern: 'devAuthBypass'
- from: 'apps/pwa/package.json'
to: 'vitest'
via: 'test script + devDependencies'
pattern: '"test".*vitest'
---
<objective>
@@ -113,6 +113,7 @@ RED test stubs with concrete behavioral contracts.
Create three ICS fixtures under `apps/api/tests/fixtures/`: `weekly-dst.ics` (VEVENT with `DTSTART;TZID=America/New_York:20260301T100000`, `RRULE:FREQ=WEEKLY`, and a full `VTIMEZONE` block for America/New_York with both STANDARD and DAYLIGHT subcomponents so DST rules are present), `allday-birthday.ics` (VEVENT with `DTSTART;VALUE=DATE:20260615`, yearly RRULE, no DTEND), `exdate-series.ics` (weekly VEVENT with one `EXDATE` line removing a single occurrence). These must be valid VCALENDAR strings parseable by ICAL.parse.
Create the four RED test stubs with the CONCRETE behavioral assertions described in <behavior> above — each must encode its real contract (the DST wall-clock assertion in expand.test.ts; the 'shared'/String(ownerUserId) calendarId routing assertion in hydrateEvents.test.ts), not merely a failing import. Each test imports the not-yet-existing module (`../../src/broker/expand.js`, etc.) so the file fails to resolve / the assertion fails — that is the intended RED state. Per the Nyquist rule, mark each `<automated>` for the modules they cover as satisfied here. Use the describe/it patterns from poller.test.ts and health.test.ts. Load fixtures with `readFileSync` relative to the test file. Do NOT implement expand.ts, the route changes, hydrateEvents.ts, or calendarConfig.ts in this task — only the stubs that later plans turn green.
</action>
<verify>
<automated>cd apps/api && grep -q "has_rrule" src/db/schema.ts && grep -q "is_shared" src/db/schema.ts && grep -q "idx_calendar_events_has_rrule" src/db/schema.ts && echo SCHEMA_OK</automated>
@@ -156,6 +157,7 @@ RED test stubs with concrete behavioral contracts.
In `apps/api/src/index.ts`, mount `app.use('/api/*', devAuthBypass())` on the line immediately BEFORE the existing `app.use('/api/*', oidcAuthMiddleware())`. The bypass is a no-op when inactive, so production behavior is unchanged.
Add `DEV_AUTH_BYPASS` to `.env.example` with a comment: `# DEV ONLY — injects a fixed dev user, skips Authelia. Hard-disabled when NODE_ENV=production. NEVER set in prod.` Extend `docs/deployment.md` dev-auth-bypass section to note the NODE_ENV production hard guard and that the production Docker Compose must not set DEV_AUTH_BYPASS.
</action>
<verify>
<automated>cd apps/api && pnpm test -- tests/auth/devBypass.test.ts</automated>
@@ -197,20 +199,22 @@ RED test stubs with concrete behavioral contracts.
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → /api/* | OIDC-gated; dev-auth bypass replaces the gate in dev only |
| CI/prod env → app config | DEV_AUTH_BYPASS env var could leak into production |
| Boundary | Description |
| ------------------------ | --------------------------------------------------------- |
| browser → /api/\* | OIDC-gated; dev-auth bypass replaces the gate in dev only |
| CI/prod env → app config | DEV_AUTH_BYPASS env var could leak into production |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-01 | Elevation of privilege | devAuthBypass() | mitigate | Hard `NODE_ENV === 'production'` guard as the FIRST conditional, before reading DEV_AUTH_BYPASS; .env.example warning; prod compose must not set the flag (Pitfall 7) |
| T-02-02 | Tampering | drizzle-kit push | accept | Local dev DB; push reviewed; no untrusted input. Operator runs push against own MariaDB |
| T-02-SC | Tampering | pnpm installs (vitest, @testing-library/*, jsdom) | mitigate | All packages are mainstream, audited in RESEARCH §Package Legitimacy (Approved); no [ASSUMED]/[SUS] packages in this plan |
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ---------------------- | -------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-02-01 | Elevation of privilege | devAuthBypass() | mitigate | Hard `NODE_ENV === 'production'` guard as the FIRST conditional, before reading DEV_AUTH_BYPASS; .env.example warning; prod compose must not set the flag (Pitfall 7) |
| T-02-02 | Tampering | drizzle-kit push | accept | Local dev DB; push reviewed; no untrusted input. Operator runs push against own MariaDB |
| T-02-SC | Tampering | pnpm installs (vitest, @testing-library/\*, jsdom) | mitigate | All packages are mainstream, audited in RESEARCH §Package Legitimacy (Approved); no [ASSUMED]/[SUS] packages in this plan |
</threat_model>
<verification>
@@ -221,16 +225,19 @@ RED test stubs with concrete behavioral contracts.
</verification>
<success_criteria>
- Schema columns added, pushed, and verified against the live DB
- PWA test runner operational
- Dev-auth bypass green with production hard guard
- ICS fixtures parse; RED stubs in place for later waves with concrete DST + calendarId-routing contracts
</success_criteria>
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 01)
New symbols/files created here (exclude from drift verification):
- `calendar_events.hasRrule` Drizzle column + `idx_calendar_events_has_rrule` index
- `calendars.isShared` Drizzle column
- `devAuthBypass` (function) — apps/api/src/auth/devBypass.ts
@@ -240,7 +247,7 @@ New symbols/files created here (exclude from drift verification):
- apps/api/tests/fixtures/{weekly-dst,allday-birthday,exdate-series}.ics
- apps/api/tests/broker/expand.test.ts, apps/api/tests/routes/events.test.ts, apps/api/tests/auth/devBypass.test.ts (new test files)
- apps/pwa/src/lib/hydrateEvents.test.ts, apps/pwa/src/lib/calendarConfig.test.ts (new test files)
</artifacts_produced>
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-01-SUMMARY.md` when done
@@ -1,17 +1,25 @@
---
phase: 02-calendar-display
plan: "01"
plan: '01'
subsystem: api-schema, api-auth, pwa-test
tags: [schema-migration, dev-auth, test-harness, ics-fixtures, red-stubs]
dependency_graph:
requires: [01-foundation-broker-spike]
provides: [calendar_events.hasRrule, calendars.isShared, devAuthBypass, pwa-vitest-jsdom, ics-fixtures, red-test-stubs]
provides:
[
calendar_events.hasRrule,
calendars.isShared,
devAuthBypass,
pwa-vitest-jsdom,
ics-fixtures,
red-test-stubs,
]
affects: [02-02, 02-03, 02-04, 02-05]
tech_stack:
added:
- vitest@^4.1.8 (PWA devDependency)
- "@testing-library/react@^16.3.0 (PWA devDependency)"
- "@testing-library/jest-dom@^6.6.3 (PWA devDependency)"
- '@testing-library/react@^16.3.0 (PWA devDependency)'
- '@testing-library/jest-dom@^6.6.3 (PWA devDependency)'
- jsdom@^26.1.0 (PWA devDependency)
patterns:
- Drizzle boolean column + index pattern (hasRrule, isShared)
@@ -36,12 +44,12 @@ key_files:
- .env.example
- docs/deployment.md
decisions:
- "Applied ALTER TABLE directly instead of drizzle-kit push due to non-TTY interactive prompt — false-positive int(11) vs int type warning on existing rows"
- "devAuthBypass evaluates env vars at call time (process start) not request time — intentional so auth mode is fixed for the lifetime of the process"
- "RED test stubs reference not-yet-built modules to ensure compile-time failure (concrete RED state), not just assertion failure"
- 'Applied ALTER TABLE directly instead of drizzle-kit push due to non-TTY interactive prompt — false-positive int(11) vs int type warning on existing rows'
- 'devAuthBypass evaluates env vars at call time (process start) not request time — intentional so auth mode is fixed for the lifetime of the process'
- 'RED test stubs reference not-yet-built modules to ensure compile-time failure (concrete RED state), not just assertion failure'
metrics:
duration: "8m 25s"
completed: "2026-06-05"
duration: '8m 25s'
completed: '2026-06-05'
tasks_completed: 3
files_created: 10
files_modified: 5
@@ -56,6 +64,7 @@ Horizontal foundation for Phase 2 calendar slice: two schema columns pushed to l
### Schema Changes (Task 1)
Added to `apps/api/src/db/schema.ts`:
- `calendarEvents.hasRrule`: `boolean('has_rrule').default(false).notNull()` — pre-filter flag for recurring event masters (RESEARCH.md §Pitfall 5)
- `calendarEvents`: new index `idx_calendar_events_has_rrule` matching style of `idx_calendar_events_dtstart_utc`
- `calendars.isShared`: `boolean('is_shared').default(false).notNull()` — operator-marked shared-family calendar flag
@@ -72,6 +81,7 @@ Both columns pushed to live MariaDB (503-event cache intact). `SHOW COLUMNS` con
### ICS Fixtures (Task 1)
Three fixtures created at `apps/api/tests/fixtures/`:
- `weekly-dst.ics`: weekly VEVENT at `DTSTART;TZID=America/New_York:20260301T100000` with full VTIMEZONE block (STANDARD + DAYLIGHT subcomponents for March 2026 EST→EDT transition)
- `allday-birthday.ics`: `DTSTART;VALUE=DATE:20260615` with `RRULE:FREQ=YEARLY`, no DTEND — pure DATE type
- `exdate-series.ics`: `RRULE:FREQ=WEEKLY;COUNT=5` with `EXDATE;TZID=America/New_York:20260615T090000` — exactly one occurrence excluded
@@ -83,6 +93,7 @@ All three fixtures parse via `ICAL.parse()` without throwing.
Four test stubs with concrete behavioral contracts (not bare failing imports):
**expand.test.ts**: Three behavioral contracts —
1. DST wall-clock: every occurrence in March 2026 window has `T10:00:00` in the ISO start string, regardless of EST/EDT offset. Tests both pre-transition (2026-03-01) and post-transition (2026-03-15) occurrences.
2. All-day: `allDay:true` and `start === '2026-06-15'` (no `T` component)
3. EXDATE: length === 4 (not 5), June 15 occurrence absent
@@ -98,6 +109,7 @@ All RED stubs fail at import resolution (module not built yet) — correct RED s
### Dev-Auth Bypass (Task 2)
Created `apps/api/src/auth/devBypass.ts`:
- Exports `devAuthBypass(): MiddlewareHandler`
- First conditional is `NODE_ENV === 'production'` — hard guard (T-02-01 mitigation)
- Returns no-op passthrough when production OR bypass flag unset
@@ -115,6 +127,7 @@ All three devBypass.test.ts cases pass: production guard, unset-flag passthrough
### Auto-fixed Issues
**1. [Rule 3 - Blocker] drizzle-kit push replaced with direct ALTER TABLE**
- **Found during:** Task 3
- **Issue:** `drizzle-kit push` emitted a non-TTY interactive prompt. The "data-loss" warnings were false positives — MariaDB stores int as `int(11)` display width but drizzle-kit 0.31.x sees this as a type change on existing rows. The prompt cannot be auto-confirmed without TTY.
- **Fix:** Applied the two actual new columns directly via `ALTER TABLE calendar_events ADD COLUMN IF NOT EXISTS has_rrule tinyint(1) NOT NULL DEFAULT 0` and `ALTER TABLE calendars ADD COLUMN IF NOT EXISTS is_shared tinyint(1) NOT NULL DEFAULT 0`, plus the index. Outcome is identical to what drizzle-kit push would have done for the new columns.
@@ -125,6 +138,7 @@ All three devBypass.test.ts cases pass: production guard, unset-flag passthrough
## Known Stubs
The following test stubs are intentionally RED (modules not yet built):
- `apps/api/tests/broker/expand.test.ts` — awaits `apps/api/src/broker/expand.ts` (Plan 02)
- `apps/api/tests/routes/events.test.ts` — awaits evolved `apps/api/src/routes/events.ts` (Plan 02)
- `apps/pwa/src/lib/hydrateEvents.test.ts` — awaits `apps/pwa/src/lib/hydrateEvents.ts` (Plan 03)
@@ -139,6 +153,7 @@ No new threat surface introduced beyond what is already in the plan's threat mod
## Self-Check: PASSED
Files created:
- [x] apps/api/src/auth/devBypass.ts — FOUND
- [x] apps/pwa/vitest.config.ts — FOUND
- [x] apps/api/tests/fixtures/weekly-dst.ics — FOUND
@@ -151,10 +166,12 @@ Files created:
- [x] apps/pwa/src/lib/calendarConfig.test.ts — FOUND
Commits:
- [x] 75252eb — Task 1 feat
- [x] 8bd44b3 — Task 2 feat
DB state:
- [x] SHOW COLUMNS FROM calendar_events LIKE 'has_rrule' — returns 1 row
- [x] SHOW COLUMNS FROM calendars LIKE 'is_shared' — returns 1 row
- [x] 503 events intact
@@ -3,7 +3,7 @@ phase: 02-calendar-display
plan: 02
type: execute
wave: 2
depends_on: ["02-01"]
depends_on: ['02-01']
files_modified:
- apps/api/src/broker/expand.ts
- apps/api/src/routes/events.ts
@@ -15,27 +15,27 @@ user_setup: []
must_haves:
truths:
- "GET /api/events?start=&end= returns a flat array of concrete occurrences (no recurring masters, no raw VCALENDAR blobs) windowed to the requested date range"
- "Each occurrence carries the owner member color (from users.color) or the shared-family rose, plus an isShared flag, an ownerUserId, and the DB calendarId"
- "Recurring events are expanded server-side with VTIMEZONE registered before expansion so DST occurrences keep correct wall-clock time"
- 'GET /api/events?start=&end= returns a flat array of concrete occurrences (no recurring masters, no raw VCALENDAR blobs) windowed to the requested date range'
- 'Each occurrence carries the owner member color (from users.color) or the shared-family rose, plus an isShared flag, an ownerUserId, and the DB calendarId'
- 'Recurring events are expanded server-side with VTIMEZONE registered before expansion so DST occurrences keep correct wall-clock time'
- "All-day occurrences are returned with allDay:true and a 'YYYY-MM-DD' start (no timezone shift) — single local timezone for v1 (D-10)"
- "EXDATE-excluded occurrences are omitted from the expansion"
- "Invalid or missing start/end query params are rejected (zod) before any SQL runs; window capped at 90 days"
- 'EXDATE-excluded occurrences are omitted from the expansion'
- 'Invalid or missing start/end query params are rejected (zod) before any SQL runs; window capped at 90 days'
artifacts:
- path: "apps/api/src/broker/expand.ts"
provides: "expandOccurrences() — ICAL.RecurExpansion + VTIMEZONE registration + allDay split → CalendarOccurrence[]"
exports: ["expandOccurrences", "CalendarOccurrence"]
- path: "apps/api/src/routes/events.ts"
provides: "windowed /api/events with calendarEvents→calendars→users join, hasRrule pre-filter, zod validation"
contains: "zValidator"
- path: 'apps/api/src/broker/expand.ts'
provides: 'expandOccurrences() — ICAL.RecurExpansion + VTIMEZONE registration + allDay split → CalendarOccurrence[]'
exports: ['expandOccurrences', 'CalendarOccurrence']
- path: 'apps/api/src/routes/events.ts'
provides: 'windowed /api/events with calendarEvents→calendars→users join, hasRrule pre-filter, zod validation'
contains: 'zValidator'
key_links:
- from: "apps/api/src/routes/events.ts"
to: "apps/api/src/broker/expand.ts"
via: "expandOccurrences() called per recurring/timed row"
pattern: "expandOccurrences"
- from: "apps/api/src/routes/events.ts"
to: "users.color"
via: "innerJoin calendars→users, select color + isShared + users.id"
- from: 'apps/api/src/routes/events.ts'
to: 'apps/api/src/broker/expand.ts'
via: 'expandOccurrences() called per recurring/timed row'
pattern: 'expandOccurrences'
- from: 'apps/api/src/routes/events.ts'
to: 'users.color'
via: 'innerJoin calendars→users, select color + isShared + users.id'
pattern: "users\\.color"
---
@@ -102,6 +102,7 @@ shared-family calendar (open question A3) — the operator marks it.
7. Use rrule ONLY as a fallback if ICAL.RecurExpansion cannot parse a given RRULE — do not import it on the primary path (D-09).
Turn the Plan 01 RED expand.test.ts stub green against the three fixtures.
</action>
<verify>
<automated>cd apps/api && pnpm test -- tests/broker/expand.test.ts</automated>
@@ -145,6 +146,7 @@ shared-family calendar (open question A3) — the operator marks it.
For each row, derive `color = row.isShared ? '#F25C7A' : row.userColor` and `isShared = row.isShared`, then call `expandOccurrences(row.rawVevent, windowStartDate, windowEndDate, { calendarId, calendarName: row.displayName, ownerUserId: row.userId, color, isShared })`. The `ownerUserId: row.userId` field is load-bearing — the client routes calendar color by it. Flatten all results into one array. Wrap the DB+expansion body in try/catch returning 503 on DB error (health.ts pattern). Return `c.json({ occurrences })`.
Turn the Plan 01 RED events.test.ts stub green (mock db.select chain following the health.test.ts vi.mock pattern; assert color field, isShared, ownerUserId, and 400 on bad params).
</action>
<verify>
<automated>cd apps/api && pnpm test -- tests/routes/events.test.ts</automated>
@@ -184,21 +186,23 @@ shared-family calendar (open question A3) — the operator marks it.
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → /api/events | start/end query params are untrusted input crossing into SQL |
| cached VEVENT → expansion | rawVevent originates from Fastmail; parsed by ical.js |
| Boundary | Description |
| ------------------------- | ------------------------------------------------------------ |
| browser → /api/events | start/end query params are untrusted input crossing into SQL |
| cached VEVENT → expansion | rawVevent originates from Fastmail; parsed by ical.js |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02b-01 | Tampering | start/end query params | mitigate | zod ISO-date regex validation before SQL; Drizzle parameterized queries (no string interpolation) |
| T-02b-02 | Denial of service | unwindowed/overwide fetch | mitigate | start+end required (zod); window hard-capped at 90 days; hasRrule index prevents full-table scan |
| T-02b-03 | Information disclosure | cross-account calendar leakage | mitigate | Route is behind oidcAuthMiddleware (Phase 1); each member's own credential fetched their own collections; no other-account ACL path exists (CAL-08-DECISION) |
| T-02b-04 | Tampering | malformed rawVevent | accept | expandOccurrences try/catch returns [] on parse failure; matches sync.ts resilience; no crash |
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ---------------------- | ------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| T-02b-01 | Tampering | start/end query params | mitigate | zod ISO-date regex validation before SQL; Drizzle parameterized queries (no string interpolation) |
| T-02b-02 | Denial of service | unwindowed/overwide fetch | mitigate | start+end required (zod); window hard-capped at 90 days; hasRrule index prevents full-table scan |
| T-02b-03 | Information disclosure | cross-account calendar leakage | mitigate | Route is behind oidcAuthMiddleware (Phase 1); each member's own credential fetched their own collections; no other-account ACL path exists (CAL-08-DECISION) |
| T-02b-04 | Tampering | malformed rawVevent | accept | expandOccurrences try/catch returns [] on parse failure; matches sync.ts resilience; no crash |
</threat_model>
<verification>
@@ -208,20 +212,22 @@ shared-family calendar (open question A3) — the operator marks it.
</verification>
<success_criteria>
- /api/events returns windowed, color-tagged, DST-correct, all-day-safe, EXDATE-aware occurrences
- Each occurrence carries ownerUserId + isShared for client-side color routing
- Bad/oversized windows rejected with 400
- Shared-family calendar marked and verified end-to-end
</success_criteria>
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 02)
- `expandOccurrences` (function) + `CalendarOccurrence` (interface) — apps/api/src/broker/expand.ts
- Evolved `eventsRouter` GET / handler with `{ occurrences }` response shape — apps/api/src/routes/events.ts
- `eventsQuerySchema` (zod) for start/end validation
- New JSON contract field set: id, uid, calendarId, calendarName, ownerUserId, color, isShared, title, start, end, allDay, location, description
</artifacts_produced>
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-02-SUMMARY.md` when done
@@ -1,6 +1,6 @@
---
phase: 02-calendar-display
plan: "02"
plan: '02'
subsystem: api-expansion, api-events
tags: [recurrence-expansion, dst-correctness, windowed-query, color-join, zod-validation]
dependency_graph:
@@ -22,12 +22,12 @@ key_files:
- apps/api/src/routes/events.ts
- apps/api/tests/routes/events.test.ts
decisions:
- "ICAL.TimezoneService.register(timezone, name) arg order: first arg is Timezone object, second is optional name string — inverted from research pseudocode which incorrectly placed tzid first"
- "events.test.ts needed @hono/oidc-auth mock — oidcAuthMiddleware throws HTTP 500 when OIDC env vars are absent, blocking all route tests; added passthrough mock as Rule 3 fix"
- "Task 3 (shared-family calendar marking) deferred by operator: id=1 is the operator personal calendar, dedicated shared Family calendar does not exist yet — is_shared stays false for all current rows"
- 'ICAL.TimezoneService.register(timezone, name) arg order: first arg is Timezone object, second is optional name string — inverted from research pseudocode which incorrectly placed tzid first'
- 'events.test.ts needed @hono/oidc-auth mock — oidcAuthMiddleware throws HTTP 500 when OIDC env vars are absent, blocking all route tests; added passthrough mock as Rule 3 fix'
- 'Task 3 (shared-family calendar marking) deferred by operator: id=1 is the operator personal calendar, dedicated shared Family calendar does not exist yet — is_shared stays false for all current rows'
metrics:
duration: "22m"
completed: "2026-06-05"
duration: '22m'
completed: '2026-06-05'
tasks_completed: 2
tasks_deferred: 1
files_created: 1
@@ -45,6 +45,7 @@ Server-side recurrence expansion with DST-correct VTIMEZONE registration, all-da
New file exporting `CalendarOccurrence` interface and `expandOccurrences()` function.
**Interface `CalendarOccurrence`** — carries all fields the Schedule-X frontend needs:
- `id`: `${uid}::${startIso}` stable identity
- `ownerUserId`: load-bearing client field; Schedule-X calendarId = `isShared ? 'shared' : String(ownerUserId)`
- `isShared`: from calendar row, stamped on every occurrence from meta
@@ -52,6 +53,7 @@ New file exporting `CalendarOccurrence` interface and `expandOccurrences()` func
- `allDay`, `color`, `calendarId`, `calendarName`, `uid`, `title`, `location`, `description`
**Implementation contracts met:**
1. `ICAL.parse()` in try/catch — malformed input returns `[]` without throwing
2. VTIMEZONE registration loop runs before `new ICAL.RecurExpansion(...)` — mandatory for DST correctness (Pitfall 3)
3. Non-recurring: single occurrence check against [windowStart, windowEnd)
@@ -61,6 +63,7 @@ New file exporting `CalendarOccurrence` interface and `expandOccurrences()` func
7. No `import ... 'rrule'` anywhere in expand.ts
**Test results (expand.test.ts — 3/3 green):**
- DST: `T10:00:00` present in every March 2026 occurrence across EST→EDT boundary
- All-day: `allDay:true`, `start === '2026-06-15'`, no `T` in string
- EXDATE: 4 occurrences returned (not 5), June 15 absent
@@ -70,6 +73,7 @@ New file exporting `CalendarOccurrence` interface and `expandOccurrences()` func
Rewrote `eventsRouter.get('/')` from raw table dump to display-ready windowed endpoint.
**Zod validation:**
- `eventsQuerySchema`: `start` and `end` each required, validated as `/^\d{4}-\d{2}-\d{2}$/`
- `zValidator('query', eventsQuerySchema)` — 400 returned automatically on schema failure
- Post-schema: 90-day window cap returns 400 if span exceeds limit (T-02b-02 DoS guard)
@@ -79,6 +83,7 @@ Rewrote `eventsRouter.get('/')` from raw table dump to display-ready windowed en
**WHERE pre-filter (RESEARCH.md Open Q3 / Pitfall 5):**
Three-branch OR covering:
1. `hasRrule=1 AND dtstartUtc < windowEnd` — recurring masters from any date
2. `hasRrule=0 AND dtstartUtc IN [windowStart, windowEnd)` — non-recurring timed events
3. `dtstartDate IN [start, end)` — all-day events (DATE comparison)
@@ -92,6 +97,7 @@ Three-branch OR covering:
**Broker-boundary invariant preserved:** No tsdav / createFastmailClient import.
**Test results (events.test.ts — 4/4 green):**
- 400 on missing start
- 400 on missing end
- 400 on malformed date
@@ -114,12 +120,14 @@ The `calendars.is_shared` column exists (added in Plan 01, default false). The r
### Auto-fixed Issues
**1. [Rule 1 - Bug] ICAL.TimezoneService.register() argument order**
- **Found during:** Task 1 typecheck
- **Issue:** Research pseudocode showed `register(tzid, timezone)` but the actual API is `register(timezone, name?)` — tzid-first call causes TS2345 type error
- **Fix:** Swapped to `register(new ICAL.Timezone({ component: vtz, tzid }), tzid)`
- **Files modified:** apps/api/src/broker/expand.ts
**2. [Rule 3 - Blocker] @hono/oidc-auth throws 500 in test environment**
- **Found during:** Task 2 (events test execution)
- **Issue:** `oidcAuthMiddleware()` calls `throw new HTTPException(500, ...)` when `OIDC_AUTH_SECRET` env var is absent. The RED stub's test imports `app` from `src/index.js` which mounts `oidcAuthMiddleware`, so all `/api/events` requests get 500 before reaching the route handler.
- **Fix:** Added `vi.mock('@hono/oidc-auth', ...)` passthrough mock to events.test.ts, making `oidcAuthMiddleware` a no-op in the test environment. Same pattern works for future route tests that use app.request().
@@ -141,16 +149,20 @@ No new threat surface beyond the plan's threat model.
## Self-Check: PASSED
Files created:
- [x] apps/api/src/broker/expand.ts — FOUND
Files modified:
- [x] apps/api/src/routes/events.ts — FOUND
- [x] apps/api/tests/routes/events.test.ts — FOUND
Commits:
- [x] 6736194 — feat(02-02): expandOccurrences Task 1
- [x] 9ee26c0 — feat(02-02): windowed events route Task 2
Test suite:
- [x] pnpm --filter @familysync/api test — 34/34 passed
- [x] pnpm --filter @familysync/api typecheck — clean
@@ -3,7 +3,7 @@ phase: 02-calendar-display
plan: 03
type: execute
wave: 2
depends_on: ["02-01"]
depends_on: ['02-01']
files_modified:
- apps/pwa/src/styles/tokens.css
- apps/pwa/src/styles/tokens.ts
@@ -24,40 +24,40 @@ user_setup: []
must_haves:
truths:
- "A CSS custom-property token layer (the clean theme) defines all colors, spacing, typography, breakpoints from UI-SPEC; no hard-coded hex/px will be needed by components — tuned to stay legible/informational at tablet distance, not ultra-minimal (D-03)"
- "Schedule-X --sx-color-* vars are mapped to project tokens so no Schedule-X default colors bleed through"
- "colorUtils derives Schedule-X lightColors (main/container/onContainer) from a member hex"
- 'A CSS custom-property token layer (the clean theme) defines all colors, spacing, typography, breakpoints from UI-SPEC; no hard-coded hex/px will be needed by components — tuned to stay legible/informational at tablet distance, not ultra-minimal (D-03)'
- 'Schedule-X --sx-color-* vars are mapped to project tokens so no Schedule-X default colors bleed through'
- 'colorUtils derives Schedule-X lightColors (main/container/onContainer) from a member hex'
- "calendarConfig translates WEEK_START_DAY=0 (Sunday/JS) to Schedule-X firstDayOfWeek=7 (Temporal Sunday) and builds the per-calendar config keyed by String(userId) + 'shared'"
- "hydrateEvents converts all-day occurrences to Temporal.PlainDate and timed occurrences to Temporal.ZonedDateTime"
- 'hydrateEvents converts all-day occurrences to Temporal.PlainDate and timed occurrences to Temporal.ZonedDateTime'
- "hydrateEvents routes each event's Schedule-X calendarId to 'shared' (isShared) or String(ownerUserId), matching the userId-keyed calendars config — never String(calendarId)"
- "calendarStore (Zustand) holds selectedView (persisted per breakpoint group), selectedDate, openEventId, calendarRange — no server data"
- "fetchEvents(start,end) calls the windowed /api/events with credentials:include and returns OccurrencesResponse"
- 'calendarStore (Zustand) holds selectedView (persisted per breakpoint group), selectedDate, openEventId, calendarRange — no server data'
- 'fetchEvents(start,end) calls the windowed /api/events with credentials:include and returns OccurrencesResponse'
artifacts:
- path: "apps/pwa/src/styles/tokens.css"
provides: "clean-theme CSS custom properties + Schedule-X var overrides"
contains: "--color-shared-family"
- path: "apps/pwa/src/lib/calendarConfig.ts"
provides: "WEEK_START_DAY, firstDayOfWeek translation, buildCalendarConfig()"
exports: ["WEEK_START_DAY", "buildCalendarConfig"]
- path: "apps/pwa/src/lib/hydrateEvents.ts"
provides: "hydrateEvents() ISO→Temporal with all-day PlainDate guard + isShared/ownerUserId calendarId routing"
exports: ["hydrateEvents"]
- path: "apps/pwa/src/store/calendarStore.ts"
provides: "Zustand UI-state store with localStorage view persistence"
exports: ["useCalendarStore"]
- path: 'apps/pwa/src/styles/tokens.css'
provides: 'clean-theme CSS custom properties + Schedule-X var overrides'
contains: '--color-shared-family'
- path: 'apps/pwa/src/lib/calendarConfig.ts'
provides: 'WEEK_START_DAY, firstDayOfWeek translation, buildCalendarConfig()'
exports: ['WEEK_START_DAY', 'buildCalendarConfig']
- path: 'apps/pwa/src/lib/hydrateEvents.ts'
provides: 'hydrateEvents() ISO→Temporal with all-day PlainDate guard + isShared/ownerUserId calendarId routing'
exports: ['hydrateEvents']
- path: 'apps/pwa/src/store/calendarStore.ts'
provides: 'Zustand UI-state store with localStorage view persistence'
exports: ['useCalendarStore']
key_links:
- from: "apps/pwa/src/main.tsx"
to: "temporal-polyfill/global"
via: "import before any Schedule-X mount"
pattern: "temporal-polyfill/global"
- from: "apps/pwa/src/lib/calendarConfig.ts"
to: "apps/pwa/src/lib/colorUtils.ts"
via: "deriveScheduleXColors() for lightColors"
pattern: "deriveScheduleXColors"
- from: "apps/pwa/src/lib/hydrateEvents.ts"
to: "apps/pwa/src/lib/calendarConfig.ts"
- from: 'apps/pwa/src/main.tsx'
to: 'temporal-polyfill/global'
via: 'import before any Schedule-X mount'
pattern: 'temporal-polyfill/global'
- from: 'apps/pwa/src/lib/calendarConfig.ts'
to: 'apps/pwa/src/lib/colorUtils.ts'
via: 'deriveScheduleXColors() for lightColors'
pattern: 'deriveScheduleXColors'
- from: 'apps/pwa/src/lib/hydrateEvents.ts'
to: 'apps/pwa/src/lib/calendarConfig.ts'
via: "calendarId = isShared ? 'shared' : String(ownerUserId) matches buildCalendarConfig keys"
pattern: "ownerUserId"
pattern: 'ownerUserId'
---
<objective>
@@ -109,6 +109,7 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
Create `apps/pwa/src/styles/index.css` importing tokens.css, plus a minimal global reset (box-sizing border-box, body font-family var, margin 0) — no third-party reset library.
Update `apps/pwa/src/main.tsx`: as the FIRST three imports (before React), add `import 'temporal-polyfill/global'`, `import '@schedule-x/theme-default/dist/index.css'`, `import './styles/index.css'` (in that order — Temporal must register before any Schedule-X usage, and token overrides must come after the Schedule-X default CSS so they win). Leave the QueryClientProvider tree intact.
</action>
<verify>
<automated>cd apps/pwa && grep -q "temporal-polyfill/global" src/main.tsx && grep -q "@schedule-x/theme-default/dist/index.css" src/main.tsx && grep -q "./styles/index.css" src/main.tsx && echo MAIN_IMPORTS_OK</automated>
@@ -143,6 +144,7 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
Create `apps/pwa/src/lib/colorUtils.ts` exporting `hexToContainer(hex)` (main at 15% opacity blended over #FFFFFF → returns a hex/rgb string), `hexToOnContainer(hex)` (main darkened 40%), and `deriveScheduleXColors(main)` returning `{ main, container, onContainer }`. Implement the math inline (no third-party color lib per RESEARCH Don't-Hand-Roll note — it's simple enough). Write colorUtils.test.ts asserting the derivations for a known hex.
Create `apps/pwa/src/lib/calendarConfig.ts` exporting `export const WEEK_START_DAY = 0` with the inline comment that Schedule-X uses 7=Sunday, a translation `const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY` exposed as an exported `SX_FIRST_DAY_OF_WEEK`, the view factory list (`createViewDay`, `createViewWeek`, `createViewMonthGrid`, `createViewMonthAgenda` from @schedule-x/calendar), and `buildCalendarConfig(members: MemberCalendarConfig[])` returning `{ calendars }` keyed by String(userId) plus a reserved `'shared'` entry using deriveScheduleXColors('#F25C7A'). Per-member entries use deriveScheduleXColors(member.color). The `String(userId)` + `'shared'` key scheme here is the routing contract hydrateEvents (Task 3) must match — keep them aligned. Limit usage to the confirmed Schedule-X API surface (Pitfall 6). Turn the Plan 01 RED calendarConfig.test.ts green (it asserts the 0→7 translation).
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- src/lib/colorUtils.test.ts src/lib/calendarConfig.test.ts</automated>
@@ -187,6 +189,7 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
Create `apps/pwa/src/store/calendarStore.ts` exporting `useCalendarStore` (Zustand `create`) with state: `selectedView:string`, `selectedDate:string`, `openEventId:string|null`, `calendarRange:{start:string;end:string}` and setters. selectedView is initialized from localStorage keyed by breakpoint group (`window.matchMedia('(max-width:767px)').matches ? 'phone' : 'tablet-desktop'`), defaulting to 'month-agenda' on phone / 'month-grid' on tablet-desktop (D-05); setSelectedView writes back to localStorage under `calendarView.{group}`. calendarRange defaults to the current month ± 1 week (do NOT depend on Schedule-X onRangeUpdate for the first fetch — A4/Open Q2). Server events NEVER enter this store. Add `zustand` to apps/pwa deps if not already present.
In `apps/pwa/src/api/client.ts`, REPLACE the old unwindowed `fetchEvents()` and its `CalendarEvent`/`EventsResponse` types with: `CalendarOccurrence` interface (mirror the server contract — include calendarId, ownerUserId, isShared so hydrateEvents can route), `OccurrencesResponse { occurrences: CalendarOccurrence[] }`, and `fetchEvents(start:string, end:string): Promise<OccurrencesResponse>` calling `/api/events?start=${start}&end=${end}` with `credentials:'include'` and the same `if(!res.ok) throw` pattern as fetchMe. Note: EventProof.tsx referenced the old fetchEvents — leave EventProof for Plan 05 to remove; if the type change breaks its build, update EventProof minimally to compile (it is replaced in Plan 05).
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- src/lib/hydrateEvents.test.ts</automated>
@@ -207,20 +210,22 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| localStorage → store init | persisted view string read at startup |
| Boundary | Description |
| --------------------------- | ----------------------------------------------- |
| localStorage → store init | persisted view string read at startup |
| server JSON → hydrateEvents | occurrence strings parsed into Temporal objects |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02c-01 | Tampering | localStorage selectedView | accept | UI-only state; an invalid stored view falls back to the D-05 default; no security impact (single-device, two-person app) |
| T-02c-02 | Tampering | hydrateEvents string parsing | accept | Temporal.from throws on malformed input surfaced as a React Query error, not a security boundary; data already passed server zod validation |
| T-02c-SC | Tampering | @schedule-x/*, temporal-polyfill, lucide-react, zustand installs | mitigate | All Approved in RESEARCH §Package Legitimacy (mainstream, no postinstall); pinned versions; no [ASSUMED]/[SUS] |
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | --------- | ----------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| T-02c-01 | Tampering | localStorage selectedView | accept | UI-only state; an invalid stored view falls back to the D-05 default; no security impact (single-device, two-person app) |
| T-02c-02 | Tampering | hydrateEvents string parsing | accept | Temporal.from throws on malformed input surfaced as a React Query error, not a security boundary; data already passed server zod validation |
| T-02c-SC | Tampering | @schedule-x/\*, temporal-polyfill, lucide-react, zustand installs | mitigate | All Approved in RESEARCH §Package Legitimacy (mainstream, no postinstall); pinned versions; no [ASSUMED]/[SUS] |
</threat_model>
<verification>
@@ -230,26 +235,28 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
</verification>
<success_criteria>
- Clean-theme token layer + Schedule-X overrides present (D-01/D-02)
- firstDayOfWeek 0→7 translation encoded; per-calendar config built from member colors
- All-day Temporal PlainDate guard in place
- hydrateEvents calendarId routes by isShared/ownerUserId to match buildCalendarConfig keys
- Zustand UI store + windowed fetchEvents ready for Plan 04
</success_criteria>
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 03)
- CSS custom properties: --color-*, --space-*, --text-*, --font-family-base, --bp-*, --sx-color-* overrides (tokens.css)
- CSS custom properties: --color-_, --space-_, --text-_, --font-family-base, --bp-_, --sx-color-\* overrides (tokens.css)
- token object export (tokens.ts); styles/index.css global reset
- `hexToContainer`, `hexToOnContainer`, `deriveScheduleXColors` (colorUtils.ts)
- `WEEK_START_DAY`, `SX_FIRST_DAY_OF_WEEK`, `buildCalendarConfig`, `MemberCalendarConfig` (calendarConfig.ts)
- `hydrateEvents`, `ScheduleXEvent` (hydrateEvents.ts) — calendarId routed by isShared/ownerUserId
- `useCalendarStore` Zustand store + CalendarStore state shape (calendarStore.ts)
- `fetchEvents(start,end)`, `CalendarOccurrence`, `OccurrencesResponse` (client.ts — replaces old unwindowed versions)
- @schedule-x/* + temporal-polyfill + lucide-react + zustand dependencies
- @schedule-x/\* + temporal-polyfill + lucide-react + zustand dependencies
- main.tsx Temporal-polyfill-first import block
</artifacts_produced>
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-03-SUMMARY.md` when done
@@ -66,11 +66,13 @@ CSS custom-property token layer with Schedule-X overrides, hex-blend color utili
### Task 1: Schedule-X Stack + Token Layer + main.tsx Import Order
**Installed packages** in `apps/pwa`:
- `@schedule-x/calendar@4.6.0`, `@schedule-x/react@4.1.0`, `@schedule-x/theme-default@4.6.0`
- `@schedule-x/event-modal@4.6.0`, `@schedule-x/events-service@4.6.0`
- `temporal-polyfill@0.3.2`, `lucide-react@1.17.0`
**`apps/pwa/src/styles/tokens.css`** — CSS custom properties declaring:
- Surface/border/text palette: `--color-surface`, `--color-surface-dim`, `--color-surface-raised`, `--color-border`, `--color-border-subtle`, `--color-text-primary/secondary/muted`, `--color-focus-ring`, `--color-overlay`
- Calendar colors: `--color-member-0..5` + `--color-shared-family: #F25C7A` + `--color-destructive`
- Spacing scale: `--space-1..12` (multiples of 4px)
@@ -84,6 +86,7 @@ CSS custom-property token layer with Schedule-X overrides, hex-blend color utili
**`apps/pwa/src/styles/index.css`** — imports tokens.css + minimal global reset.
**`apps/pwa/src/main.tsx`** — updated with load-bearing import order:
1. `import 'temporal-polyfill/global'` (must be first)
2. `import '@schedule-x/theme-default/dist/index.css'` (SX layout CSS)
3. `import './styles/index.css'` (token overrides win cascade)
@@ -91,11 +94,13 @@ CSS custom-property token layer with Schedule-X overrides, hex-blend color utili
### Task 2: colorUtils + calendarConfig — RED Stubs Turned GREEN
**`apps/pwa/src/lib/colorUtils.ts`** exports:
- `hexToContainer(hex)` — alpha blends at 15% opacity over white
- `hexToOnContainer(hex)` — darkens 40% (channel multiply by 0.6)
- `deriveScheduleXColors(main)` returning `{ main, container, onContainer }`
**`apps/pwa/src/lib/calendarConfig.ts`** exports:
- `WEEK_START_DAY = 0` (JS Sunday convention)
- `SX_FIRST_DAY_OF_WEEK = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY`
- `buildCalendarConfig(members)` returning `{ firstDayOfWeek: 7, calendars }` with `'shared'` (rose) + per-member entries keyed by `String(userId)`
@@ -105,6 +110,7 @@ CSS custom-property token layer with Schedule-X overrides, hex-blend color utili
### Task 3: hydrateEvents + calendarStore + windowed fetchEvents — RED Stubs Turned GREEN
**`apps/pwa/src/lib/hydrateEvents.ts`**:
- `allDay:true` uses `Temporal.PlainDate.from(occ.start)` (guards all-day date shift)
- `allDay:false` uses `Temporal.ZonedDateTime.from(occ.start/end)`
- `calendarId = occ.isShared ? 'shared' : String(occ.ownerUserId)` — never `String(occ.calendarId)`
@@ -113,11 +119,13 @@ CSS custom-property token layer with Schedule-X overrides, hex-blend color utili
`hydrateEvents.test.ts` (Plan 01 RED stubs) — all 4 assertions now GREEN.
**`apps/pwa/src/store/calendarStore.ts`** Zustand store:
- `selectedView` — from localStorage per breakpoint group; D-05 defaults
- `calendarRange` — month ± 7-day buffer for initial TanStack Query key
- `openEventId`, `selectedDate` — UI-only, not persisted
**`apps/pwa/src/api/client.ts`**:
- Added `CalendarOccurrence`, `OccurrencesResponse`, `fetchEvents(start, end)`
- Kept deprecated `CalendarEvent`, `EventsResponse`, `fetchEventsLegacy()` for EventProof.tsx (removed Plan 05)
@@ -131,6 +139,7 @@ tsc --noEmit: clean (0 errors)
```
All Wave 1 RED stubs are GREEN:
- `calendarConfig.test.ts` — 4/4 pass
- `hydrateEvents.test.ts` — 4/4 pass
- `colorUtils.test.ts` — 10/10 pass
@@ -140,6 +149,7 @@ All Wave 1 RED stubs are GREEN:
### Auto-fixed Issues
**1. [Rule 2 - Missing Critical Functionality] Added temporal-polyfill/global to hydrateEvents.test.ts**
- **Found during:** Task 3 test run
- **Issue:** Plan 01 RED stub lacked `import 'temporal-polyfill/global'`; jsdom has no native Temporal
- **Fix:** Added as first import in `hydrateEvents.test.ts`
@@ -147,6 +157,7 @@ All Wave 1 RED stubs are GREEN:
- **Commit:** f377d7c
**2. [Rule 2 - Missing Critical Functionality] Added fetchEventsLegacy() to preserve EventProof**
- **Found during:** Task 3 — updating client.ts
- **Issue:** EventProof.tsx called no-arg `fetchEvents()` and used `CalendarEvent` fields not on `CalendarOccurrence`
- **Fix:** Added `fetchEventsLegacy()` (deprecated) + updated EventProof to use it; plan says it is replaced in Plan 05
@@ -164,6 +175,7 @@ No new threat surface beyond the plan's threat model. All packages pre-approved
## Self-Check: PASSED
Files created:
- [x] apps/pwa/src/styles/tokens.css
- [x] apps/pwa/src/styles/tokens.ts
- [x] apps/pwa/src/styles/index.css
@@ -174,6 +186,7 @@ Files created:
- [x] apps/pwa/src/store/calendarStore.ts
Commits:
- [x] 0911a23 — Task 1: Schedule-X stack + token layer + main.tsx
- [x] 43554f4 — Task 2: colorUtils + calendarConfig; calendarConfig stubs GREEN
- [x] f377d7c — Task 3: hydrateEvents + calendarStore + windowed fetchEvents; all stubs GREEN
@@ -3,7 +3,7 @@ phase: 02-calendar-display
plan: 04
type: execute
wave: 3
depends_on: ["02-02", "02-03"]
depends_on: ['02-02', '02-03']
files_modified:
- apps/pwa/src/components/CalendarShell.tsx
- apps/pwa/src/App.tsx
@@ -14,32 +14,32 @@ user_setup: []
must_haves:
truths:
- "Opening the app renders a Schedule-X calendar populated with REAL windowed Fastmail occurrences fetched from /api/events"
- 'Opening the app renders a Schedule-X calendar populated with REAL windowed Fastmail occurrences fetched from /api/events'
- "Events render in their owner's member color; shared-family events render in the reserved rose color (D-06)"
- "The user can switch between day, week, month, and agenda views and events render in each (D-04)"
- "Recurring events show all in-window occurrences; all-day events appear as full-day banners on the correct date with no shift"
- "The visible window drives the TanStack Query key; navigating to a new window refetches"
- "Default view is agenda on phone and month on tablet/desktop (D-05)"
- 'The user can switch between day, week, month, and agenda views and events render in each (D-04)'
- 'Recurring events show all in-window occurrences; all-day events appear as full-day banners on the correct date with no shift'
- 'The visible window drives the TanStack Query key; navigating to a new window refetches'
- 'Default view is agenda on phone and month on tablet/desktop (D-05)'
artifacts:
- path: "apps/pwa/src/components/CalendarShell.tsx"
provides: "Schedule-X calendar wired to TanStack Query + hydrateEvents + Zustand range"
- path: 'apps/pwa/src/components/CalendarShell.tsx'
provides: 'Schedule-X calendar wired to TanStack Query + hydrateEvents + Zustand range'
min_lines: 60
- path: "apps/pwa/src/App.tsx"
provides: "renders CalendarShell as the app root (replaces EventProof landing)"
contains: "CalendarShell"
- path: 'apps/pwa/src/App.tsx'
provides: 'renders CalendarShell as the app root (replaces EventProof landing)'
contains: 'CalendarShell'
key_links:
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "/api/events"
- from: 'apps/pwa/src/components/CalendarShell.tsx'
to: '/api/events'
via: "useQuery(['events',start,end]) → fetchEvents"
pattern: "fetchEvents"
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "Schedule-X eventsService"
via: "eventsService.set(hydrateEvents(data.occurrences))"
pattern: "hydrateEvents"
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "apps/pwa/src/store/calendarStore.ts"
via: "calendarRange drives query key; onRangeUpdate updates it"
pattern: "useCalendarStore"
pattern: 'fetchEvents'
- from: 'apps/pwa/src/components/CalendarShell.tsx'
to: 'Schedule-X eventsService'
via: 'eventsService.set(hydrateEvents(data.occurrences))'
pattern: 'hydrateEvents'
- from: 'apps/pwa/src/components/CalendarShell.tsx'
to: 'apps/pwa/src/store/calendarStore.ts'
via: 'calendarRange drives query key; onRangeUpdate updates it'
pattern: 'useCalendarStore'
---
<objective>
@@ -95,6 +95,7 @@ Output: CalendarShell.tsx wired to the full data pipeline; App.tsx renders it; a
Render `<ScheduleXCalendar calendarApp={calendar} />` filling the available space. Use token-based styling only (className/var(--token)) — no hard-coded hex/px (Phase 2 rule). The AppNav/ViewToolbar/ColorLegend/popover chrome is Plan 05; CalendarShell here may render a minimal toolbar placeholder or rely on Schedule-X's built-in controls so the four views are switchable and verifiable now.
Update `apps/pwa/src/App.tsx`: replace the EventProof landing content with `<CalendarShell />` as the app root. Migrate any remaining hard-coded hex/px in App.tsx to tokens (Phase 2 rule). Leave the meQuery sign-in-required error branch intact for unauthenticated state.
</action>
<verify>
<automated>cd apps/pwa && grep -q "ScheduleXCalendar" src/components/CalendarShell.tsx && grep -q "hydrateEvents" src/components/CalendarShell.tsx && grep -q "queryKey: \['events'" src/components/CalendarShell.tsx && grep -q "CalendarShell" src/App.tsx && echo SHELL_WIRED</automated>
@@ -143,18 +144,20 @@ Output: CalendarShell.tsx wired to the full data pipeline; App.tsx renders it; a
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Boundary | Description |
| ---------------------------------- | -------------------------------------------------- |
| /api/events JSON → calendar render | server occurrences rendered into the DOM via React |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02d-01 | Tampering (XSS) | event title/location/description in render | mitigate | React JSX default escaping; never dangerouslySetInnerHTML for event fields (carried into Plan 05 popover) |
| T-02d-02 | Information disclosure | events from another member's account | accept | API already enforces auth + per-credential scoping (Plan 02 / CAL-08-DECISION); client renders only what the authed endpoint returns |
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ---------------------- | ------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| T-02d-01 | Tampering (XSS) | event title/location/description in render | mitigate | React JSX default escaping; never dangerouslySetInnerHTML for event fields (carried into Plan 05 popover) |
| T-02d-02 | Information disclosure | events from another member's account | accept | API already enforces auth + per-credential scoping (Plan 02 / CAL-08-DECISION); client renders only what the authed endpoint returns |
</threat_model>
<verification>
@@ -164,19 +167,21 @@ Output: CalendarShell.tsx wired to the full data pipeline; App.tsx renders it; a
</verification>
<success_criteria>
- Real Fastmail occurrences render color-coded across day/week/month/agenda (CAL-02, CAL-03)
- Recurring + all-day occurrences render correctly in-window (CAL-07 display)
- Window navigation refetches via TanStack Query
</success_criteria>
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 04)
- `CalendarShell` (React component) — apps/pwa/src/components/CalendarShell.tsx
- App.tsx now renders CalendarShell as root (EventProof landing removed from render path)
- CalendarShell.test.tsx (CAL-03 render smoke)
- Schedule-X eventsService + eventModal plugin instances + useCalendarApp config in CalendarShell
</artifacts_produced>
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-04-SUMMARY.md` when done
@@ -1,12 +1,12 @@
---
phase: 02-calendar-display
plan: "04"
plan: '04'
subsystem: pwa-calendar-shell
tags: [schedule-x, tanstack-query, zustand, hydrate-events, temporal, calendar-shell, smoke-test]
dependency_graph:
requires: ["02-02", "02-03"]
requires: ['02-02', '02-03']
provides: [CalendarShell, App-root-calendar, CAL-03-smoke-test]
affects: ["02-05"]
affects: ['02-05']
tech_stack:
added: []
patterns:
@@ -24,13 +24,13 @@ key_files:
- apps/pwa/src/App.tsx
- apps/pwa/vitest.config.ts
decisions:
- "CalendarShell callbacks nested under config.callbacks per CalendarConfigExternal type (not top-level)"
- "DateRange.start/end are Temporal.ZonedDateTime; extracted to ISO date via .toPlainDate().toString() for Zustand"
- "test-setup.ts as vitest setupFiles for window.matchMedia polyfill — calendarStore creates Zustand store at module load time before test polyfills run"
- "App.tsx simplified to single-line wrapper; EventProof and health probe removed from render path"
- 'CalendarShell callbacks nested under config.callbacks per CalendarConfigExternal type (not top-level)'
- 'DateRange.start/end are Temporal.ZonedDateTime; extracted to ISO date via .toPlainDate().toString() for Zustand'
- 'test-setup.ts as vitest setupFiles for window.matchMedia polyfill — calendarStore creates Zustand store at module load time before test polyfills run'
- 'App.tsx simplified to single-line wrapper; EventProof and health probe removed from render path'
metrics:
duration: "~12m"
completed: "2026-06-05"
duration: '~12m'
completed: '2026-06-05'
tasks_completed: 2
files_created: 3
files_modified: 2
@@ -45,6 +45,7 @@ Schedule-X CalendarShell component wired to TanStack Query windowed fetch, hydra
### Task 1: CalendarShell + App.tsx
**`apps/pwa/src/components/CalendarShell.tsx`** (186 lines):
- `useCalendarApp(config, [eventsService, eventModal])` with all four view factories: `createViewDay`, `createViewWeek`, `createViewMonthGrid`, `createViewMonthAgenda`
- `defaultView` from Zustand persisted view (D-05 defaults: phone→month-agenda, tablet-desktop→month-grid already encoded in store)
- `firstDayOfWeek: SX_FIRST_DAY_OF_WEEK` (7 = Sunday, Temporal convention) — Pitfall 1 guard
@@ -61,6 +62,7 @@ Schedule-X CalendarShell component wired to TanStack Query windowed fetch, hydra
### Task 2: CalendarShell Render Smoke Test (CAL-03)
**`apps/pwa/src/components/CalendarShell.test.tsx`** (6 tests):
- Render-without-throw smoke (validates `@schedule-x/react@4.1.0``@schedule-x/calendar@4.6.0` import compatibility — Pitfall 6)
- `ScheduleXCalendar` mounts with non-null `calendarApp`
- `hydrateEvents` called with both timed + all-day occurrences; `eventsService.set()` called with hydrated events
@@ -87,6 +89,7 @@ vite build: clean (474.27 kB, built in 395ms)
### Auto-fixed Issues
**1. [Rule 1 - Bug] CalendarCallbacks nested under config.callbacks — not top-level**
- **Found during:** Task 1 — tsc reported `onRangeUpdate` not in `CalendarConfigExternal`
- **Issue:** Research pattern (RESEARCH.md Pattern 4) showed `onRangeUpdate` at the top level of the config object. The actual type (`CalendarConfigExternal extends Partial<ReducedCalendarConfigInternal>`) carries `callbacks?: CalendarCallbacks` where `CalendarCallbacks` contains `onRangeUpdate` and `onEventClick`. They must be nested under `config.callbacks`.
- **Fix:** Moved `onRangeUpdate` and `onEventClick` into `callbacks: { ... }` in the `useCalendarApp` config
@@ -94,6 +97,7 @@ vite build: clean (474.27 kB, built in 395ms)
- **Commit:** b79f649
**2. [Rule 3 - Blocking] window.matchMedia not defined in jsdom**
- **Found during:** Task 2 — test run crashed at Zustand store initialisation
- **Issue:** `calendarStore.ts` calls `window.matchMedia` inside `readPersistedView()` which runs at `create()` time — i.e. at module load, before any test-file-level polyfill runs. Inline `Object.defineProperty` in the test file is too late.
- **Fix:** Created `src/test-setup.ts` with the polyfill; added `setupFiles: ['./src/test-setup.ts']` to `vitest.config.ts`
@@ -107,15 +111,18 @@ None — CalendarShell fetches real windowed data from `/api/events`, hydrates t
## Threat Flags
No new threat surface beyond the plan's threat model.
- T-02d-01 (XSS): CalendarShell uses React JSX default escaping for all event field rendering — no `dangerouslySetInnerHTML`. Carried to Plan 05 popover.
## Self-Check: PASSED
Files created:
- [x] apps/pwa/src/components/CalendarShell.tsx
- [x] apps/pwa/src/components/CalendarShell.test.tsx
- [x] apps/pwa/src/test-setup.ts
Commits:
- [x] b79f649 — Task 1: CalendarShell + App.tsx
- [x] f0af43c — Task 2: CalendarShell smoke test
@@ -3,7 +3,7 @@ phase: 02-calendar-display
plan: 05
type: execute
wave: 4
depends_on: ["02-04"]
depends_on: ['02-04']
files_modified:
- apps/pwa/src/components/EventDetailPopover.tsx
- apps/pwa/src/components/ColorLegend.tsx
@@ -20,30 +20,30 @@ user_setup: []
must_haves:
truths:
- "Tapping any event opens a read-only detail popover (title, date/time, location, description, calendar name + owner color) built for Phase 3 reuse as the edit surface"
- 'Tapping any event opens a read-only detail popover (title, date/time, location, description, calendar name + owner color) built for Phase 3 reuse as the edit surface'
- "A color legend (member → color, plus 'Family' rose row) is always visible so ownership is decodable"
- "Initial load shows a shimmer skeleton; a successful fetch with zero events shows the empty state; a failed fetch shows the error state with a working Retry"
- "All chrome (AppNav, ViewToolbar, legend) is token-styled with 44px minimum touch targets and is keyboard/focus accessible"
- "The popover never renders event fields via dangerouslySetInnerHTML (XSS guard) and traps focus with Escape-to-close"
- 'Initial load shows a shimmer skeleton; a successful fetch with zero events shows the empty state; a failed fetch shows the error state with a working Retry'
- 'All chrome (AppNav, ViewToolbar, legend) is token-styled with 44px minimum touch targets and is keyboard/focus accessible'
- 'The popover never renders event fields via dangerouslySetInnerHTML (XSS guard) and traps focus with Escape-to-close'
artifacts:
- path: "apps/pwa/src/components/EventDetailPopover.tsx"
provides: "read-only event detail popover; Phase-3-reusable edit surface; focus trap + XSS-safe"
exports: ["EventDetailPopover"]
- path: "apps/pwa/src/components/ColorLegend.tsx"
provides: "always-visible member→color legend with Family row"
exports: ["ColorLegend"]
- path: "apps/pwa/src/components/SkeletonCalendar.tsx"
provides: "shimmer loading skeleton (month + agenda variants)"
exports: ["SkeletonCalendar"]
- path: 'apps/pwa/src/components/EventDetailPopover.tsx'
provides: 'read-only event detail popover; Phase-3-reusable edit surface; focus trap + XSS-safe'
exports: ['EventDetailPopover']
- path: 'apps/pwa/src/components/ColorLegend.tsx'
provides: 'always-visible member→color legend with Family row'
exports: ['ColorLegend']
- path: 'apps/pwa/src/components/SkeletonCalendar.tsx'
provides: 'shimmer loading skeleton (month + agenda variants)'
exports: ['SkeletonCalendar']
key_links:
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "apps/pwa/src/components/EventDetailPopover.tsx"
via: "customComponents.eventModal + openEventId from Zustand"
pattern: "EventDetailPopover"
- from: "apps/pwa/src/components/CalendarShell.tsx"
to: "SkeletonCalendar | EmptyState | error state"
via: "TanStack Query isLoading/empty/isError branches"
pattern: "SkeletonCalendar"
- from: 'apps/pwa/src/components/CalendarShell.tsx'
to: 'apps/pwa/src/components/EventDetailPopover.tsx'
via: 'customComponents.eventModal + openEventId from Zustand'
pattern: 'EventDetailPopover'
- from: 'apps/pwa/src/components/CalendarShell.tsx'
to: 'SkeletonCalendar | EmptyState | error state'
via: 'TanStack Query isLoading/empty/isError branches'
pattern: 'SkeletonCalendar'
---
<objective>
@@ -98,6 +98,7 @@ EventProof removed; human-verify checkpoint.
Wire into CalendarShell: pass `customComponents={{ eventModal: EventDetailPopover }}` to `<ScheduleXCalendar>`, and ensure the event-click path sets openEventId so the popover opens. Keep the eventsService/eventModal plugin wiring from Plan 04.
Write `EventDetailPopover.test.tsx` (jsdom): renders an event's fields as text, Escape triggers close, and asserts no dangerouslySetInnerHTML usage (render a title containing an HTML-looking string and assert it appears escaped as text).
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- src/components/EventDetailPopover.test.tsx</automated>
@@ -134,6 +135,7 @@ EventProof removed; human-verify checkpoint.
In CalendarShell, render AppNav + ViewToolbar + ColorLegend chrome around `<ScheduleXCalendar>` (grid is the primary focal point per reviewer note). Branch on the events query: `isLoading` (initial) → SkeletonCalendar; success + `occurrences.length === 0` → EmptyState; `isError` (after retry:2) → error state replacing the grid with heading "Couldn't load events", body "Check your connection and try again.", and a "Retry" button calling `queryClient.refetchQueries({ queryKey: ['events'] })`. All token-styled.
Delete `apps/pwa/src/components/EventProof.tsx` and remove any remaining imports/references to it (Plan 04 removed it from the render path; confirm the file and its imports are gone).
</action>
<verify>
<automated>cd apps/pwa && grep -q "SkeletonCalendar" src/components/CalendarShell.tsx && grep -q "EmptyState" src/components/CalendarShell.tsx && grep -q "Couldn't load events" src/components/CalendarShell.tsx && grep -q "ColorLegend" src/components/CalendarShell.tsx && echo STATES_WIRED</automated>
@@ -174,18 +176,20 @@ EventProof removed; human-verify checkpoint.
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Boundary | Description |
| -------------------------- | ---------------------------------------------------- |
| event fields → popover DOM | title/location/description rendered into the popover |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02e-01 | Tampering (XSS) | EventDetailPopover event fields | mitigate | Plain-text JSX children only; no dangerouslySetInnerHTML; test asserts HTML-looking title renders escaped |
| T-02e-02 | Denial of service | Retry button hammering /api/events | accept | Manual user action, retry:2 backoff already on the query; two-person self-hosted app, negligible risk |
| Threat ID | Category | Component | Disposition | Mitigation Plan |
| --------- | ----------------- | ---------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------- |
| T-02e-01 | Tampering (XSS) | EventDetailPopover event fields | mitigate | Plain-text JSX children only; no dangerouslySetInnerHTML; test asserts HTML-looking title renders escaped |
| T-02e-02 | Denial of service | Retry button hammering /api/events | accept | Manual user action, retry:2 backoff already on the query; two-person self-hosted app, negligible risk |
</threat_model>
<verification>
@@ -195,14 +199,16 @@ EventProof removed; human-verify checkpoint.
</verification>
<success_criteria>
- Color-coded events + legend decode ownership (criterion 1)
- All four views render events correctly (criterion 2)
- Recurring occurrences correct incl. DST (criterion 3)
- All-day events as full-day banners with no shift (criterion 4)
- Tap-to-expand popover + skeleton/empty/error states polished and accessible
</success_criteria>
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 05)
- `EventDetailPopover` (React component, Phase-3-reusable edit surface) — EventDetailPopover.tsx
@@ -210,7 +216,7 @@ EventProof removed; human-verify checkpoint.
- CalendarShell: chrome + loading/empty/error branches + customComponents.eventModal wiring
- EventProof.tsx DELETED (Phase 1 proof component retired)
- EventDetailPopover.test.tsx
</artifacts_produced>
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-05-SUMMARY.md` when done
@@ -56,6 +56,7 @@ Read-only EventDetailPopover (XSS-safe, accessible, Phase-3-reusable), always-vi
### Task 1: EventDetailPopover + CalendarShell wiring (TDD)
**`apps/pwa/src/components/EventDetailPopover.tsx`** (259 lines):
- Resolves open event by `openEventId` (Zustand) from TanStack Query `['events']` cache using `queryClient.getQueriesData`
- Dual-mode: standalone (primary, driven by Zustand) + `customComponents.eventModal` prop from Schedule-X
- Renders title (heading), date/time, location (with MapPin icon when present), description (max-4-lines scroll), calendar name + color swatch footer
@@ -67,6 +68,7 @@ Read-only EventDetailPopover (XSS-safe, accessible, Phase-3-reusable), always-vi
- Wired in CalendarShell: `customComponents={{ eventModal: EventDetailPopover }}` AND rendered standalone after the layout
**`apps/pwa/src/components/EventDetailPopover.test.tsx`** (192 lines, TDD RED → GREEN):
- TDD RED commit: tests written first, failing because file missing
- GREEN commit: implementation makes all 12 tests pass
- Tests: title/location/description/calendarName render as text; heading role; Escape/close-button/backdrop all call `setOpenEventId(null)`; null openEventId renders nothing
@@ -77,31 +79,37 @@ Read-only EventDetailPopover (XSS-safe, accessible, Phase-3-reusable), always-vi
### Task 2: Chrome components, state branches, EventProof retired
**`apps/pwa/src/components/ColorLegend.tsx`**:
- One row per member: 12px color circle (`aria-label="{name}: {hex}"`) + display name
- "Family" row always rendered last using `--color-shared-family` (#F25C7A)
- Font: 13px label weight, `--color-text-secondary`
**`apps/pwa/src/components/AppNav.tsx`**:
- Phone: 48px top bar — "FamilySync" display text left, user avatar right with `aria-label` + `title` per reviewer note
- Tablet/desktop: 240px left sidebar — app name + "Calendars" section header + `<ColorLegend>`
**`apps/pwa/src/components/ViewToolbar.tsx`**:
- Today | | | Day | Week | Month | Agenda
- 44px min-height on all buttons; keyboard-activatable
- Active view: `rgba(74, 144, 217, 0.12)` surface tint (NOT accent color) per UI-SPEC 60/30/10 rule
- Navigation via `calendarApp.$app.calendarState.setRange()` / `setView()` (internal Schedule-X API)
**`apps/pwa/src/components/SkeletonCalendar.tsx`**:
- Month variant: 6×7 grid of shimmer cells + 7-col header
- Agenda variant: 4 date-group blocks, 23 rows each at 6090% widths
- `aria-busy="true"`, `aria-label="Loading calendar"` on root
- Shimmer: `@keyframes shimmer` from tokens.css, `background-size: 200% 100%`, 1.5s infinite
**`apps/pwa/src/components/EmptyState.tsx`**:
- `CalendarDays` lucide icon (32px, `--color-text-muted`)
- Heading "Nothing here" + body "No events in this period. Try a different date or switch views." per UI-SPEC copywriting
**`apps/pwa/src/components/CalendarShell.tsx`** (major refactor):
- Phone: AppNav top bar → ViewToolbar → calendar content → ColorLegend below
- Tablet/desktop: AppNav sidebar (240px) + main area (ViewToolbar → calendar content)
- State branches: `isInitialLoading``SkeletonCalendar`; `isEventsError` → "Couldn't load events" + "Check your connection" + **Retry** button calling `queryClient.refetchQueries({ queryKey: ['events'] })`; `isEmptyResult``EmptyState`; success+data → `ScheduleXCalendar`
@@ -125,6 +133,7 @@ vite build: clean (490.30 kB, 827ms)
### Auto-fixed Issues
**1. [Rule 3 - Blocking] @testing-library/jest-dom not imported in test-setup.ts**
- **Found during:** Task 1 GREEN phase — `toHaveTextContent` threw "Invalid Chai property"
- **Issue:** `@testing-library/jest-dom` extends Vitest/Chai's `expect` with DOM matchers. It was installed (in devDependencies) but never imported in `src/test-setup.ts`, so matchers were not registered.
- **Fix:** Added `import '@testing-library/jest-dom'` to `src/test-setup.ts`
@@ -132,6 +141,7 @@ vite build: clean (490.30 kB, 827ms)
- **Commit:** 3eebfbf (bundled with Task 1 GREEN commit)
**2. [Rule 1 - Bug] CalendarShell.test.tsx: synchronous getByTestId fails after loading state added**
- **Found during:** Task 2 verification — existing CalendarShell smoke test failed
- **Issue:** The test did `screen.getByTestId('schedule-x-calendar')` synchronously, but CalendarShell now shows SkeletonCalendar while loading. The calendar element only appears after queries resolve.
- **Fix:** Changed to `await screen.findByTestId('schedule-x-calendar')` (async, waits for element)
@@ -139,6 +149,7 @@ vite build: clean (490.30 kB, 827ms)
- **Commit:** 216ddce (bundled with Task 2 commit)
**3. [Rule 1 - Bug] ViewToolbar: CalendarApp.setDate/decrementRange/incrementRange/setView don't exist on public API**
- **Found during:** Task 2 tsc check — 4 type errors
- **Issue:** `CalendarApp` class only exposes `render`, `destroy`, `setTheme`, `getTheme`, and `events` (EventsFacade). Navigation methods (`setRange`, `setView`) live on the internal `$app.calendarState` (a `CalendarAppSingleton` property).
- **Fix:** Changed `calendarApp` prop type to `any`, accessed internal state via `calendarApp.$app.calendarState` with runtime null-guards. Navigation uses `Temporal.Now.plainDateISO()` for today and `ZonedDateTime.until().days` for range inference.
@@ -156,6 +167,7 @@ None — all components render from live data (TanStack Query cache) or accurate
## Threat Flags
T-02e-01 mitigated:
- EventDetailPopover: title, description, location, calendarName all rendered as plain-text JSX children
- Test asserts `<script>alert("xss")</script>` in title → `heading.innerHTML` does NOT contain `<script>`, textContent DOES contain the literal string
- Test asserts `<b>Bold</b>` in description → `descEl.innerHTML` does NOT contain `<b>`
@@ -169,6 +181,7 @@ The plan gates on operator visual verification. The automated tasks (1 and 2) ar
## Self-Check: PASSED
Files created:
- [x] apps/pwa/src/components/EventDetailPopover.tsx
- [x] apps/pwa/src/components/EventDetailPopover.test.tsx
- [x] apps/pwa/src/components/ColorLegend.tsx
@@ -178,9 +191,11 @@ Files created:
- [x] apps/pwa/src/components/EmptyState.tsx
Files deleted:
- [x] apps/pwa/src/components/EventProof.tsx (confirmed ABSENT)
Commits:
- [x] 433fb9f — TDD RED: EventDetailPopover test
- [x] 3eebfbf — feat: EventDetailPopover + CalendarShell wiring
- [x] 216ddce — feat: Task 2 chrome + states + EventProof retired
@@ -21,6 +21,7 @@ until >2 members), single-occurrence recurring edits (v1.x).
## Implementation Decisions
### Theming architecture (the load-bearing decision)
- **D-01:** Build a **design-token layer** — color, spacing, density, and typography expressed
as CSS custom properties + a small theme object — and build the UI exclusively against those
tokens. No hard-coded colors/spacing in components.
@@ -33,12 +34,14 @@ until >2 members), single-occurrence recurring edits (v1.x).
see [[project-familysync]] / PROJECT.md Out-of-Scope note on wall display.)
### Views & default
- **D-04:** Provide all four views: **day, week, month, agenda** (CAL-03).
- **D-05:** **Device-adaptive default view:** phone → **Agenda** (lowest friction for the
non-technical iPhone member); tablet/desktop → **Month** (spatial overview, closest to the v2
display). Remember the last-used view per device.
### Color & ownership legibility
- **D-06:** **Per-member color fill** using the color already assigned on the user row in Phase 1
(6-color palette already scales as members are added); the **shared-family calendar gets one
reserved, distinct color**. This is the "whose is this" signal and must read at a glance.
@@ -47,12 +50,14 @@ until >2 members), single-occurrence recurring edits (v1.x).
as the future display theme) — a 2-person household doesn't need it yet.
### Event detail density
- **D-08:** **Informational + tap-to-expand.** Month = colored bars with the event title (not bare
dots); Week/Day = time + title; Agenda = time + title + location. Tapping any event opens a
**read-only detail popover** (title, time, location, description) — this popover is intended to
be **reused as the edit surface in Phase 3**, so build it with that in mind.
### Recurrence / time (carried forward — not re-discussed)
- **D-09:** Recurring events are **expanded server-side** (`CALDAV:expand` / broker emits concrete
occurrences for the requested window) — locked in STATE/CLAUDE. The client renders occurrences;
it does not run rrule expansion itself for the primary path.
@@ -60,6 +65,7 @@ until >2 members), single-occurrence recurring edits (v1.x).
already in the schema). Secondary-timezone display toggle is deferred to v1.x.
### Claude's Discretion
- Week start day (Sunday vs Monday): default **Sunday** (US locale — the account has a "USA
Holidays" calendar); expose as a token/config so it's trivial to flip. Planner/researcher may
confirm.
@@ -71,23 +77,27 @@ until >2 members), single-occurrence recurring edits (v1.x).
- Skeleton/loading and empty states: build them, polished enough for the "slick" constraint.
### Dev-auth bypass (from D-14, project-level)
- `/api/*` is OIDC-gated, but live Authelia is deferred (D-14). Plan a **documented dev-auth
bypass** (e.g., an env-flagged middleware that injects a fixed dev user) so Phase 2 UI can be
built and tested locally without a live OIDC provider. Must be off by default / impossible in
production builds.
</decisions>
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Project decisions & scope
- `.planning/PROJECT.md` — core value, constraints, Key Decisions incl. D-14/D-15; wall-display is v2 (informs D-02/D-03).
- `.planning/ROADMAP.md` §"Phase 2: Calendar Display" — goal + success criteria; §Phase 3/4 for scope boundaries.
- `.planning/REQUIREMENTS.md` — CAL-02 (unified color-coded view), CAL-03 (day/week/month/agenda), and the recurring-event *display* portion of CAL-07.
- `.planning/REQUIREMENTS.md` — CAL-02 (unified color-coded view), CAL-03 (day/week/month/agenda), and the recurring-event _display_ portion of CAL-07.
### Phase 1 foundation this builds on
- `.planning/phases/01-foundation-broker-spike/01-03-SUMMARY.md` — broker API surface (syncCalendar, poller), event cache shape.
- `.planning/phases/01-foundation-broker-spike/01-04-SUMMARY.md` — index.ts route wiring, `/api/events`, `/api/me`.
- `.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md` — per-member app-password model (how multiple members' calendars aggregate).
@@ -95,26 +105,30 @@ until >2 members), single-occurrence recurring edits (v1.x).
- `apps/api/src/db/schema.ts``users.color`, `calendars.userId`, `calendarEvents` (dtstartUtc/dtstartDate/allDay/rawVevent) — the D-13 split the display relies on.
- `CLAUDE.md` — locked stack (React 19, Vite, TanStack Query, Zustand, ical.js, rrule), CalDAV/expand guidance, iOS constraints.
- `docs/deployment.md` — dev-auth bypass context lives alongside Gate 2 (D-14).
</canonical_refs>
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `apps/pwa/src/api/client.ts` — typed fetch client (`fetchMe`, `credentials: 'include'`); extend with a typed `fetchEvents(range)`.
- `apps/pwa/src/components/EventProof.tsx` — proof-of-concept that already fetches `/api/events` and parses a VEVENT; the calendar replaces/absorbs it.
- `apps/pwa/src/App.tsx` — member badge (name + color via `/api/me`); the per-member color source for D-06.
- `apps/pwa/src/main.tsx``QueryClientProvider` already set up (TanStack Query is the server-state owner).
### Established Patterns
- Server state → TanStack Query; UI-only state (selected date, current view) → Zustand (locked; do not put events in Zustand).
- Broker is the ONLY Fastmail I/O boundary; `/api/events` reads the MariaDB cache only (no tsdav in routes) — recurrence expansion belongs server-side near the broker/route, never a direct Fastmail call from the UI.
- Hono app exported from `index.ts` without auto-starting (testable); add display-oriented endpoints there.
### Integration Points
- `/api/events` must evolve from "raw row dump" to a display-ready shape: expanded occurrences within a requested date window, parsed title/time/location, all-day flag, and member color / shared-vs-personal indicator (join calendarEvents → calendars → users.color). This is the main backend work of Phase 2.
- Dev-auth bypass middleware sits in front of `/api/*` (see D-14).
</code_context>
</code_context>
<specifics>
## Specific Ideas
@@ -122,7 +136,7 @@ until >2 members), single-occurrence recurring edits (v1.x).
- "Clean theme now, switch to **Skylight style** later" — Skylight = large, glanceable, high-contrast family dashboard. It's the reference for the future display theme; the token layer (D-01) exists to make that swap cheap.
- The **real end goal is a tablet wall-display** — legibility and information density are first-class even in v1's clean theme (D-03).
- Theme/aesthetic reference target: Apple/Fantastical-style clean for v1.
</specifics>
</specifics>
<deferred>
## Deferred Ideas
@@ -133,10 +147,11 @@ until >2 members), single-occurrence recurring edits (v1.x).
- **Single-occurrence / "this and following" recurring edits** — v1.x; Phase 3 does create + whole-series only.
### Reviewed Todos (not folded)
- `kickoff-new-project.md` ("Kick off FamilySync with /gsd:new-project") — matched only on generic keywords (date/requirements/phase); a stale project-bootstrap todo, not Phase 2 scope.
</deferred>
</deferred>
---
*Phase: 2-Calendar Display*
*Context gathered: 2026-06-04*
_Phase: 2-Calendar Display_
_Context gathered: 2026-06-04_
@@ -15,11 +15,11 @@ Initial framing (pick one aesthetic) was reformulated after the user clarified t
**modular** approach: start clean, but be able to switch to a Skylight/tablet "display" theme
later as family members are added — the real end goal being a **legible tablet wall-display**.
| Option | Description | Selected |
|--------|-------------|----------|
| Token layer + clean theme only | Design tokens (CSS vars + theme object); ship only clean; future display theme = token swap; no switcher UI yet | ✓ |
| Token layer + two themes + toggle now | Build clean + first-cut display theme + runtime toggle now | |
| Hardcode clean, refactor later | No abstraction; retrofit theming at v2 | |
| Option | Description | Selected |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -------- |
| Token layer + clean theme only | Design tokens (CSS vars + theme object); ship only clean; future display theme = token swap; no switcher UI yet | ✓ |
| Token layer + two themes + toggle now | Build clean + first-cut display theme + runtime toggle now | |
| Hardcode clean, refactor later | No abstraction; retrofit theming at v2 | |
**User's choice:** Token layer + clean theme only.
**Notes:** Claude flagged that building two themes + a switcher now is v2 gold-plating (wall-display
@@ -29,41 +29,43 @@ model must scale past two members.
## Default view & per-device
| Option | Description | Selected |
|--------|-------------|----------|
| Phone→Agenda, Tablet/Desktop→Month | Device-adaptive default; remember last-used per device | ✓ |
| Month everywhere | Consistent grid; cramped on phone | |
| Agenda everywhere | List-first; underuses tablet/desktop | |
| Option | Description | Selected |
| ---------------------------------- | ------------------------------------------------------ | -------- |
| Phone→Agenda, Tablet/Desktop→Month | Device-adaptive default; remember last-used per device | ✓ |
| Month everywhere | Consistent grid; cramped on phone | |
| Agenda everywhere | List-first; underuses tablet/desktop | |
**User's choice:** Phone→Agenda, Tablet/Desktop→Month.
## Color & shared-vs-personal
| Option | Description | Selected |
|--------|-------------|----------|
| Per-member fill + reserved shared color | Phase-1 member colors; shared calendar gets one distinct color | ✓ |
| Per-member fill + shared marked by icon | Icon instead of reserved color | |
| Per-calendar color | Hue per Fastmail collection, member secondary | |
| Option | Description | Selected |
| --------------------------------------- | -------------------------------------------------------------- | -------- |
| Per-member fill + reserved shared color | Phase-1 member colors; shared calendar gets one distinct color | ✓ |
| Per-member fill + shared marked by icon | Icon instead of reserved color | |
| Per-calendar color | Hue per Fastmail collection, member secondary | |
**User's choice:** Per-member fill + reserved shared color.
**Notes:** Per-member show/hide filter deferred until >2 members; a color legend is shown.
## Event detail density
| Option | Description | Selected |
|--------|-------------|----------|
| Informational + tap-to-expand | Bars w/ title (month), time+title (week/day), +location (agenda); read-only popover reused for Phase 3 edit | ✓ |
| Minimal | Dots + agenda titles, no popover | |
| Maximal inline | time+title+location everywhere | |
| Option | Description | Selected |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------- | -------- |
| Informational + tap-to-expand | Bars w/ title (month), time+title (week/day), +location (agenda); read-only popover reused for Phase 3 edit | ✓ |
| Minimal | Dots + agenda titles, no popover | |
| Maximal inline | time+title+location everywhere | |
**User's choice:** Informational + tap-to-expand.
## Claude's Discretion
- Rendering library choice (must be token-styleable, headless-friendly, all 4 views, server-expanded occurrences, good iOS touch) — researcher decides.
- Week start day — default Sunday (US locale), exposed as a token.
- Skeleton/loading + empty states — build, polished for the "slick" constraint.
## Deferred Ideas
- Tablet/wall-display kiosk mode + Skylight display theme + runtime theme-switcher — v2.
- Per-member show/hide filter — when membership > 2.
- Secondary timezone toggle — v1.x.
@@ -13,18 +13,22 @@ updated: 2026-06-05
## Tests
### 1. Color-coded rendering
expected: Each member's events appear in their assigned color; ColorLegend shows members; shared events distinguishable (rose).
result: passed — operator confirmed personal events in member blue + legend. Shared/rose lane intentionally empty per D-16 (no shared Fastmail calendar created yet); code path verified.
### 2. All four views render + grid scrolls
expected: Day/Week/Month/Agenda each render events; week/day time-grid scrolls without clipping; weekday headers + hour labels legible.
result: passed — operator confirmed after fixing the height/scroll chain and label contrast.
### 3. Recurring events across DST
expected: A weekly event shows all occurrences in-window and stays at the correct local wall-clock across the March 2026 spring-forward.
result: passed — operator confirmed recurring events display at correct local time (e.g. "Small group @ 6PM" Thursdays at 5:45 PM, incl. June 11). DST spring-forward (March 2026) is implemented (VTIMEZONE registered before RecurExpansion; local display timezone) — recommended as a future spot-check if not explicitly navigated.
### 4. All-day banners — no date shift
expected: All-day events appear as full-day banners on the exact correct date.
result: passed — operator confirmed; all-day path uses Temporal.PlainDate ('YYYY-MM-DD'), never ZonedDateTime.
@@ -8,31 +8,31 @@
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/api/src/db/schema.ts` | model | CRUD | self (modify) | exact |
| `apps/api/src/broker/expand.ts` | utility | transform | `apps/api/src/broker/sync.ts` | role-match |
| `apps/api/src/routes/events.ts` | route | request-response | self (modify) + `apps/api/src/routes/me.ts` | exact |
| `apps/api/src/auth/devBypass.ts` | middleware | request-response | `apps/api/src/auth/middleware.ts` | role-match |
| `apps/api/src/index.ts` | config | request-response | self (modify) | exact |
| `apps/api/tests/broker/expand.test.ts` | test | transform | `apps/api/tests/broker/poller.test.ts` | role-match |
| `apps/api/tests/routes/events.test.ts` | test | request-response | `apps/api/tests/health.test.ts` | role-match |
| `apps/pwa/vitest.config.ts` | config | — | `apps/api/vitest.config.ts` | role-match |
| `apps/pwa/src/styles/tokens.css` | utility | — | none | no analog |
| `apps/pwa/src/styles/tokens.ts` | utility | — | none | no analog |
| `apps/pwa/src/styles/index.css` | utility | — | none | no analog |
| `apps/pwa/src/lib/calendarConfig.ts` | utility | transform | `apps/pwa/src/api/client.ts` | partial |
| `apps/pwa/src/lib/hydrateEvents.ts` | utility | transform | `apps/pwa/src/api/client.ts` | partial |
| `apps/pwa/src/lib/colorUtils.ts` | utility | transform | `apps/pwa/src/App.tsx` (ColorSwatch) | partial |
| `apps/pwa/src/store/calendarStore.ts` | store | event-driven | none | no analog |
| `apps/pwa/src/components/CalendarShell.tsx` | component | request-response | `apps/pwa/src/App.tsx` | role-match |
| `apps/pwa/src/components/EventDetailPopover.tsx` | component | request-response | `apps/pwa/src/App.tsx` (MemberBadge) | partial |
| `apps/pwa/src/components/AppNav.tsx` | component | — | `apps/pwa/src/App.tsx` | partial |
| `apps/pwa/src/components/ViewToolbar.tsx` | component | event-driven | `apps/pwa/src/App.tsx` | partial |
| `apps/pwa/src/components/ColorLegend.tsx` | component | — | `apps/pwa/src/App.tsx` (MemberBadge) | partial |
| `apps/pwa/src/components/SkeletonCalendar.tsx` | component | — | `apps/pwa/src/App.tsx` (loading state) | partial |
| `apps/pwa/src/api/client.ts` | utility | request-response | self (modify) | exact |
| `apps/pwa/src/main.tsx` | config | — | self (modify) | exact |
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
| ------------------------------------------------ | ---------- | ---------------- | ------------------------------------------- | ------------- |
| `apps/api/src/db/schema.ts` | model | CRUD | self (modify) | exact |
| `apps/api/src/broker/expand.ts` | utility | transform | `apps/api/src/broker/sync.ts` | role-match |
| `apps/api/src/routes/events.ts` | route | request-response | self (modify) + `apps/api/src/routes/me.ts` | exact |
| `apps/api/src/auth/devBypass.ts` | middleware | request-response | `apps/api/src/auth/middleware.ts` | role-match |
| `apps/api/src/index.ts` | config | request-response | self (modify) | exact |
| `apps/api/tests/broker/expand.test.ts` | test | transform | `apps/api/tests/broker/poller.test.ts` | role-match |
| `apps/api/tests/routes/events.test.ts` | test | request-response | `apps/api/tests/health.test.ts` | role-match |
| `apps/pwa/vitest.config.ts` | config | — | `apps/api/vitest.config.ts` | role-match |
| `apps/pwa/src/styles/tokens.css` | utility | — | none | no analog |
| `apps/pwa/src/styles/tokens.ts` | utility | — | none | no analog |
| `apps/pwa/src/styles/index.css` | utility | — | none | no analog |
| `apps/pwa/src/lib/calendarConfig.ts` | utility | transform | `apps/pwa/src/api/client.ts` | partial |
| `apps/pwa/src/lib/hydrateEvents.ts` | utility | transform | `apps/pwa/src/api/client.ts` | partial |
| `apps/pwa/src/lib/colorUtils.ts` | utility | transform | `apps/pwa/src/App.tsx` (ColorSwatch) | partial |
| `apps/pwa/src/store/calendarStore.ts` | store | event-driven | none | no analog |
| `apps/pwa/src/components/CalendarShell.tsx` | component | request-response | `apps/pwa/src/App.tsx` | role-match |
| `apps/pwa/src/components/EventDetailPopover.tsx` | component | request-response | `apps/pwa/src/App.tsx` (MemberBadge) | partial |
| `apps/pwa/src/components/AppNav.tsx` | component | — | `apps/pwa/src/App.tsx` | partial |
| `apps/pwa/src/components/ViewToolbar.tsx` | component | event-driven | `apps/pwa/src/App.tsx` | partial |
| `apps/pwa/src/components/ColorLegend.tsx` | component | — | `apps/pwa/src/App.tsx` (MemberBadge) | partial |
| `apps/pwa/src/components/SkeletonCalendar.tsx` | component | — | `apps/pwa/src/App.tsx` (loading state) | partial |
| `apps/pwa/src/api/client.ts` | utility | request-response | self (modify) | exact |
| `apps/pwa/src/main.tsx` | config | — | self (modify) | exact |
---
@@ -43,6 +43,7 @@
**Analog:** self
**Add to `calendarEvents` table — Drizzle column pattern** (lines 84105 of current file):
```typescript
// New columns to add — follow existing column declaration style exactly:
hasRrule: boolean('has_rrule').default(false).notNull(),
@@ -56,6 +57,7 @@ index('idx_calendar_events_has_rrule').on(t.hasRrule),
```
**Import pattern** (lines 111 of existing schema.ts):
```typescript
import {
mysqlTable,
@@ -67,7 +69,7 @@ import {
boolean,
index,
unique,
} from 'drizzle-orm/mysql-core'
} from 'drizzle-orm/mysql-core';
```
---
@@ -77,55 +79,57 @@ import {
**Analog:** `apps/api/src/broker/sync.ts`
**Imports pattern** (lines 18 of sync.ts):
```typescript
import ICAL from 'ical.js'
import { eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendars, calendarEvents } from '../db/schema.js'
import ICAL from 'ical.js';
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { calendars, calendarEvents } from '../db/schema.js';
```
**ICAL.parse + Component pipeline pattern** (lines 7891 of sync.ts):
```typescript
let parsed: ReturnType<typeof ICAL.parse>
let parsed: ReturnType<typeof ICAL.parse>;
try {
parsed = ICAL.parse(obj.data as string)
parsed = ICAL.parse(obj.data as string);
} catch {
// Malformed VCALENDAR — skip but do not crash the sync
continue
continue;
}
const comp = new ICAL.Component(parsed)
const vevent = comp.getFirstSubcomponent('vevent')
if (!vevent) continue
const comp = new ICAL.Component(parsed);
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) continue;
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null;
```
**allDay detection pattern** (lines 8898 of sync.ts):
```typescript
// D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
const allDay: boolean = dtstart?.isDate ?? false
const allDay: boolean = dtstart?.isDate ?? false;
```
**Error handling pattern** (lines 7578 of sync.ts):
```typescript
try {
parsed = ICAL.parse(obj.data as string)
parsed = ICAL.parse(obj.data as string);
} catch {
continue // malformed VCALENDAR — skip silently
continue; // malformed VCALENDAR — skip silently
}
```
**VTIMEZONE registration — must come before RecurExpansion** (from RESEARCH.md Pattern 1):
```typescript
// CRITICAL: Register VTIMEZONE before constructing ICAL.RecurExpansion
for (const vtz of comp.getAllSubcomponents('vtimezone')) {
const tzid = vtz.getFirstPropertyValue('tzid') as string
const tzid = vtz.getFirstPropertyValue('tzid') as string;
if (tzid && !ICAL.TimezoneService.has(tzid)) {
ICAL.TimezoneService.register(
tzid,
new ICAL.Timezone({ component: vtz, tzid }),
)
ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtz, tzid }));
}
}
```
@@ -137,40 +141,43 @@ for (const vtz of comp.getAllSubcomponents('vtimezone')) {
**Analog:** `apps/api/src/routes/me.ts` + current `events.ts`
**Route file structure pattern** (lines 129 of me.ts):
```typescript
import { Hono } from 'hono'
import { getAuth } from '../auth/middleware.js'
import { upsertUser } from '../auth/user.js'
export const meRouter = new Hono()
```typescript
import { Hono } from 'hono';
import { getAuth } from '../auth/middleware.js';
import { upsertUser } from '../auth/user.js';
export const meRouter = new Hono();
meRouter.get('/', async (c) => {
const auth = await getAuth(c)
const auth = await getAuth(c);
if (!auth) {
return c.json({ error: 'Unauthorized' }, 401)
return c.json({ error: 'Unauthorized' }, 401);
}
// ... business logic
return c.json({ user: { id, displayName, color } })
})
return c.json({ user: { id, displayName, color } });
});
```
**Zod query param validation pattern** — follow `@hono/zod-validator` (from CLAUDE.md stack; no existing example yet — planner must scaffold):
```typescript
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const eventsQuerySchema = z.object({
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
});
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
const { start, end } = c.req.valid('query')
const { start, end } = c.req.valid('query');
// ...
})
});
```
**Drizzle join pattern** (from sync.ts lines 59, 99 + schema.ts foreign key pattern):
```typescript
// Pattern: db.select().from(table).where(eq(...)).limit(1)
// For join: db.select().from(calendarEvents)
@@ -180,13 +187,14 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
```
**Error handling pattern** (lines 1626 of health.ts):
```typescript
try {
// ...
return c.json({ ok: true, db: 'up' })
return c.json({ ok: true, db: 'up' });
} catch (err) {
console.error('[health] DB round-trip failed:', err)
return c.json({ ok: false, db: 'down' }, 503)
console.error('[health] DB round-trip failed:', err);
return c.json({ ok: false, db: 'down' }, 503);
}
```
@@ -197,27 +205,29 @@ try {
**Analog:** `apps/api/src/auth/middleware.ts`
**Middleware export pattern** (lines 2426 of middleware.ts):
```typescript
// middleware.ts uses re-export; devBypass.ts uses named function export
export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-auth'
export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-auth';
```
**Hono middleware handler signature** (from Hono docs + RESEARCH.md Pattern 5):
```typescript
import type { MiddlewareHandler } from 'hono'
import type { MiddlewareHandler } from 'hono';
export function devAuthBypass(): MiddlewareHandler {
// Hard production guard FIRST — before reading any env var
if (process.env.NODE_ENV === 'production') {
return async (_c, next) => next()
return async (_c, next) => next();
}
if (process.env.DEV_AUTH_BYPASS !== 'true') {
return async (_c, next) => next()
return async (_c, next) => next();
}
return async (c, next) => {
c.set('user', DEV_USER)
await next()
}
c.set('user', DEV_USER);
await next();
};
}
```
@@ -228,27 +238,29 @@ export function devAuthBypass(): MiddlewareHandler {
**Analog:** self
**Middleware mount order pattern** (lines 1429 of index.ts):
```typescript
// OIDC callback BEFORE auth guard (T-02-02)
app.get('/callback', (c) => processOAuthCallback(c))
app.get('/callback', (c) => processOAuthCallback(c));
// Unauthenticated routes BEFORE the guard
app.route('/health', healthRouter)
app.route('/health', healthRouter);
// Auth guard on /api/*
app.use('/api/*', oidcAuthMiddleware())
app.use('/api/*', oidcAuthMiddleware());
// Protected routes after guard
app.route('/api/me', meRouter)
app.route('/api/events', eventsRouter)
app.route('/api/me', meRouter);
app.route('/api/events', eventsRouter);
```
**Dev bypass mount pattern** — devBypass must be mounted BEFORE oidcAuthMiddleware:
```typescript
// In dev: swap oidcAuthMiddleware for devAuthBypass when bypass is active
// The bypass short-circuits the OIDC redirect entirely
app.use('/api/*', devAuthBypass()) // no-op passthrough when NODE_ENV=production or flag not set
app.use('/api/*', oidcAuthMiddleware())
app.use('/api/*', devAuthBypass()); // no-op passthrough when NODE_ENV=production or flag not set
app.use('/api/*', oidcAuthMiddleware());
// Note: devAuthBypass sets c.set('user', DEV_USER) so oidcAuthMiddleware is still called
// but getAuth(c) will find the injected user. See RESEARCH.md Pattern 5 for alternate approach.
```
@@ -260,45 +272,49 @@ app.use('/api/*', oidcAuthMiddleware())
**Analog:** `apps/api/tests/broker/poller.test.ts`
**Test file structure** (lines 114 of poller.test.ts):
```typescript
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
// vi.mock hoisted to module top by Vitest
vi.mock('../../src/broker/sync.js', () => ({
syncCalendar: mockSyncCalendar,
}))
}));
```
**describe/it/expect pattern** (lines 71121 of poller.test.ts):
```typescript
describe('broker poller — runPoll', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.clearAllMocks();
// reset arrays and mock implementations
})
});
it('skips syncCalendar when ctag is unchanged', async () => {
const { runPoll } = await import('../../src/broker/poller.js')
const { runPoll } = await import('../../src/broker/poller.js');
// arrange
await runPoll()
await runPoll();
// assert
expect(mockSyncCalendar).not.toHaveBeenCalled()
})
})
expect(mockSyncCalendar).not.toHaveBeenCalled();
});
});
```
**Error resilience test pattern** (lines 176195 of poller.test.ts):
```typescript
it('handles decryptPassword failure gracefully without crashing the poller', async () => {
;(decryptPassword as Mock).mockImplementationOnce(() => {
throw new Error('Decryption failed')
})
await expect(runPoll()).resolves.not.toThrow()
expect(mockSyncCalendar).not.toHaveBeenCalled()
})
(decryptPassword as Mock).mockImplementationOnce(() => {
throw new Error('Decryption failed');
});
await expect(runPoll()).resolves.not.toThrow();
expect(mockSyncCalendar).not.toHaveBeenCalled();
});
```
**Fixture files** — create in `apps/api/tests/fixtures/` (new directory):
- `weekly-dst.ics` — weekly RRULE spanning March DST (America/New_York)
- `allday-birthday.ics` — DATE-type annual event, no DTEND
- `exdate-series.ics` — weekly series with one EXDATE
@@ -310,24 +326,25 @@ it('handles decryptPassword failure gracefully without crashing the poller', asy
**Analog:** `apps/api/tests/health.test.ts`
**Route test pattern** (lines 138 of health.test.ts):
```typescript
import { describe, it, expect, vi } from 'vitest'
import { describe, it, expect, vi } from 'vitest';
vi.mock('../src/db/client.js', () => ({
db: {
execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]),
},
}))
}));
describe('GET /health', () => {
it('returns 200 with { ok: true, db: "up" } when DB round-trip succeeds', async () => {
const { app } = await import('../src/index.js')
const res = await app.request('/health')
expect(res.status).toBe(200)
const body = await res.json() as { ok: boolean; db: string }
expect(body.ok).toBe(true)
})
})
const { app } = await import('../src/index.js');
const res = await app.request('/health');
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean; db: string };
expect(body.ok).toBe(true);
});
});
```
**app.request() pattern for Hono route tests** — use `app.request('/api/events?start=2026-06-01&end=2026-07-01')` following the same import-in-test pattern.
@@ -340,14 +357,14 @@ describe('GET /health', () => {
```typescript
// Copy this exactly, add jsdom environment for React:
import { defineConfig } from 'vitest/config'
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom', // differs from API (node)
environment: 'jsdom', // differs from API (node)
globals: true,
},
})
});
```
---
@@ -357,32 +374,38 @@ export default defineConfig({
**Analog:** self
**Existing function pattern to copy** (lines 2234 of client.ts):
```typescript
export async function fetchMe(): Promise<MeResponse> {
const res = await fetch('/api/me', {
credentials: 'include',
})
});
if (!res.ok) {
throw new Error(`GET /api/me failed: ${res.status}`)
throw new Error(`GET /api/me failed: ${res.status}`);
}
return res.json() as Promise<MeResponse>
return res.json() as Promise<MeResponse>;
}
```
**New `fetchEvents` must follow same shape:**
```typescript
// Replace the existing fetchEvents (no-window version) with a windowed version:
export interface CalendarOccurrence { /* from shared types */ }
export interface OccurrencesResponse { occurrences: CalendarOccurrence[] }
export interface CalendarOccurrence {
/* from shared types */
}
export interface OccurrencesResponse {
occurrences: CalendarOccurrence[];
}
export async function fetchEvents(start: string, end: string): Promise<OccurrencesResponse> {
const res = await fetch(`/api/events?start=${start}&end=${end}`, {
credentials: 'include',
})
});
if (!res.ok) {
throw new Error(`GET /api/events failed: ${res.status}`)
throw new Error(`GET /api/events failed: ${res.status}`);
}
return res.json() as Promise<OccurrencesResponse>
return res.json() as Promise<OccurrencesResponse>;
}
```
@@ -393,23 +416,26 @@ export async function fetchEvents(start: string, end: string): Promise<Occurrenc
**Analog:** `apps/pwa/src/api/client.ts` (typed transform pattern)
**Interface definition pattern** (lines 1216 of client.ts):
```typescript
export interface MeUser {
id: number
displayName: string | null
color: string
id: number;
displayName: string | null;
color: string;
}
```
**Function export pattern** (lines 2234 of client.ts):
```typescript
export async function fetchMe(): Promise<MeResponse> { ... }
// → hydrateEvents follows same: export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[]
```
**Temporal polyfill import** — must be registered before any Temporal usage:
```typescript
import 'temporal-polyfill/global' // registers Temporal on globalThis; import in main.tsx first
import 'temporal-polyfill/global'; // registers Temporal on globalThis; import in main.tsx first
```
---
@@ -419,14 +445,16 @@ import 'temporal-polyfill/global' // registers Temporal on globalThis; import i
**Analog:** `apps/pwa/src/api/client.ts` (typed constants + factory function)
**Exported constant pattern** (lines 1216 of client.ts as reference for typed exports):
```typescript
export const WEEK_START_DAY = 0 // 0 = Sunday; Schedule-X uses 7 = Sunday (translate before passing)
export const WEEK_START_DAY = 0; // 0 = Sunday; Schedule-X uses 7 = Sunday (translate before passing)
```
**Key translation note** — document inline per RESEARCH.md:
```typescript
// WEEK_START_DAY=0 (JS/date-fns Sunday) → Schedule-X firstDayOfWeek=7 (Temporal Sunday)
const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY
const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY;
```
---
@@ -436,6 +464,7 @@ const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY
**Analog:** `apps/pwa/src/App.tsx` (ColorSwatch inline style, lines 1834)
**Color inline style pattern to extend** (lines 1834 of App.tsx):
```typescript
function ColorSwatch({ color }: { color: string }) {
return (
@@ -451,15 +480,20 @@ function ColorSwatch({ color }: { color: string }) {
```
**Target function signatures:**
```typescript
// container = main hex at 15% opacity blended over white
export function hexToContainer(hex: string): string // returns CSS hex or rgba
export function hexToContainer(hex: string): string; // returns CSS hex or rgba
// onContainer = main hex darkened 40%
export function hexToOnContainer(hex: string): string
export function hexToOnContainer(hex: string): string;
// convenience: all three for Schedule-X lightColors
export function deriveScheduleXColors(main: string): { main: string; container: string; onContainer: string }
export function deriveScheduleXColors(main: string): {
main: string;
container: string;
onContainer: string;
};
```
---
@@ -471,14 +505,14 @@ export function deriveScheduleXColors(main: string): { main: string; container:
```typescript
// State shape from UI-SPEC § State Management Contract:
interface CalendarStore {
selectedView: string // persisted in localStorage per breakpointGroup
selectedDate: string // ISO string; not persisted
openEventId: string | null // null = popover closed
calendarRange: { start: string; end: string } // drives TanStack Query key
setSelectedView: (view: string) => void
setSelectedDate: (date: string) => void
setOpenEventId: (id: string | null) => void
setCalendarRange: (range: { start: string; end: string }) => void
selectedView: string; // persisted in localStorage per breakpointGroup
selectedDate: string; // ISO string; not persisted
openEventId: string | null; // null = popover closed
calendarRange: { start: string; end: string }; // drives TanStack Query key
setSelectedView: (view: string) => void;
setSelectedDate: (date: string) => void;
setOpenEventId: (id: string | null) => void;
setCalendarRange: (range: { start: string; end: string }) => void;
}
```
@@ -489,26 +523,29 @@ interface CalendarStore {
**Analog:** `apps/pwa/src/App.tsx`
**TanStack Query usage pattern** (lines 5863 of App.tsx):
```typescript
const meQuery = useQuery({
queryKey: ['me'],
queryFn: fetchMe,
retry: false,
staleTime: 5 * 60 * 1000,
})
});
```
**Events query — extend this pattern:**
```typescript
const eventsQuery = useQuery({
queryKey: ['events', start, end],
queryFn: () => fetchEvents(start, end),
retry: 2,
staleTime: 5 * 60 * 1000,
})
});
```
**Loading/error conditional render pattern** (lines 7792 of App.tsx):
```typescript
{meQuery.isLoading && (
<div style={{ color: '#666', marginBottom: '1rem' }}>Loading...</div>
@@ -522,6 +559,7 @@ const eventsQuery = useQuery({
```
**Component file structure** (App.tsx overall shape):
- Inline interfaces at top
- Sub-components declared before default export
- Default export is the root component
@@ -534,6 +572,7 @@ const eventsQuery = useQuery({
**Analog:** `apps/pwa/src/App.tsx` (MemberBadge component, lines 3655)
**Component prop interface pattern** (lines 3638 of App.tsx):
```typescript
function MemberBadge({ user }: { user: MeUser }) {
return (
@@ -541,15 +580,17 @@ function MemberBadge({ user }: { user: MeUser }) {
```
**Target interface:**
```typescript
interface EventDetailPopoverProps {
eventId: string | null // null = closed
onClose: () => void
eventId: string | null; // null = closed
onClose: () => void;
// event data resolved from Zustand openEventId → TanStack Query cache lookup
}
```
**Accessibility pattern** from UI-SPEC:
- Focus trap while open; Escape closes
- Close button: `aria-label="Close"`; min 44px touch target
- Never use `dangerouslySetInnerHTML` for event title/description (XSS guard)
@@ -561,6 +602,7 @@ interface EventDetailPopoverProps {
**Analog:** `apps/pwa/src/App.tsx` loading state (lines 7780)
**Loading pattern to replace:**
```typescript
{meQuery.isLoading && (
<div style={{ color: '#666', marginBottom: '1rem' }}>Loading...</div>
@@ -568,17 +610,23 @@ interface EventDetailPopoverProps {
```
**Skeleton shimmer approach** — CSS animation, no third-party library:
```css
/* In tokens.css or inline: */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Apply: background: linear-gradient(90deg, var(--color-surface-dim), var(--color-border-subtle), var(--color-surface-dim));
background-size: 200% 100%; animation: shimmer 1.5s infinite; */
```
**aria-busy pattern** per UI-SPEC:
```tsx
<div aria-busy="true" aria-label="Loading calendar">
{/* shimmer placeholders */}
@@ -592,6 +640,7 @@ interface EventDetailPopoverProps {
**Analog:** self
**Current structure** (lines 121 of main.tsx):
```typescript
import React from 'react'
import ReactDOM from 'react-dom/client'
@@ -617,10 +666,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
```
**Add before all other imports** (Temporal polyfill must be first):
```typescript
import 'temporal-polyfill/global' // registers Temporal on globalThis FIRST
import '@schedule-x/theme-default/dist/index.css' // Schedule-X layout engine CSS
import './styles/tokens.css' // token overrides (must come after SX CSS)
import 'temporal-polyfill/global'; // registers Temporal on globalThis FIRST
import '@schedule-x/theme-default/dist/index.css'; // Schedule-X layout engine CSS
import './styles/tokens.css'; // token overrides (must come after SX CSS)
```
---
@@ -628,91 +678,112 @@ import './styles/tokens.css' // token overrides (must come after S
## Shared Patterns
### Authentication Guard (all API routes)
**Source:** `apps/api/src/index.ts` lines 2429
```typescript
app.use('/api/*', oidcAuthMiddleware())
app.route('/api/me', meRouter)
app.route('/api/events', eventsRouter)
app.use('/api/*', oidcAuthMiddleware());
app.route('/api/me', meRouter);
app.route('/api/events', eventsRouter);
```
**Apply to:** All new/modified route files. Dev bypass mounts before this, not instead.
### Hono Route Error Handling
**Source:** `apps/api/src/routes/health.ts` lines 1626
```typescript
try {
await db.execute(sql`SELECT 1`)
return c.json({ ok: true, db: 'up' })
await db.execute(sql`SELECT 1`);
return c.json({ ok: true, db: 'up' });
} catch (err) {
console.error('[health] DB round-trip failed:', err)
return c.json({ ok: false, db: 'down' }, 503)
console.error('[health] DB round-trip failed:', err);
return c.json({ ok: false, db: 'down' }, 503);
}
```
**Apply to:** `routes/events.ts` — wrap the windowed query + expansion in try/catch, return 503 on DB error.
### Drizzle Upsert Pattern
**Source:** `apps/api/src/broker/sync.ts` lines 3956
```typescript
await db
.insert(calendars)
.values({ ... })
.onDuplicateKeyUpdate({ set: { ... } })
```
**Apply to:** Any schema migration that adds columns — upsert pattern unchanged.
### D-13 allDay Discrimination
**Source:** `apps/api/src/broker/sync.ts` lines 8898
```typescript
const allDay: boolean = dtstart?.isDate ?? false
const allDay: boolean = dtstart?.isDate ?? false;
// dtstartDate: for all-day, convert YYYY-MM-DD → Date at midnight UTC
const dtstartDateValue: Date | null =
allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null
const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null
allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null;
const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null;
```
**Apply to:** `broker/expand.ts` — preserve the same discrimination when building CalendarOccurrence output. All-day `start` field must be `'YYYY-MM-DD'` (not a datetime string). Timed `start` must be a timezone-offset ISO string.
### TanStack Query Usage
**Source:** `apps/pwa/src/App.tsx` lines 5870
```typescript
const meQuery = useQuery({
queryKey: ['me'],
queryFn: fetchMe,
retry: false,
staleTime: 5 * 60 * 1000,
})
});
```
**Apply to:** All data-fetching components. Events query uses `retry: 2`. Server data never enters Zustand.
### Fetch Client with Credentials
**Source:** `apps/pwa/src/api/client.ts` lines 2234
```typescript
const res = await fetch('/api/me', { credentials: 'include' })
const res = await fetch('/api/me', { credentials: 'include' });
if (!res.ok) {
throw new Error(`GET /api/me failed: ${res.status}`)
throw new Error(`GET /api/me failed: ${res.status}`);
}
return res.json() as Promise<MeResponse>
return res.json() as Promise<MeResponse>;
```
**Apply to:** All new `client.ts` functions (`fetchEvents`). The `credentials: 'include'` is required for the OIDC session cookie.
### CSS Token Usage in Components
**Source:** `apps/pwa/src/App.tsx` lines 3755 (inline style approach)
```typescript
style={{
background: '#f0f9ff', // ← Phase 1: hardcoded
border: `2px solid ${user.color}`,
}}
```
**Apply to (Phase 2 rule):** Replace all hardcoded hex/px values with `var(--token-name)` CSS custom properties. The existing App.tsx hardcoded values must also be migrated. No hardcoded colors in any Phase 2 component.
---
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/pwa/src/styles/tokens.css` | utility | — | No CSS token layer exists; Phase 2 introduces it from scratch |
| `apps/pwa/src/styles/tokens.ts` | utility | — | No TypeScript token mirror exists |
| `apps/pwa/src/styles/index.css` | utility | — | No global CSS exists; current App.tsx uses inline styles only |
| `apps/pwa/src/store/calendarStore.ts` | store | event-driven | No Zustand store exists in codebase yet; first Zustand usage |
| File | Role | Data Flow | Reason |
| ------------------------------------- | ------- | ------------ | ------------------------------------------------------------- |
| `apps/pwa/src/styles/tokens.css` | utility | — | No CSS token layer exists; Phase 2 introduces it from scratch |
| `apps/pwa/src/styles/tokens.ts` | utility | — | No TypeScript token mirror exists |
| `apps/pwa/src/styles/index.css` | utility | — | No global CSS exists; current App.tsx uses inline styles only |
| `apps/pwa/src/store/calendarStore.ts` | store | event-driven | No Zustand store exists in codebase yet; first Zustand usage |
---
@@ -7,6 +7,7 @@
---
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
@@ -37,18 +38,20 @@
- Per-member show/hide filter (add when membership > 2).
- Secondary timezone display toggle (v1.x).
- Single-occurrence / "this and following" recurring edits (v1.x).
</user_constraints>
</user_constraints>
---
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| CAL-02 | User sees a unified, color-coded calendar that aggregates every accessible calendar into one view | §Backend: /api/events evolution; §Frontend: Schedule-X calendars config with per-calendar lightColors |
| CAL-03 | User can switch between week, month, day, and agenda/list views | §Schedule-X Views; all four views confirmed in @schedule-x/calendar v4.6.0 |
| CAL-07 | User can create a recurring event and see all its occurrences expanded correctly (display portion only — creation is Phase 3) | §Recurrence expansion pipeline; §DST correctness; §All-day event handling |
| ID | Description | Research Support |
| ------ | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| CAL-02 | User sees a unified, color-coded calendar that aggregates every accessible calendar into one view | §Backend: /api/events evolution; §Frontend: Schedule-X calendars config with per-calendar lightColors |
| CAL-03 | User can switch between week, month, day, and agenda/list views | §Schedule-X Views; all four views confirmed in @schedule-x/calendar v4.6.0 |
| CAL-07 | User can create a recurring event and see all its occurrences expanded correctly (display portion only — creation is Phase 3) | §Recurrence expansion pipeline; §DST correctness; §All-day event handling |
</phase_requirements>
---
@@ -67,18 +70,18 @@ All-day event correctness is already partially solved by the D-13 schema split (
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Event storage and polling | API / Backend | — | Phase 1 broker owns all Fastmail I/O; routes read MariaDB cache |
| Recurrence expansion | API / Backend | — | Server expands to concrete occurrences for the requested window; client renders, never expands (D-09) |
| All-day / timed discrimination | API / Backend | — | D-13 schema split already done in Phase 1; route must preserve and expose the distinction |
| Color and ownership join | API / Backend | — | `calendars.userId → users.color` join lives closest to the data; frontend just reads the color hex |
| Temporal object construction | Frontend (PWA) | — | Server sends plain strings; client converts to `Temporal.ZonedDateTime` / `Temporal.PlainDate` before Schedule-X |
| Calendar rendering (views) | Frontend (PWA) | — | Schedule-X renders day/week/month/agenda in the browser |
| Token layer / theming | Frontend (PWA) | — | CSS custom properties + TS token object; Schedule-X `--sx-color-*` vars overridden |
| View state, selected date, open popover | Frontend (PWA) — Zustand | — | UI-only state; never server data |
| Event list caching and re-fetch | Frontend (PWA) — TanStack Query | — | Cache key = `['events', start, end]`; invalidated on range change |
| Dev-auth bypass | API / Backend | — | Env-flagged middleware injecting fixed user; never active in production |
| Capability | Primary Tier | Secondary Tier | Rationale |
| --------------------------------------- | ------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------- |
| Event storage and polling | API / Backend | — | Phase 1 broker owns all Fastmail I/O; routes read MariaDB cache |
| Recurrence expansion | API / Backend | — | Server expands to concrete occurrences for the requested window; client renders, never expands (D-09) |
| All-day / timed discrimination | API / Backend | — | D-13 schema split already done in Phase 1; route must preserve and expose the distinction |
| Color and ownership join | API / Backend | — | `calendars.userId → users.color` join lives closest to the data; frontend just reads the color hex |
| Temporal object construction | Frontend (PWA) | — | Server sends plain strings; client converts to `Temporal.ZonedDateTime` / `Temporal.PlainDate` before Schedule-X |
| Calendar rendering (views) | Frontend (PWA) | — | Schedule-X renders day/week/month/agenda in the browser |
| Token layer / theming | Frontend (PWA) | — | CSS custom properties + TS token object; Schedule-X `--sx-color-*` vars overridden |
| View state, selected date, open popover | Frontend (PWA) — Zustand | — | UI-only state; never server data |
| Event list caching and re-fetch | Frontend (PWA) — TanStack Query | — | Cache key = `['events', start, end]`; invalidated on range change |
| Dev-auth bypass | API / Backend | — | Env-flagged middleware injecting fixed user; never active in production |
---
@@ -86,17 +89,17 @@ All-day event correctness is already partially solved by the D-13 schema split (
### Core (all versions verified against npm registry 2026-06-04)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `@schedule-x/calendar` | 4.6.0 | Calendar engine (views, Temporal-based event model) | Selected in UI-SPEC; active maintenance; last published 2026-05-12 |
| `@schedule-x/react` | 4.1.0 | React adapter (`useCalendarApp`, `ScheduleXCalendar`) | Official React adapter; peer-requires `@schedule-x/calendar ^3.1.0 \|\| ^4.0.0`; 4.6.0 satisfies this |
| `@schedule-x/theme-default` | 4.6.0 | Default CSS layout; overridden by project tokens | Required for Schedule-X internal layout engine; all colors are token-overridden |
| `@schedule-x/event-modal` | 4.6.0 | `createEventModalPlugin()` for custom `eventModal` component | Required to replace default modal with `EventDetailPopover` |
| `@schedule-x/events-service` | 4.6.0 | `createEventsServicePlugin()` for dynamic event updates | Required to update events after TanStack Query fetches new window |
| `temporal-polyfill` | 0.3.2 | `Temporal` global polyfill for browsers without native support | `@schedule-x/calendar` peer-requires `temporal-polyfill@0.3.0`; 0.3.2 satisfies |
| `lucide-react` | 1.17.0 | Icon library (CalendarDays, X, MapPin, ChevronLeft/Right) | Specified in UI-SPEC; tree-shakeable; active maintenance |
| `ical.js` | 2.2.1 | VEVENT parse + `ICAL.RecurExpansion` for recurrence | Already in both `apps/api` and `apps/pwa`; Phase 1 pattern established |
| `rrule` | 2.8.1 | RRULE string parsing (used only if `ICAL.RecurExpansion` is insufficient) | Already in project stack per CLAUDE.md; last pub 2023-11-10 — treat as stable |
| Library | Version | Purpose | Why Standard |
| ---------------------------- | ------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `@schedule-x/calendar` | 4.6.0 | Calendar engine (views, Temporal-based event model) | Selected in UI-SPEC; active maintenance; last published 2026-05-12 |
| `@schedule-x/react` | 4.1.0 | React adapter (`useCalendarApp`, `ScheduleXCalendar`) | Official React adapter; peer-requires `@schedule-x/calendar ^3.1.0 \|\| ^4.0.0`; 4.6.0 satisfies this |
| `@schedule-x/theme-default` | 4.6.0 | Default CSS layout; overridden by project tokens | Required for Schedule-X internal layout engine; all colors are token-overridden |
| `@schedule-x/event-modal` | 4.6.0 | `createEventModalPlugin()` for custom `eventModal` component | Required to replace default modal with `EventDetailPopover` |
| `@schedule-x/events-service` | 4.6.0 | `createEventsServicePlugin()` for dynamic event updates | Required to update events after TanStack Query fetches new window |
| `temporal-polyfill` | 0.3.2 | `Temporal` global polyfill for browsers without native support | `@schedule-x/calendar` peer-requires `temporal-polyfill@0.3.0`; 0.3.2 satisfies |
| `lucide-react` | 1.17.0 | Icon library (CalendarDays, X, MapPin, ChevronLeft/Right) | Specified in UI-SPEC; tree-shakeable; active maintenance |
| `ical.js` | 2.2.1 | VEVENT parse + `ICAL.RecurExpansion` for recurrence | Already in both `apps/api` and `apps/pwa`; Phase 1 pattern established |
| `rrule` | 2.8.1 | RRULE string parsing (used only if `ICAL.RecurExpansion` is insufficient) | Already in project stack per CLAUDE.md; last pub 2023-11-10 — treat as stable |
### No New Backend Dependencies Needed
@@ -119,20 +122,20 @@ pnpm add @schedule-x/calendar@4.6.0 @schedule-x/react@4.1.0 @schedule-x/theme-de
slopcheck was not available at research time. All packages below were verified via official documentation or established source repos. No packages flagged as suspicious by manual review.
| Package | Registry | Age | Source Repo | Postinstall | Disposition |
|---------|----------|-----|-------------|-------------|-------------|
| `@schedule-x/calendar` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `@schedule-x/react` | npm | 2+ yrs | github.com/schedule-x/react | none | Approved |
| `@schedule-x/theme-default` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `@schedule-x/event-modal` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `@schedule-x/events-service` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `temporal-polyfill` | npm | 2+ yrs | github.com/fullcalendar/temporal-polyfill | none | Approved |
| `lucide-react` | npm | 4+ yrs | github.com/lucide-icons/lucide | none | Approved |
| Package | Registry | Age | Source Repo | Postinstall | Disposition |
| ---------------------------- | -------- | ------ | ----------------------------------------- | ----------- | ----------- |
| `@schedule-x/calendar` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `@schedule-x/react` | npm | 2+ yrs | github.com/schedule-x/react | none | Approved |
| `@schedule-x/theme-default` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `@schedule-x/event-modal` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `@schedule-x/events-service` | npm | 2+ yrs | github.com/schedule-x/schedule-x | none | Approved |
| `temporal-polyfill` | npm | 2+ yrs | github.com/fullcalendar/temporal-polyfill | none | Approved |
| `lucide-react` | npm | 4+ yrs | github.com/lucide-icons/lucide | none | Approved |
**Packages removed due to slopcheck [SLOP] verdict:** none
**Packages flagged as suspicious [SUS]:** none
*slopcheck was unavailable at research time. All packages are tagged [VERIFIED: npm registry] based on official source repos confirmed via npm view. Planner should add `checkpoint:human-verify` before install if extra caution is warranted — this two-person household app is self-hosted with no third-party attack surface for these well-established packages.*
_slopcheck was unavailable at research time. All packages are tagged [VERIFIED: npm registry] based on official source repos confirmed via npm view. Planner should add `checkpoint:human-verify` before install if extra caution is warranted — this two-person household app is self-hosted with no third-party attack surface for these well-established packages._
---
@@ -222,23 +225,23 @@ apps/
```typescript
// apps/api/src/broker/expand.ts
// Source: https://github.com/kewisch/ical.js/wiki/Common-Use-Cases
import ICAL from 'ical.js'
import ICAL from 'ical.js';
export interface CalendarOccurrence {
id: string // `${uid}::${dtstart_iso}` — stable identity for Schedule-X
uid: string
calendarId: number
calendarName: string
ownerUserId: number
color: string // hex from users.color or shared-family constant
isShared: boolean // true when calendar is the shared-family calendar
title: string
start: string // ISO 8601 with timezone offset: '2026-06-15T10:00:00+02:00[America/Toronto]'
// for all-day: 'DATE:2026-06-15' — use a distinct format so client knows
end: string
allDay: boolean
location: string | null
description: string | null
id: string; // `${uid}::${dtstart_iso}` — stable identity for Schedule-X
uid: string;
calendarId: number;
calendarName: string;
ownerUserId: number;
color: string; // hex from users.color or shared-family constant
isShared: boolean; // true when calendar is the shared-family calendar
title: string;
start: string; // ISO 8601 with timezone offset: '2026-06-15T10:00:00+02:00[America/Toronto]'
// for all-day: 'DATE:2026-06-15' — use a distinct format so client knows
end: string;
allDay: boolean;
location: string | null;
description: string | null;
}
export function expandOccurrences(
@@ -251,24 +254,24 @@ export function expandOccurrences(
color: string,
isShared: boolean,
): CalendarOccurrence[] {
const parsed = ICAL.parse(rawVevent)
const comp = new ICAL.Component(parsed)
const parsed = ICAL.parse(rawVevent);
const comp = new ICAL.Component(parsed);
// CRITICAL: Register VTIMEZONE components before RecurExpansion
// Without this, RecurExpansion uses UTC and DST transitions produce wrong wall-clock times
for (const vtimezone of comp.getAllSubcomponents('vtimezone')) {
const tzid = vtimezone.getFirstPropertyValue('tzid') as string
const tzid = vtimezone.getFirstPropertyValue('tzid') as string;
if (tzid && !ICAL.TimezoneService.has(tzid)) {
ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtimezone, tzid }))
ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtimezone, tzid }));
}
}
const vevent = comp.getFirstSubcomponent('vevent')
if (!vevent) return []
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) return [];
const event = new ICAL.Event(vevent)
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time
const uid = event.uid
const event = new ICAL.Event(vevent);
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time;
const uid = event.uid;
// Non-recurring event: single occurrence check
if (!event.isRecurring()) {
@@ -276,19 +279,19 @@ export function expandOccurrences(
}
// Recurring event: use RecurExpansion
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart })
const rangeStart = ICAL.Time.fromJSDate(windowStart, /* useUtc */ false)
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, /* useUtc */ false)
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart });
const rangeStart = ICAL.Time.fromJSDate(windowStart, /* useUtc */ false);
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, /* useUtc */ false);
const occurrences: CalendarOccurrence[] = []
let next: ICAL.Time | null
const occurrences: CalendarOccurrence[] = [];
let next: ICAL.Time | null;
while ((next = expand.next()) && next.compare(rangeEnd) < 0) {
if (next.compare(rangeStart) < 0) continue
if (next.compare(rangeStart) < 0) continue;
// Build occurrence, compute end from duration
// ...
}
return occurrences
return occurrences;
}
```
@@ -303,19 +306,19 @@ export function expandOccurrences(
```typescript
// apps/pwa/src/lib/hydrateEvents.ts
// Source: https://schedule-x.dev/docs/calendar/events
import 'temporal-polyfill/global' // registers Temporal on globalThis
import type { CalendarOccurrence } from '@familysync/shared' // server type
import 'temporal-polyfill/global'; // registers Temporal on globalThis
import type { CalendarOccurrence } from '@familysync/shared'; // server type
export interface ScheduleXEvent {
id: string
title: string
start: Temporal.ZonedDateTime | Temporal.PlainDate
end: Temporal.ZonedDateTime | Temporal.PlainDate
calendarId: string // must be string matching the key in calendars config
location?: string
description?: string
id: string;
title: string;
start: Temporal.ZonedDateTime | Temporal.PlainDate;
end: Temporal.ZonedDateTime | Temporal.PlainDate;
calendarId: string; // must be string matching the key in calendars config
location?: string;
description?: string;
// custom business fields pass through
_familySync?: { uid: string; color: string; isShared: boolean }
_familySync?: { uid: string; color: string; isShared: boolean };
}
export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[] {
@@ -325,11 +328,11 @@ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent
return {
id: occ.id,
title: occ.title,
start: Temporal.PlainDate.from(occ.start), // occ.start is 'YYYY-MM-DD'
start: Temporal.PlainDate.from(occ.start), // occ.start is 'YYYY-MM-DD'
end: Temporal.PlainDate.from(occ.end),
calendarId: String(occ.calendarId),
_familySync: { uid: occ.uid, color: occ.color, isShared: occ.isShared },
}
};
}
// Timed: use ZonedDateTime from the offset-aware ISO string the server returns
return {
@@ -341,8 +344,8 @@ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent
location: occ.location ?? undefined,
description: occ.description ?? undefined,
_familySync: { uid: occ.uid, color: occ.color, isShared: occ.isShared },
}
})
};
});
}
```
@@ -357,51 +360,54 @@ import {
createViewWeek,
createViewMonthGrid,
createViewMonthAgenda,
} from '@schedule-x/calendar'
import { createEventsServicePlugin } from '@schedule-x/events-service'
import { createEventModalPlugin } from '@schedule-x/event-modal'
} from '@schedule-x/calendar';
import { createEventsServicePlugin } from '@schedule-x/events-service';
import { createEventModalPlugin } from '@schedule-x/event-modal';
export const WEEK_START_DAY = 0 // 0 = Sunday in project convention; Schedule-X uses 7 = Sunday
export const WEEK_START_DAY = 0; // 0 = Sunday in project convention; Schedule-X uses 7 = Sunday
// Schedule-X v4 firstDayOfWeek: Temporal numbering — 1=Mon, 7=Sun
// Must translate from project convention (0=Sun) to Schedule-X (7=Sun)
function toSXWeekStart(dayConvention: number): number {
return dayConvention === 0 ? 7 : dayConvention
return dayConvention === 0 ? 7 : dayConvention;
}
export interface MemberCalendarConfig {
id: string // String(users.id)
name: string // users.displayName
color: string // users.color hex
id: string; // String(users.id)
name: string; // users.displayName
color: string; // users.color hex
}
export function buildCalendarConfig(members: MemberCalendarConfig[]) {
const calendars: Record<string, { colorName: string; lightColors: { main: string; container: string; onContainer: string } }> = {}
const calendars: Record<
string,
{ colorName: string; lightColors: { main: string; container: string; onContainer: string } }
> = {};
// Shared-family calendar: reserved rose color
calendars['shared'] = {
colorName: 'shared',
lightColors: deriveScheduleXColors('#F25C7A'),
}
};
// Per-member calendars keyed by String(userId)
for (const m of members) {
calendars[m.id] = {
colorName: `member-${m.id}`,
lightColors: deriveScheduleXColors(m.color),
}
};
}
return { calendars }
return { calendars };
}
// UI-SPEC color derivation: container = main at 15% opacity over white, onContainer = main darkened 40%
function deriveScheduleXColors(main: string) {
return {
main,
container: hexWithOpacity(main, 0.15), // CSS rgba computed over #FFFFFF
container: hexWithOpacity(main, 0.15), // CSS rgba computed over #FFFFFF
onContainer: darkenHex(main, 0.4),
}
};
}
```
@@ -467,7 +473,7 @@ function CalendarShell() {
// apps/api/src/auth/devBypass.ts
// Active ONLY when DEV_AUTH_BYPASS=true AND NODE_ENV !== 'production'
// Injects a fixed dev user into the request context so oidcAuthMiddleware is skipped
import type { MiddlewareHandler } from 'hono'
import type { MiddlewareHandler } from 'hono';
const DEV_USER = {
id: 1,
@@ -475,21 +481,21 @@ const DEV_USER = {
oidcSub: 'dev-user',
displayName: 'Dev User',
color: '#4A90D9',
}
};
export function devAuthBypass(): MiddlewareHandler {
if (process.env.NODE_ENV === 'production') {
// Hard guard — never active in production regardless of env flag
return async (_c, next) => next()
return async (_c, next) => next();
}
if (process.env.DEV_AUTH_BYPASS !== 'true') {
return async (_c, next) => next()
return async (_c, next) => next();
}
// Inject fixed dev user into Hono context (replaces getAuth(c) result)
return async (c, next) => {
c.set('user', DEV_USER)
await next()
}
c.set('user', DEV_USER);
await next();
};
}
```
@@ -509,14 +515,14 @@ Mount in `index.ts` BEFORE `oidcAuthMiddleware` on `/api/*` when bypass is activ
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Recurring event expansion with EXDATE | Custom RRULE iterator | `ICAL.RecurExpansion` | RecurExpansion handles RDATE, EXDATE, RECURRENCE-ID in one integrated iterator |
| All four calendar views | Custom React grid | `@schedule-x/calendar` views | Day/week/month/agenda correctly handling overlap, all-day banners, and touch is 3-6 weeks of work |
| Custom event modal | Custom DOM overlay | `createEventModalPlugin` + `customComponents.eventModal` | Schedule-X positions the modal relative to the event; re-use in Phase 3 is built-in |
| VTIMEZONE DST tables | Custom offset lookup | `ICAL.TimezoneService.register()` from parsed VTIMEZONE | The VTIMEZONE component in the ICS already contains the correct DST rules for the calendar's timezone |
| Calendar color derivation | Manual CSS computation | `colorUtils.ts` utility function (small, one-file) | The 15%/darken derivation is simple enough to implement inline; no third-party needed |
| iCalendar string parsing | Custom VCALENDAR parser | `ICAL.parse()` + `ICAL.Component` | VCALENDAR has pathological edge cases (folded lines, UTF-8 encoded params, VTIMEZONE nesting) |
| Problem | Don't Build | Use Instead | Why |
| ------------------------------------- | ----------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Recurring event expansion with EXDATE | Custom RRULE iterator | `ICAL.RecurExpansion` | RecurExpansion handles RDATE, EXDATE, RECURRENCE-ID in one integrated iterator |
| All four calendar views | Custom React grid | `@schedule-x/calendar` views | Day/week/month/agenda correctly handling overlap, all-day banners, and touch is 3-6 weeks of work |
| Custom event modal | Custom DOM overlay | `createEventModalPlugin` + `customComponents.eventModal` | Schedule-X positions the modal relative to the event; re-use in Phase 3 is built-in |
| VTIMEZONE DST tables | Custom offset lookup | `ICAL.TimezoneService.register()` from parsed VTIMEZONE | The VTIMEZONE component in the ICS already contains the correct DST rules for the calendar's timezone |
| Calendar color derivation | Manual CSS computation | `colorUtils.ts` utility function (small, one-file) | The 15%/darken derivation is simple enough to implement inline; no third-party needed |
| iCalendar string parsing | Custom VCALENDAR parser | `ICAL.parse()` + `ICAL.Component` | VCALENDAR has pathological edge cases (folded lines, UTF-8 encoded params, VTIMEZONE nesting) |
**Key insight:** Calendar view rendering that handles overlap, drag handle exclusion zones, DST, all-day banners, and touch gestures for iOS correctly is multi-month work. Schedule-X exists precisely for this.
@@ -603,63 +609,60 @@ Mount in `index.ts` BEFORE `oidcAuthMiddleware` on `/api/*` when bypass is activ
```typescript
// Source: https://github.com/kewisch/ical.js/wiki/Common-Use-Cases
// Source: https://kewisch.github.io/ical.js/api/
import ICAL from 'ical.js'
import ICAL from 'ical.js';
function expandVeventOccurrences(
rawVcalendar: string,
windowStart: Date,
windowEnd: Date,
): Array<{ dtstart: Date; dtend: Date; allDay: boolean }> {
const parsed = ICAL.parse(rawVcalendar)
const comp = new ICAL.Component(parsed)
const parsed = ICAL.parse(rawVcalendar);
const comp = new ICAL.Component(parsed);
// Step 1: Register all VTIMEZONE components in this VCALENDAR.
// Must happen BEFORE constructing ICAL.RecurExpansion.
for (const vtz of comp.getAllSubcomponents('vtimezone')) {
const tzid = vtz.getFirstPropertyValue('tzid') as string
const tzid = vtz.getFirstPropertyValue('tzid') as string;
if (tzid && !ICAL.TimezoneService.has(tzid)) {
ICAL.TimezoneService.register(
tzid,
new ICAL.Timezone({ component: vtz, tzid }),
)
ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtz, tzid }));
}
}
const vevent = comp.getFirstSubcomponent('vevent')
if (!vevent) return []
const vevent = comp.getFirstSubcomponent('vevent');
if (!vevent) return [];
const event = new ICAL.Event(vevent)
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time
const allDay = dtstart.isDate
const event = new ICAL.Event(vevent);
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time;
const allDay = dtstart.isDate;
const results: Array<{ dtstart: Date; dtend: Date; allDay: boolean }> = []
const rangeStart = ICAL.Time.fromJSDate(windowStart, false)
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, false)
const results: Array<{ dtstart: Date; dtend: Date; allDay: boolean }> = [];
const rangeStart = ICAL.Time.fromJSDate(windowStart, false);
const rangeEnd = ICAL.Time.fromJSDate(windowEnd, false);
if (!event.isRecurring()) {
if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) {
const dtend = vevent.getFirstPropertyValue('dtend') as ICAL.Time | null
const dtend = vevent.getFirstPropertyValue('dtend') as ICAL.Time | null;
results.push({
dtstart: dtstart.toJSDate(),
dtend: (dtend ?? dtstart).toJSDate(),
allDay,
})
});
}
return results
return results;
}
// RecurExpansion handles RRULE + RDATE + EXDATE internally
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart })
let next: ICAL.Time | null
const expand = new ICAL.RecurExpansion({ component: vevent, dtstart });
let next: ICAL.Time | null;
while ((next = expand.next()) && next.compare(rangeEnd) < 0) {
if (next.compare(rangeStart) < 0) continue
if (next.compare(rangeStart) < 0) continue;
// Compute end using the original event's duration
const duration = event.duration
const occEnd = next.clone()
occEnd.addDuration(duration)
results.push({ dtstart: next.toJSDate(), dtend: occEnd.toJSDate(), allDay })
const duration = event.duration;
const occEnd = next.clone();
occEnd.addDuration(duration);
results.push({ dtstart: next.toJSDate(), dtend: occEnd.toJSDate(), allDay });
}
return results
return results;
}
```
@@ -671,10 +674,14 @@ function expandVeventOccurrences(
// The client must use Temporal.PlainDate — NOT ZonedDateTime
// WRONG (shifts date in negative-offset timezones):
{ start: Temporal.ZonedDateTime.from('2026-06-15T00:00:00Z') }
{
start: Temporal.ZonedDateTime.from('2026-06-15T00:00:00Z');
}
// CORRECT:
{ start: Temporal.PlainDate.from('2026-06-15') }
{
start: Temporal.PlainDate.from('2026-06-15');
}
```
### Schedule-X CSS Token Override Pattern
@@ -689,7 +696,7 @@ function expandVeventOccurrences(
:root {
/* Map Schedule-X color vars to project tokens */
--sx-color-primary: var(--color-member-0); /* current user's color */
--sx-color-primary: var(--color-member-0); /* current user's color */
--sx-color-on-primary: #ffffff;
--sx-color-surface: var(--color-surface);
--sx-color-on-surface: var(--color-text-primary);
@@ -712,9 +719,9 @@ function expandVeventOccurrences(
```typescript
// apps/api/src/routes/events.ts — current implementation
eventsRouter.get('/', async (c) => {
const events = await db.select().from(calendarEvents) // no window, no join, no expansion
return c.json({ events })
})
const events = await db.select().from(calendarEvents); // no window, no join, no expansion
return c.json({ events });
});
```
### Target state (Phase 2)
@@ -722,14 +729,14 @@ eventsRouter.get('/', async (c) => {
```typescript
// apps/api/src/routes/events.ts — evolved
eventsRouter.get('/', async (c) => {
const { start, end } = c.req.query()
const { start, end } = c.req.query();
// Zod-validate start/end as ISO dates
// SQL: calendarEvents JOIN calendars JOIN users
// WHERE (dtstartUtc BETWEEN start AND end) OR (dtstartDate BETWEEN start AND end)
// OR event.hasRrule (to catch recurring masters whose window occurrence may differ)
// For each row: call expandOccurrences(rawVevent, windowStart, windowEnd, ...)
// Return: { occurrences: CalendarOccurrence[] }
})
});
```
**SQL pre-filter strategy:** The SQL `WHERE` must also include events with an RRULE property that _started before_ the window, because a weekly meeting created 3 years ago can still have occurrences in the current window. Include a `hasRrule` boolean column (can be added via migration) or parse `rawVevent` in the expansion step and skip in-memory if no occurrences fall in window. The simpler approach: include all events where `dtstartUtc < windowEnd` (no lower bound) OR `dtstartDate < windowEnd`, then let `expandOccurrences` handle the window check. Add a schema migration to add a `hasRrule` boolean indexed column to `calendarEvents` to avoid scanning all historical events on every request.
@@ -740,14 +747,15 @@ eventsRouter.get('/', async (c) => {
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Schedule-X ISO string events `"YYYY-MM-DD HH:MM"` | `Temporal.ZonedDateTime` / `Temporal.PlainDate` | Schedule-X v3 (2024) | Server must return parseable strings; client must hydrate |
| `react-big-calendar` (moment/date-fns) | Schedule-X (Temporal-based) | 2024 ecosystem shift | react-big-calendar's CSS is hard to override; Schedule-X CSS tokens are first-class |
| `FullCalendar` open-source | Schedule-X (fully MIT) | 2024 for self-hosted | FullCalendar premium features are commercial; Schedule-X is fully open |
| rrule-only recurrence expansion | `ICAL.RecurExpansion` (higher-level) | ical.js 1.x+ | RecurExpansion integrates RRULE + RDATE + EXDATE; no separate EXDATE handling needed |
| Old Approach | Current Approach | When Changed | Impact |
| ------------------------------------------------- | ----------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------ |
| Schedule-X ISO string events `"YYYY-MM-DD HH:MM"` | `Temporal.ZonedDateTime` / `Temporal.PlainDate` | Schedule-X v3 (2024) | Server must return parseable strings; client must hydrate |
| `react-big-calendar` (moment/date-fns) | Schedule-X (Temporal-based) | 2024 ecosystem shift | react-big-calendar's CSS is hard to override; Schedule-X CSS tokens are first-class |
| `FullCalendar` open-source | Schedule-X (fully MIT) | 2024 for self-hosted | FullCalendar premium features are commercial; Schedule-X is fully open |
| rrule-only recurrence expansion | `ICAL.RecurExpansion` (higher-level) | ical.js 1.x+ | RecurExpansion integrates RRULE + RDATE + EXDATE; no separate EXDATE handling needed |
**Deprecated/outdated:**
- `react-big-calendar`: Not deprecated per se, but the CSS override story is significantly worse for a token-based design system. The UI-SPEC already rejected it.
- Schedule-X v2 ISO string format: Removed in v3. Any tutorial older than mid-2024 using string dates is wrong.
@@ -757,36 +765,36 @@ eventsRouter.get('/', async (c) => {
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Vitest (already configured in `apps/api/vitest.config.ts`) |
| Config file | `apps/api/vitest.config.ts` (exists); `apps/pwa` has no test setup — needs Wave 0 |
| Quick run command | `pnpm --filter @familysync/api test` |
| Full suite command | `pnpm -r test` (workspace-wide) |
| Property | Value |
| ------------------ | --------------------------------------------------------------------------------- |
| Framework | Vitest (already configured in `apps/api/vitest.config.ts`) |
| Config file | `apps/api/vitest.config.ts` (exists); `apps/pwa` has no test setup — needs Wave 0 |
| Quick run command | `pnpm --filter @familysync/api test` |
| Full suite command | `pnpm -r test` (workspace-wide) |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| CAL-02 (color) | Events returned with correct `color` field from `users.color` | unit | `pnpm --filter @familysync/api test -- tests/routes/events.test.ts` | ❌ Wave 0 |
| CAL-02 (aggregation) | Events from multiple calendars (multiple users) returned in single response | unit | same file | ❌ Wave 0 |
| CAL-03 (views) | Schedule-X renders without error with all four views configured | smoke | `pnpm --filter @familysync/pwa test -- calendar.spec.tsx` | ❌ Wave 0 |
| CAL-07 (recurrence) | `expandOccurrences()` returns correct occurrences for weekly RRULE in a 30-day window | unit | `pnpm --filter @familysync/api test -- tests/broker/expand.test.ts` | ❌ Wave 0 |
| CAL-07 (DST) | `expandOccurrences()` with America/New_York RRULE across March DST boundary returns correct wall-clock times | unit | same file | ❌ Wave 0 |
| CAL-07 (all-day) | `expandOccurrences()` for all-day event returns `allDay: true` and `start: 'YYYY-MM-DD'` with no time component | unit | same file | ❌ Wave 0 |
| CAL-07 (EXDATE) | `expandOccurrences()` excludes EXDATE occurrences from expansion | unit | same file | ❌ Wave 0 |
| CAL-07 (Temporal) | `hydrateEvents()` converts all-day occurrences to `Temporal.PlainDate` and timed to `Temporal.ZonedDateTime` | unit | `pnpm --filter @familysync/pwa test -- lib/hydrateEvents.test.ts` | ❌ Wave 0 |
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
| -------------------- | --------------------------------------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------- | ------------ |
| CAL-02 (color) | Events returned with correct `color` field from `users.color` | unit | `pnpm --filter @familysync/api test -- tests/routes/events.test.ts` | ❌ Wave 0 |
| CAL-02 (aggregation) | Events from multiple calendars (multiple users) returned in single response | unit | same file | ❌ Wave 0 |
| CAL-03 (views) | Schedule-X renders without error with all four views configured | smoke | `pnpm --filter @familysync/pwa test -- calendar.spec.tsx` | ❌ Wave 0 |
| CAL-07 (recurrence) | `expandOccurrences()` returns correct occurrences for weekly RRULE in a 30-day window | unit | `pnpm --filter @familysync/api test -- tests/broker/expand.test.ts` | ❌ Wave 0 |
| CAL-07 (DST) | `expandOccurrences()` with America/New_York RRULE across March DST boundary returns correct wall-clock times | unit | same file | ❌ Wave 0 |
| CAL-07 (all-day) | `expandOccurrences()` for all-day event returns `allDay: true` and `start: 'YYYY-MM-DD'` with no time component | unit | same file | ❌ Wave 0 |
| CAL-07 (EXDATE) | `expandOccurrences()` excludes EXDATE occurrences from expansion | unit | same file | ❌ Wave 0 |
| CAL-07 (Temporal) | `hydrateEvents()` converts all-day occurrences to `Temporal.PlainDate` and timed to `Temporal.ZonedDateTime` | unit | `pnpm --filter @familysync/pwa test -- lib/hydrateEvents.test.ts` | ❌ Wave 0 |
### Fixture ICS Files (test corpus)
The most valuable test artifacts are fixture `.ics` files. Create in `apps/api/tests/fixtures/`:
| Filename | Contents | Tests |
|----------|----------|-------|
| `weekly-dst.ics` | Weekly meeting at 10:00 America/New_York spanning March DST transition (2026-03-01 to 2026-04-30) | CAL-07 DST |
| `allday-birthday.ics` | Annual birthday event (DATE type, no DTEND) | CAL-07 all-day |
| `exdate-series.ics` | Weekly series with one EXDATE (a skipped occurrence) | CAL-07 EXDATE |
| `multi-cal.ics` | Two separate VCALENDAR blobs to represent two members' events | CAL-02 aggregation |
| Filename | Contents | Tests |
| --------------------- | ------------------------------------------------------------------------------------------------- | ------------------ |
| `weekly-dst.ics` | Weekly meeting at 10:00 America/New_York spanning March DST transition (2026-03-01 to 2026-04-30) | CAL-07 DST |
| `allday-birthday.ics` | Annual birthday event (DATE type, no DTEND) | CAL-07 all-day |
| `exdate-series.ics` | Weekly series with one EXDATE (a skipped occurrence) | CAL-07 EXDATE |
| `multi-cal.ics` | Two separate VCALENDAR blobs to represent two members' events | CAL-02 aggregation |
These fixture files can be generated from real Fastmail ICS exports or hand-crafted with known-correct VTIMEZONE blocks.
@@ -813,34 +821,34 @@ These fixture files can be generated from real Fastmail ICS exports or hand-craf
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes — dev bypass must not leak | `NODE_ENV === 'production'` hard guard in `devAuthBypass()` |
| V3 Session Management | carried from Phase 1 | `@hono/oidc-auth` JWT cookie (httpOnly + Secure + SameSite) |
| V4 Access Control | yes — `/api/events` must be authenticated | `oidcAuthMiddleware` on `/api/*` (Phase 1 pattern) |
| V5 Input Validation | yes — `?start=` and `?end=` query params | `zod` + `@hono/zod-validator`: validate ISO date format before SQL |
| V6 Cryptography | no new crypto in Phase 2 | — |
| ASVS Category | Applies | Standard Control |
| --------------------- | ----------------------------------------- | ------------------------------------------------------------------ |
| V2 Authentication | yes — dev bypass must not leak | `NODE_ENV === 'production'` hard guard in `devAuthBypass()` |
| V3 Session Management | carried from Phase 1 | `@hono/oidc-auth` JWT cookie (httpOnly + Secure + SameSite) |
| V4 Access Control | yes — `/api/events` must be authenticated | `oidcAuthMiddleware` on `/api/*` (Phase 1 pattern) |
| V5 Input Validation | yes — `?start=` and `?end=` query params | `zod` + `@hono/zod-validator`: validate ISO date format before SQL |
| V6 Cryptography | no new crypto in Phase 2 | — |
### Known Threat Patterns for This Phase
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Dev-auth bypass left active in production | Elevation of privilege | Hard `NODE_ENV !== 'production'` guard; `.env.example` warning |
| SQL injection via `?start=` / `?end=` date params | Tampering | Zod ISO date validation; Drizzle parameterized queries |
| XSS via event title/description in EventDetailPopover | Tampering | React's default JSX escaping; never use `dangerouslySetInnerHTML` for event fields |
| Overfetch (no window) timing/DoS | Denial of service | Zod-enforce required `start` + `end` params; cap window to 90 days max |
| Pattern | STRIDE | Standard Mitigation |
| ----------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------- |
| Dev-auth bypass left active in production | Elevation of privilege | Hard `NODE_ENV !== 'production'` guard; `.env.example` warning |
| SQL injection via `?start=` / `?end=` date params | Tampering | Zod ISO date validation; Drizzle parameterized queries |
| XSS via event title/description in EventDetailPopover | Tampering | React's default JSX escaping; never use `dangerouslySetInnerHTML` for event fields |
| Overfetch (no window) timing/DoS | Denial of service | Zod-enforce required `start` + `end` params; cap window to 90 days max |
---
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js 22 | API + PWA build | ✓ | (WSL2 dev env — assumed from Phase 1) | — |
| pnpm | Workspace install | ✓ | (Phase 1 used it) | — |
| MariaDB (Docker) | `/api/events` windowed query | ✓ | Phase 1 confirmed: 503 events cached | — |
| Temporal (browser) | Schedule-X v4 | Partial | Needs `temporal-polyfill` in PWA | `temporal-polyfill@0.3.2` — no fallback needed |
| Live Authelia/Pangolin | Full auth flow | ✗ (D-14 deferred) | — | Dev-auth bypass middleware (must build in Phase 2) |
| Dependency | Required By | Available | Version | Fallback |
| ---------------------- | ---------------------------- | ----------------- | ------------------------------------- | -------------------------------------------------- |
| Node.js 22 | API + PWA build | ✓ | (WSL2 dev env — assumed from Phase 1) | — |
| pnpm | Workspace install | ✓ | (Phase 1 used it) | — |
| MariaDB (Docker) | `/api/events` windowed query | ✓ | Phase 1 confirmed: 503 events cached | — |
| Temporal (browser) | Schedule-X v4 | Partial | Needs `temporal-polyfill` in PWA | `temporal-polyfill@0.3.2` — no fallback needed |
| Live Authelia/Pangolin | Full auth flow | ✗ (D-14 deferred) | — | Dev-auth bypass middleware (must build in Phase 2) |
**Missing with no fallback:** None. Dev-auth bypass covers the Authelia deferral.
@@ -848,12 +856,12 @@ These fixture files can be generated from real Fastmail ICS exports or hand-craf
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `ICAL.RecurExpansion` handles EXDATE internally when using the high-level API | Architecture Patterns | Planner would need to add manual EXDATE filtering in `expandOccurrences` |
| A2 | `@schedule-x/react@4.1.0` is API-compatible with `@schedule-x/calendar@4.6.0` for the features used (views, calendars, onRangeUpdate, customComponents) | Standard Stack | Version mismatch may cause TypeScript errors on newer options; test in Wave 0 |
| A3 | The shared-family calendar can be identified programmatically (by displayName or a new `isShared` column) without a schema migration | Backend: /api/events evolution | If not deterministic, Phase 2 plan must include a migration adding `calendars.isShared` |
| A4 | `onRangeUpdate` fires immediately on mount with the initial window | Architecture Patterns | If it does not fire on mount, initial fetch requires a separate first-render trigger |
| # | Claim | Section | Risk if Wrong |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------- |
| A1 | `ICAL.RecurExpansion` handles EXDATE internally when using the high-level API | Architecture Patterns | Planner would need to add manual EXDATE filtering in `expandOccurrences` |
| A2 | `@schedule-x/react@4.1.0` is API-compatible with `@schedule-x/calendar@4.6.0` for the features used (views, calendars, onRangeUpdate, customComponents) | Standard Stack | Version mismatch may cause TypeScript errors on newer options; test in Wave 0 |
| A3 | The shared-family calendar can be identified programmatically (by displayName or a new `isShared` column) without a schema migration | Backend: /api/events evolution | If not deterministic, Phase 2 plan must include a migration adding `calendars.isShared` |
| A4 | `onRangeUpdate` fires immediately on mount with the initial window | Architecture Patterns | If it does not fire on mount, initial fetch requires a separate first-render trigger |
**A1 verification:** The ical.js wiki states RecurExpansion "takes into account recurrence exceptions (RDATE and EXDATE)" [CITED: github.com/kewisch/ical.js/wiki/Common-Use-Cases]. Treat as HIGH confidence.
**A2 verification:** Peer dep `^4.0.0` satisfied by 4.6.0 [VERIFIED: npm registry]. API surface used (views, calendars, onRangeUpdate) is stable since v4.0.0. Treat as MEDIUM confidence — validate in Wave 0.
@@ -884,6 +892,7 @@ These fixture files can be generated from real Fastmail ICS exports or hand-craf
## Sources
### Primary (HIGH confidence)
- [schedule-x.dev/docs/frameworks/react](https://schedule-x.dev/docs/frameworks/react) — React adapter usage, views, eventsService plugin
- [schedule-x.dev/docs/calendar/calendars](https://schedule-x.dev/docs/calendar/calendars) — lightColors config, calendarId on events
- [schedule-x.dev/docs/calendar/events](https://schedule-x.dev/docs/calendar/events) — Temporal.ZonedDateTime / Temporal.PlainDate requirement
@@ -895,11 +904,13 @@ These fixture files can be generated from real Fastmail ICS exports or hand-craf
- `npm view` on all Phase 2 packages — versions and publish dates confirmed [VERIFIED: npm registry]
### Secondary (MEDIUM confidence)
- [schedule-x.dev/docs/calendar/major-version-migrations](https://schedule-x.dev/docs/calendar/major-version-migrations) — v2→v3 breaking changes (Temporal adoption confirmed)
- [schedule-x.dev/docs/calendar/plugins/event-modal](https://schedule-x.dev/docs/calendar/plugins/event-modal) — createEventModalPlugin + customComponents.eventModal
- WebSearch on rrule DST behavior — confirmed known issue with `tzid` parameter and UTC fallback; `ICAL.RecurExpansion` is the recommended alternative
### Tertiary (LOW confidence)
- WebSearch results on VTIMEZONE registration best practices — cross-verified with official ical.js wiki
---
@@ -907,6 +918,7 @@ These fixture files can be generated from real Fastmail ICS exports or hand-craf
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all packages verified on npm registry; Schedule-X selected in UI-SPEC
- Architecture (recurrence expansion): HIGH — ICAL.RecurExpansion documented in official ical.js wiki; Phase 1 sync.ts pattern extended
- Architecture (Schedule-X Temporal format): HIGH — verified against official Schedule-X docs
@@ -8,6 +8,7 @@ created: 2026-06-04
---
# Phase 2 — UI Design Contract
## Calendar Display
> Visual and interaction contract for Phase 2. Generated by gsd-ui-researcher.
@@ -17,13 +18,13 @@ created: 2026-06-04
## Design System
| Property | Value |
|----------|-------|
| Tool | none (shadcn not yet initialized) |
| Preset | not applicable |
| Component library | none — custom components against token layer |
| Icon library | lucide-react (lightweight, tree-shakeable, first-party React SVGs; consistent stroke style) |
| Font | system-ui stack: `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` |
| Property | Value |
| ----------------- | ------------------------------------------------------------------------------------------- |
| Tool | none (shadcn not yet initialized) |
| Preset | not applicable |
| Component library | none — custom components against token layer |
| Icon library | lucide-react (lightweight, tree-shakeable, first-party React SVGs; consistent stroke style) |
| Font | system-ui stack: `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` |
**Note:** No `components.json` exists in the PWA app. The Phase 1 shell uses inline styles.
Phase 2 introduces a CSS custom-property token layer (see § Token Layer below) as the primary
@@ -51,7 +52,7 @@ props; they use `var(--token-name)` in CSS Modules or `className` strings.
```ts
// apps/pwa/src/lib/calendarConfig.ts
export const WEEK_START_DAY = 0 // 0 = Sunday; flip to 1 = Monday with one edit
export const WEEK_START_DAY = 0; // 0 = Sunday; flip to 1 = Monday with one edit
```
Pass to Schedule-X's `firstDayOfWeek` option. Do not hardcode 0 anywhere else.
@@ -62,33 +63,33 @@ Pass to Schedule-X's `firstDayOfWeek` option. Do not hardcode 0 anywhere else.
### Base palette
| Token | Hex | Role |
|-------|-----|------|
| `--color-surface` | `#FFFFFF` | Page background, calendar grid cells |
| `--color-surface-dim` | `#F7F7F8` | Off-white wash: week/day off-hours bands, modal backdrop |
| `--color-surface-raised` | `#FFFFFF` | Cards, popovers (shadow provides elevation) |
| `--color-border` | `#E2E4E9` | Grid lines, dividers, input borders |
| `--color-border-subtle` | `#ECEEF2` | Secondary separators |
| `--color-text-primary` | `#111318` | Body text, event titles |
| `--color-text-secondary` | `#6B7280` | Meta text: times, locations, legend labels |
| `--color-text-muted` | `#9CA3AF` | Placeholder, empty-state body, disabled |
| `--color-focus-ring` | `#4A90D9` | Keyboard focus outline (3px, 2px offset) |
| `--color-overlay` | `rgba(0,0,0,0.32)` | Popover backdrop scrim |
| Token | Hex | Role |
| ------------------------ | ------------------ | -------------------------------------------------------- |
| `--color-surface` | `#FFFFFF` | Page background, calendar grid cells |
| `--color-surface-dim` | `#F7F7F8` | Off-white wash: week/day off-hours bands, modal backdrop |
| `--color-surface-raised` | `#FFFFFF` | Cards, popovers (shadow provides elevation) |
| `--color-border` | `#E2E4E9` | Grid lines, dividers, input borders |
| `--color-border-subtle` | `#ECEEF2` | Secondary separators |
| `--color-text-primary` | `#111318` | Body text, event titles |
| `--color-text-secondary` | `#6B7280` | Meta text: times, locations, legend labels |
| `--color-text-muted` | `#9CA3AF` | Placeholder, empty-state body, disabled |
| `--color-focus-ring` | `#4A90D9` | Keyboard focus outline (3px, 2px offset) |
| `--color-overlay` | `rgba(0,0,0,0.32)` | Popover backdrop scrim |
### Semantic calendar colors
These are the ONLY colors used for event fills. All are derived from member records
(`users.color`) or the reserved shared-family constant.
| Token | Hex | Assigned to | Source |
|-------|-----|-------------|--------|
| `--color-member-0` | `#4A90D9` | Lucas (member 1) | Phase-1 `users.color` |
| `--color-member-1` | `#50C878` | Wife (member 2) | Phase-1 `users.color` |
| `--color-member-2` | `#F5A623` | Slot 3 (future) | Phase-1 palette |
| `--color-member-3` | `#9B59B6` | Slot 4 (future) | Phase-1 palette |
| `--color-member-4` | `#E67E22` | Slot 5 (future) | Phase-1 palette |
| `--color-member-5` | `#1ABC9C` | Slot 6 (future) | Phase-1 palette |
| `--color-shared-family` | `#F25C7A` | Shared-family calendar (ALL members) | Confirmed by user |
| Token | Hex | Assigned to | Source |
| ----------------------- | --------- | ------------------------------------ | --------------------- |
| `--color-member-0` | `#4A90D9` | Lucas (member 1) | Phase-1 `users.color` |
| `--color-member-1` | `#50C878` | Wife (member 2) | Phase-1 `users.color` |
| `--color-member-2` | `#F5A623` | Slot 3 (future) | Phase-1 palette |
| `--color-member-3` | `#9B59B6` | Slot 4 (future) | Phase-1 palette |
| `--color-member-4` | `#E67E22` | Slot 5 (future) | Phase-1 palette |
| `--color-member-5` | `#1ABC9C` | Slot 6 (future) | Phase-1 palette |
| `--color-shared-family` | `#F25C7A` | Shared-family calendar (ALL members) | Confirmed by user |
**Implementation note:** The `calendars` configuration object passed to Schedule-X is built
dynamically at runtime by mapping `users.color` values to Schedule-X `lightColors.main`. The
@@ -99,29 +100,29 @@ The shared-family calendar always uses `#F25C7A` regardless of any user row.
For each member color `MAIN`, derive:
| Sub-token suffix | Derivation | Usage |
|------------------|------------|-------|
| `container` | `MAIN` at 15% opacity over white | Event chip background |
| `onContainer` | `MAIN` darkened 40% | Event chip text, passed to Schedule-X |
| Sub-token suffix | Derivation | Usage |
| ---------------- | -------------------------------- | ------------------------------------- |
| `container` | `MAIN` at 15% opacity over white | Event chip background |
| `onContainer` | `MAIN` darkened 40% | Event chip text, passed to Schedule-X |
These need not be pre-declared for every slot — compute them with a small utility function
(`colorTokens.ts`) at runtime using CSS Color Level 4 or a tiny LCH/hex math helper.
### 60 / 30 / 10 split
| Band | Tokens | Approximate coverage |
|------|--------|----------------------|
| 60% dominant (surface) | `--color-surface`, `--color-surface-dim` | Calendar grid, page background |
| Band | Tokens | Approximate coverage |
| ------------------------- | ------------------------------------------------------------------- | -------------------------------------- |
| 60% dominant (surface) | `--color-surface`, `--color-surface-dim` | Calendar grid, page background |
| 30% secondary (structure) | `--color-surface-raised`, `--color-border`, `--color-border-subtle` | Cards, nav bar, header, popover shells |
| 10% accent | `--color-shared-family` + per-member fills | Event chips only |
| 10% accent | `--color-shared-family` + per-member fills | Event chips only |
**Accent reserved for:** event chip fills and the color legend swatches. Accent colors MUST NOT
appear on buttons, nav items, headings, or any chrome element.
### Destructive
| Token | Hex | Usage |
|-------|-----|-------|
| Token | Hex | Usage |
| --------------------- | --------- | ------------------------------------------------------------------ |
| `--color-destructive` | `#DC2626` | Not used in Phase 2 (read-only). Token declared for Phase 3 reuse. |
---
@@ -130,17 +131,18 @@ appear on buttons, nav items, headings, or any chrome element.
All values are multiples of 4px. Use tokens; never write raw `px` values in components.
| Token | Value | CSS var | Usage |
|-------|-------|---------|-------|
| `space-1` | 4px | `--space-1` | Icon gap, badge dot, tight inline padding |
| `space-2` | 8px | `--space-2` | Event chip inner padding (vertical), color legend row gap |
| `space-3` | 12px | `--space-3` | Event chip inner padding (horizontal), compact cell padding |
| `space-4` | 16px | `--space-4` | Default element spacing, popover section gap |
| `space-6` | 24px | `--space-6` | Section padding, nav bar height rhythm |
| `space-8` | 32px | `--space-8` | Layout gaps, popover width gutter |
| `space-12` | 48px | `--space-12` | Major section breaks |
| Token | Value | CSS var | Usage |
| ---------- | ----- | ------------ | ----------------------------------------------------------- |
| `space-1` | 4px | `--space-1` | Icon gap, badge dot, tight inline padding |
| `space-2` | 8px | `--space-2` | Event chip inner padding (vertical), color legend row gap |
| `space-3` | 12px | `--space-3` | Event chip inner padding (horizontal), compact cell padding |
| `space-4` | 16px | `--space-4` | Default element spacing, popover section gap |
| `space-6` | 24px | `--space-6` | Section padding, nav bar height rhythm |
| `space-8` | 32px | `--space-8` | Layout gaps, popover width gutter |
| `space-12` | 48px | `--space-12` | Major section breaks |
**Exceptions:**
- Touch targets: minimum 44px height/width on interactive elements (iOS HIG). This is a layout
constraint, not a spacing token. Apply via `min-height: 44px`.
- Calendar header row height: 48px (`--space-12` used as a layout constant).
@@ -152,12 +154,12 @@ All values are multiples of 4px. Use tokens; never write raw `px` values in comp
Font family token: `--font-family-base: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
| Role | Token | Size | Weight | Line Height | Usage |
|------|-------|------|--------|-------------|-------|
| Body | `--text-body` | 15px | 400 | 1.5 | Popover description, agenda location lines |
| Label | `--text-label` | 13px | 400 | 1.4 | Event chip text, time labels, legend labels, secondary meta |
| Heading | `--text-heading` | 18px | 600 | 1.25 | Popover title, view section headers (month name + year) |
| Display | `--text-display` | 24px | 600 | 1.2 | App name in nav bar (desktop), day number in day-view header |
| Role | Token | Size | Weight | Line Height | Usage |
| ------- | ---------------- | ---- | ------ | ----------- | ------------------------------------------------------------ |
| Body | `--text-body` | 15px | 400 | 1.5 | Popover description, agenda location lines |
| Label | `--text-label` | 13px | 400 | 1.4 | Event chip text, time labels, legend labels, secondary meta |
| Heading | `--text-heading` | 18px | 600 | 1.25 | Popover title, view section headers (month name + year) |
| Display | `--text-display` | 24px | 600 | 1.2 | App name in nav bar (desktop), day number in day-view header |
**Weights declared:** 400 (regular) and 600 (semibold). No other weights permitted.
@@ -169,17 +171,19 @@ Font family token: `--font-family-base: system-ui, -apple-system, BlinkMacSystem
These are the only three breakpoints. Reference them by name in code, never by raw px value.
| Name | Token | Min width | Default view | Notes |
|------|-------|-----------|--------------|-------|
| `phone` | `--bp-phone` | 0px | Agenda | Stacked single-column layout |
| `tablet` | `--bp-tablet` | 768px | Month | Two-column possible; nav becomes persistent sidebar |
| `desktop` | `--bp-desktop` | 1280px | Month | Full grid width |
| Name | Token | Min width | Default view | Notes |
| --------- | -------------- | --------- | ------------ | --------------------------------------------------- |
| `phone` | `--bp-phone` | 0px | Agenda | Stacked single-column layout |
| `tablet` | `--bp-tablet` | 768px | Month | Two-column possible; nav becomes persistent sidebar |
| `desktop` | `--bp-desktop` | 1280px | Month | Full grid width |
**View default logic (D-05):**
```ts
const isMobile = window.matchMedia('(max-width: 767px)').matches
const defaultView = isMobile ? 'month-agenda' : 'month-grid'
const isMobile = window.matchMedia('(max-width: 767px)').matches;
const defaultView = isMobile ? 'month-agenda' : 'month-grid';
```
Last-used view is persisted in Zustand and localStorage, keyed by breakpoint group
(`'phone' | 'tablet-desktop'`).
@@ -190,12 +194,13 @@ Last-used view is persisted in Zustand and localStorage, keyed by breakpoint gro
**Selected: Schedule-X** (`@schedule-x/react` + `@schedule-x/calendar`)
**Rationale:**
- Supports all four required views natively: `createViewDay`, `createViewWeek`,
`createViewMonthGrid`, `createViewMonthAgenda` (agenda).
- Theming via CSS custom properties — its `--sx-color-*` vars are overridden by mapping to
this spec's token values in `tokens.css`. No Schedule-X default stylesheet bleeds through.
- Per-calendar color is first-class (`calendars` config with `lightColors.main / container /
onContainer`) — maps directly to per-member `users.color` and the shared-family rose.
onContainer`) — maps directly to per-member `users.color` and the shared-family rose.
- `onRangeUpdate` callback fires when the user navigates, enabling TanStack Query to fetch
only the visible window from `/api/events?start=&end=`.
- Custom `eventModal` component via `customComponents` prop — the read-only detail popover
@@ -204,6 +209,7 @@ Last-used view is persisted in Zustand and localStorage, keyed by breakpoint gro
- Active maintenance; Temporal-polyfill based (aligns with modern date handling).
**Rejected alternatives:**
- `react-big-calendar`: opinionated CSS (hard to token-ify without !important fights);
unmaintained `moment` / `date-fns` localization coupling; weak agenda view.
- `FullCalendar`: commercial license for premium features; React package adds ~140 KB gzip.
@@ -220,17 +226,21 @@ Result: Schedule-X internal layout engine works; all colors come from this spec'
## Component Inventory
### CalendarShell
Top-level layout wrapper.
- `<AppNav>` (top bar on phone; left sidebar 240px on tablet/desktop)
- `<ViewToolbar>` (Today button, prev/next arrows, date label, view switcher)
- `<ColorLegend>` (member → color; always visible on tablet/desktop; collapsible on phone)
- `<ScheduleXCalendar>` (fills remaining space)
### AppNav
- Phone: top bar, 48px height, app name left, user avatar/color swatch right
- Tablet/Desktop: left sidebar, 240px width; app name + color legend + (future) nav items
### ViewToolbar
- Buttons: Today | < | > | [Day] [Week] [Month] [Agenda]
- Font: 13px label weight
- Active view button: `--color-member-0` (Lucas, current user) background at 12% opacity,
@@ -238,12 +248,14 @@ Top-level layout wrapper.
- Touch targets: 44px minimum height
### ColorLegend
- One row per member: color swatch (12px circle) + display name
- Shared-family row: rose swatch + "Family" label
- Font: 13px label weight, `--color-text-secondary`
- Always rendered; never interactive in Phase 2 (show/hide filter deferred)
### EventChip (month grid)
- Rounded pill, 4px radius
- Background: member `container` color (15% opacity)
- Text: member `onContainer` color, 13px, weight 400, single line, truncated with ellipsis
@@ -251,18 +263,21 @@ Top-level layout wrapper.
- Minimum height: 20px; minimum tap target area: 44px via transparent padding
### EventBlock (week / day view)
- Rectangular block, 4px radius
- Same fill/border as EventChip
- Displays: title (13px, weight 600) + start time (13px, weight 400) stacked
- Overflow clips; no ellipsis in short blocks (too short = just color)
### AgendaRow
- Date group header: heading weight (18px/600), `--color-text-primary`
- Event row: time (13px, muted) | title (15px, primary) | location (13px, secondary, italic)
- Left 4px border strip: member color
- Tap target: full row, min 44px height
### EventDetailPopover (read-only in Phase 2; reused as edit surface in Phase 3)
- Modal-style overlay on phone (full bottom sheet, slides up)
- Popover anchored to event on tablet/desktop (max-width 360px, 8px radius, shadow)
- Sections:
@@ -275,6 +290,7 @@ Top-level layout wrapper.
- Phase 3 note: add edit/delete actions in the footer area (reserved but empty in Phase 2)
### SkeletonCalendar
- Month skeleton: 6×7 grid of rounded rect placeholders, animated shimmer
(`background: linear-gradient(90deg, --color-surface-dim, --color-border-subtle, --color-surface-dim)`)
- Agenda skeleton: 4 date-group blocks, 23 rows each, varying widths (6090% of row)
@@ -282,6 +298,7 @@ Top-level layout wrapper.
- No spinner; shimmer only (matches Fantastical-style)
### EmptyState (no events in range)
- Centered in the calendar viewport
- Icon: lucide-react `CalendarDays` (32px, `--color-text-muted`)
- Heading + body copy (see § Copywriting)
@@ -292,6 +309,7 @@ Top-level layout wrapper.
## View Layout Specification
### Month view (default: tablet/desktop)
- 7-column grid, column headers: SunSat (3-letter, label weight)
- Day cells: 4px border, corner shows day number (13px label)
- Today's cell: `--color-surface-dim` background; day number has filled dot indicator
@@ -300,6 +318,7 @@ Top-level layout wrapper.
- Off-month days: day number in `--color-text-muted`; cells at 60% opacity
### Week view
- Time column 48px wide; columns for each day
- Current time indicator: 2px `--color-member-0` (current user's color) horizontal line
- All-day banner row at top, above time grid: full-width event blocks
@@ -307,10 +326,12 @@ Top-level layout wrapper.
- Event blocks overlap-handled by Schedule-X internals
### Day view
- Same layout as week, single day column (full width minus time column)
- Date in header: `--text-display` (24px/600)
### Agenda view (default: phone)
- Chronological list, grouped by date
- Infinite scroll or paginated by month (Schedule-X `createViewMonthAgenda`)
- Past events: not shown; starts at today
@@ -320,20 +341,20 @@ Top-level layout wrapper.
## Copywriting Contract
| Element | Copy |
|---------|------|
| Primary CTA (Phase 2) | None — read-only phase; no create action |
| Empty state heading | "Nothing here" |
| Empty state body | "No events in this period. Try a different date or switch views." |
| Loading state | (No text — skeleton shimmer only) |
| Error state heading | "Couldn't load events" |
| Error state body | "Check your connection and try again." |
| Error action | "Retry" (taps `queryClient.refetchQueries(['events'])`) |
| "+N more" label | "+{N} more" (month grid overflow) |
| Popover close | "×" (aria-label="Close") |
| Today button | "Today" |
| Color legend — shared | "Family" |
| Nav bar — app name | "FamilySync" |
| Element | Copy |
| --------------------- | ----------------------------------------------------------------- |
| Primary CTA (Phase 2) | None — read-only phase; no create action |
| Empty state heading | "Nothing here" |
| Empty state body | "No events in this period. Try a different date or switch views." |
| Loading state | (No text — skeleton shimmer only) |
| Error state heading | "Couldn't load events" |
| Error state body | "Check your connection and try again." |
| Error action | "Retry" (taps `queryClient.refetchQueries(['events'])`) |
| "+N more" label | "+{N} more" (month grid overflow) |
| Popover close | "×" (aria-label="Close") |
| Today button | "Today" |
| Color legend — shared | "Family" |
| Nav bar — app name | "FamilySync" |
**Destructive actions in Phase 2:** None. Phase 2 is read-only.
@@ -342,12 +363,14 @@ Top-level layout wrapper.
## Interaction Contract
### Navigation
- Prev/next: advance by one unit of current view (day/week/month)
- Today: jump to today's date, preserve current view
- View switch: instant; no animation (avoid jank on low-end Android WebViews)
- All transitions: no slide animations; content replaces in-place
### Touch (iOS PWA)
- All tap targets: minimum 44×44px (enforced via `min-height` / `padding`)
- No hover states on touch devices (use `:focus-visible` only)
- Swipe left/right on calendar grid: advance/retreat by one unit (Schedule-X built-in)
@@ -355,6 +378,7 @@ Top-level layout wrapper.
- Tap backdrop / swipe down: close EventDetailPopover (bottom sheet on phone)
### Keyboard / accessibility
- View toolbar buttons: focusable, `role="button"`, keyboard activated with Enter/Space
- Event chips: `role="button"`, `aria-label="{title}, {date}, {time}"`
- Popover: focus trap while open; Escape closes; focus returns to triggering element
@@ -363,6 +387,7 @@ Top-level layout wrapper.
- Skeleton: `aria-busy="true"` on calendar root during loading
### Error / retry
- TanStack Query `retry: 2` for events query; after exhaustion show error state
- Error state replaces calendar grid (not a toast); "Retry" button triggers manual refetch
@@ -370,14 +395,14 @@ Top-level layout wrapper.
## State Management Contract
| State | Owner | Key | Notes |
|-------|-------|-----|-------|
| Visible event list | TanStack Query | `['events', start, end]` | Invalidated on range change |
| Current user (`/api/me`) | TanStack Query | `['me']` | Used for color derivation |
| Selected view | Zustand + localStorage | `calendarView.{breakpointGroup}` | Persisted per device category |
| Selected date (nav) | Zustand | `calendarSelectedDate` | ISO string; not persisted |
| Open popover event ID | Zustand | `openEventId` | `null` when closed |
| Visible range | Zustand | `calendarRange` | `{ start: string, end: string }` — drives Query key |
| State | Owner | Key | Notes |
| ------------------------ | ---------------------- | -------------------------------- | --------------------------------------------------- |
| Visible event list | TanStack Query | `['events', start, end]` | Invalidated on range change |
| Current user (`/api/me`) | TanStack Query | `['me']` | Used for color derivation |
| Selected view | Zustand + localStorage | `calendarView.{breakpointGroup}` | Persisted per device category |
| Selected date (nav) | Zustand | `calendarSelectedDate` | ISO string; not persisted |
| Open popover event ID | Zustand | `openEventId` | `null` when closed |
| Visible range | Zustand | `calendarRange` | `{ start: string, end: string }` — drives Query key |
Server events NEVER enter Zustand. Zustand holds only UI-shape state.
@@ -385,11 +410,11 @@ Server events NEVER enter Zustand. Zustand holds only UI-shape state.
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|-------------|
| shadcn official | none — shadcn not initialized in Phase 2 | not applicable |
| schedule-x (npm) | `@schedule-x/react`, `@schedule-x/calendar`, `@schedule-x/theme-default` | npm package — no registry vetting gate required; standard npm supply chain |
| lucide-react (npm) | icon components | npm package — standard |
| Registry | Blocks Used | Safety Gate |
| ------------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| shadcn official | none — shadcn not initialized in Phase 2 | not applicable |
| schedule-x (npm) | `@schedule-x/react`, `@schedule-x/calendar`, `@schedule-x/theme-default` | npm package — no registry vetting gate required; standard npm supply chain |
| lucide-react (npm) | icon components | npm package — standard |
No third-party shadcn registries in Phase 2.
@@ -397,23 +422,23 @@ No third-party shadcn registries in Phase 2.
## Pre-Population Sources
| Decision | Source |
|----------|--------|
| Shared-family color = `#F25C7A` | User-confirmed in phase prompt |
| `WEEK_START_DAY = 0` (Sunday) | User-confirmed in phase prompt |
| Token-layer architecture (D-01/D-02) | CONTEXT.md §Theming |
| Clean theme only (D-02) | CONTEXT.md §Theming |
| All four views (D-04) | CONTEXT.md §Views, REQUIREMENTS.md CAL-03 |
| Phone→Agenda / tablet→Month default (D-05) | CONTEXT.md §Views |
| Per-member color from `users.color` (D-06) | CONTEXT.md §Color, CLAUDE.md schema |
| Color legend, no show/hide filter (D-07) | CONTEXT.md §Color |
| Informational density + tap-to-expand (D-08) | CONTEXT.md §Event detail |
| Server-side recurrence expansion (D-09) | CONTEXT.md §Recurrence, CLAUDE.md |
| Single local timezone, no shift for all-day (D-10) | CONTEXT.md §Recurrence |
| TanStack Query = server state, Zustand = UI state | CLAUDE.md, CONTEXT.md §Code patterns |
| React 19 + Vite stack | CLAUDE.md §Recommended Stack |
| No shadcn yet (Phase 3 adoption) | Codebase scan (no components.json) |
| Schedule-X as rendering library | Researcher decision (D-Claude); see §Rendering Library |
| Decision | Source |
| -------------------------------------------------- | ------------------------------------------------------ |
| Shared-family color = `#F25C7A` | User-confirmed in phase prompt |
| `WEEK_START_DAY = 0` (Sunday) | User-confirmed in phase prompt |
| Token-layer architecture (D-01/D-02) | CONTEXT.md §Theming |
| Clean theme only (D-02) | CONTEXT.md §Theming |
| All four views (D-04) | CONTEXT.md §Views, REQUIREMENTS.md CAL-03 |
| Phone→Agenda / tablet→Month default (D-05) | CONTEXT.md §Views |
| Per-member color from `users.color` (D-06) | CONTEXT.md §Color, CLAUDE.md schema |
| Color legend, no show/hide filter (D-07) | CONTEXT.md §Color |
| Informational density + tap-to-expand (D-08) | CONTEXT.md §Event detail |
| Server-side recurrence expansion (D-09) | CONTEXT.md §Recurrence, CLAUDE.md |
| Single local timezone, no shift for all-day (D-10) | CONTEXT.md §Recurrence |
| TanStack Query = server state, Zustand = UI state | CLAUDE.md, CONTEXT.md §Code patterns |
| React 19 + Vite stack | CLAUDE.md §Recommended Stack |
| No shadcn yet (Phase 3 adoption) | Codebase scan (no components.json) |
| Schedule-X as rendering library | Researcher decision (D-Claude); see §Rendering Library |
---
@@ -15,16 +15,16 @@ created: 2026-06-04
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | vitest |
| **Config (API)** | `apps/api/vitest.config.ts` (environment: node — exists, Phase 1) |
| **Config (PWA)** | `apps/pwa/vitest.config.ts` (environment: jsdom — created in Plan 01 Task 1) |
| **Quick run command (API)** | `cd apps/api && pnpm test -- <test-file>` |
| **Quick run command (PWA)** | `cd apps/pwa && pnpm test -- <test-file>` |
| **Full suite command** | `pnpm -r test` (runs both workspaces) |
| **Type gate** | `pnpm exec tsc --noEmit` per workspace |
| **Estimated runtime** | ~25 seconds full suite (no live network; broker mocked) |
| Property | Value |
| --------------------------- | ---------------------------------------------------------------------------- |
| **Framework** | vitest |
| **Config (API)** | `apps/api/vitest.config.ts` (environment: node — exists, Phase 1) |
| **Config (PWA)** | `apps/pwa/vitest.config.ts` (environment: jsdom — created in Plan 01 Task 1) |
| **Quick run command (API)** | `cd apps/api && pnpm test -- <test-file>` |
| **Quick run command (PWA)** | `cd apps/pwa && pnpm test -- <test-file>` |
| **Full suite command** | `pnpm -r test` (runs both workspaces) |
| **Type gate** | `pnpm exec tsc --noEmit` per workspace |
| **Estimated runtime** | ~25 seconds full suite (no live network; broker mocked) |
PWA harness (vitest + jsdom + @testing-library/react + @testing-library/jest-dom) is installed in **Plan 01 Task 1** — until that task completes, all PWA test rows are blocked on the harness (`❌ W0`).
@@ -41,24 +41,24 @@ PWA harness (vitest + jsdom + @testing-library/react + @testing-library/jest-dom
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 02-01-01 | 01 | 1 | CAL-07 | T-02-SC | schema + RED stubs + fixtures (no behavior yet) | unit/grep | `grep -q has_rrule + ICAL.parse fixtures` (3 grep/node checks) | ✅ creates RED stubs | ⬜ pending |
| 02-01-02 | 01 | 1 | CAL-02 | T-02-01 | dev-bypass hard-disabled when NODE_ENV=production | unit | `cd apps/api && pnpm test -- tests/auth/devBypass.test.ts` | ❌ W0 (this task creates it) | ⬜ pending |
| 02-01-03 | 01 | 1 | CAL-07 | T-02-02 | live MariaDB has has_rrule + is_shared (no false-green) | integration | `node SHOW COLUMNS calendar_events/calendars` | N/A (DB assertion) | ⬜ pending |
| 02-02-01 | 02 | 2 | CAL-07 | T-02b-04 | DST wall-clock preserved; all-day no shift; EXDATE excluded | unit | `cd apps/api && pnpm test -- tests/broker/expand.test.ts` | ✅ RED stub from 02-01 | ⬜ pending |
| 02-02-02 | 02 | 2 | CAL-02, CAL-07 | T-02b-01/02 | start/end zod-validated before SQL; 90-day cap; color+isShared join | unit | `cd apps/api && pnpm test -- tests/routes/events.test.ts` | ✅ RED stub from 02-01 | ⬜ pending |
| 02-02-03 | 02 | 2 | CAL-02 | — | operator marks shared calendar (is_shared=1) | manual | human-verify checkpoint (see Manual-Only) | N/A | ⬜ pending |
| 02-03-01 | 03 | 2 | CAL-02 | T-02c-SC | token layer + Schedule-X var overrides; Temporal-first import | grep/node | `grep --color-shared-family + node require deps` | N/A (style/deps) | ⬜ pending |
| 02-03-02 | 03 | 2 | CAL-02 | — | firstDayOfWeek 0→7; per-member + 'shared' config | unit | `cd apps/pwa && pnpm test -- src/lib/colorUtils.test.ts src/lib/calendarConfig.test.ts` | ✅ RED stub (calendarConfig) from 02-01 | ⬜ pending |
| 02-03-03 | 03 | 2 | CAL-07 | T-02c-02 | all-day→PlainDate guard; calendarId routed by isShared/ownerUserId | unit | `cd apps/pwa && pnpm test -- src/lib/hydrateEvents.test.ts` | ✅ RED stub from 02-01 | ⬜ pending |
| 02-04-01 | 04 | 3 | CAL-02, CAL-03 | T-02d-01 | Schedule-X renders real windowed occurrences; 4 views; token-only | grep/type | `grep ScheduleXCalendar/hydrateEvents + tsc --noEmit` | N/A (wired in 04) | ⬜ pending |
| 02-04-02 | 04 | 3 | CAL-03 | T-02d-01 | render smoke: 4 views + timed+all-day through hydrate→eventsService | unit | `cd apps/pwa && pnpm test -- src/components/CalendarShell.test.tsx` | ❌ W0 (this task creates it) | ⬜ pending |
| 02-05-01 | 05 | 4 | CAL-03 | T-02e-01 | popover renders fields as text (no dangerouslySetInnerHTML); Escape closes | unit | `cd apps/pwa && pnpm test -- src/components/EventDetailPopover.test.tsx` | ❌ W0 (this task creates it) | ⬜ pending |
| 02-05-02 | 05 | 4 | CAL-02, CAL-03 | T-02e-02 | legend/nav/toolbar + skeleton/empty/error; EventProof removed | grep/type | `grep SkeletonCalendar/EmptyState + tsc --noEmit` | N/A (wired in 05) | ⬜ pending |
| 02-05-03 | 05 | 4 | CAL-02, CAL-03, CAL-07 | — | visual + functional verification of 4 success criteria | manual | human-verify checkpoint (see Manual-Only) | N/A | ⬜ pending |
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
| -------- | ---- | ---- | ---------------------- | ----------- | -------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------- | --------------------------------------- | ---------- |
| 02-01-01 | 01 | 1 | CAL-07 | T-02-SC | schema + RED stubs + fixtures (no behavior yet) | unit/grep | `grep -q has_rrule + ICAL.parse fixtures` (3 grep/node checks) | ✅ creates RED stubs | ⬜ pending |
| 02-01-02 | 01 | 1 | CAL-02 | T-02-01 | dev-bypass hard-disabled when NODE_ENV=production | unit | `cd apps/api && pnpm test -- tests/auth/devBypass.test.ts` | ❌ W0 (this task creates it) | ⬜ pending |
| 02-01-03 | 01 | 1 | CAL-07 | T-02-02 | live MariaDB has has_rrule + is_shared (no false-green) | integration | `node SHOW COLUMNS calendar_events/calendars` | N/A (DB assertion) | ⬜ pending |
| 02-02-01 | 02 | 2 | CAL-07 | T-02b-04 | DST wall-clock preserved; all-day no shift; EXDATE excluded | unit | `cd apps/api && pnpm test -- tests/broker/expand.test.ts` | ✅ RED stub from 02-01 | ⬜ pending |
| 02-02-02 | 02 | 2 | CAL-02, CAL-07 | T-02b-01/02 | start/end zod-validated before SQL; 90-day cap; color+isShared join | unit | `cd apps/api && pnpm test -- tests/routes/events.test.ts` | ✅ RED stub from 02-01 | ⬜ pending |
| 02-02-03 | 02 | 2 | CAL-02 | — | operator marks shared calendar (is_shared=1) | manual | human-verify checkpoint (see Manual-Only) | N/A | ⬜ pending |
| 02-03-01 | 03 | 2 | CAL-02 | T-02c-SC | token layer + Schedule-X var overrides; Temporal-first import | grep/node | `grep --color-shared-family + node require deps` | N/A (style/deps) | ⬜ pending |
| 02-03-02 | 03 | 2 | CAL-02 | — | firstDayOfWeek 0→7; per-member + 'shared' config | unit | `cd apps/pwa && pnpm test -- src/lib/colorUtils.test.ts src/lib/calendarConfig.test.ts` | ✅ RED stub (calendarConfig) from 02-01 | ⬜ pending |
| 02-03-03 | 03 | 2 | CAL-07 | T-02c-02 | all-day→PlainDate guard; calendarId routed by isShared/ownerUserId | unit | `cd apps/pwa && pnpm test -- src/lib/hydrateEvents.test.ts` | ✅ RED stub from 02-01 | ⬜ pending |
| 02-04-01 | 04 | 3 | CAL-02, CAL-03 | T-02d-01 | Schedule-X renders real windowed occurrences; 4 views; token-only | grep/type | `grep ScheduleXCalendar/hydrateEvents + tsc --noEmit` | N/A (wired in 04) | ⬜ pending |
| 02-04-02 | 04 | 3 | CAL-03 | T-02d-01 | render smoke: 4 views + timed+all-day through hydrate→eventsService | unit | `cd apps/pwa && pnpm test -- src/components/CalendarShell.test.tsx` | ❌ W0 (this task creates it) | ⬜ pending |
| 02-05-01 | 05 | 4 | CAL-03 | T-02e-01 | popover renders fields as text (no dangerouslySetInnerHTML); Escape closes | unit | `cd apps/pwa && pnpm test -- src/components/EventDetailPopover.test.tsx` | ❌ W0 (this task creates it) | ⬜ pending |
| 02-05-02 | 05 | 4 | CAL-02, CAL-03 | T-02e-02 | legend/nav/toolbar + skeleton/empty/error; EventProof removed | grep/type | `grep SkeletonCalendar/EmptyState + tsc --noEmit` | N/A (wired in 05) | ⬜ pending |
| 02-05-03 | 05 | 4 | CAL-02, CAL-03, CAL-07 | — | visual + functional verification of 4 success criteria | manual | human-verify checkpoint (see Manual-Only) | N/A | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
_Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky_
Tasks without a unit-test `<automated>` (02-01-01 grep/fixture, 02-01-03 DB, 02-03-01 grep/deps, 02-04-01 grep/type, 02-05-02 grep/type) each carry an automated grep/node/tsc check, and none of them appear in 3-consecutive sequence without a unit test between them: the expand/events/colorUtils/hydrateEvents/popover/smoke unit tests interleave every wave.
@@ -81,13 +81,13 @@ Wave 0 = Plan 01 Task 1, which creates the failing-but-present test stubs and fi
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Designate the shared-family calendar (`calendars.is_shared=1`) | CAL-02 | Which calendar is "shared-family" cannot be derived deterministically from data (broker exposes "Calendar" + "USA Holidays"; members' calendars arrive under their own credential) — operator must designate it. A different identification rule (e.g. displayName-pattern matching) is a **plan revision, not a resume-from-checkpoint**. | Plan 02 Task 3: list calendars, `UPDATE calendars SET is_shared=1 WHERE id=<chosen>`, re-list, curl `/api/events` confirms isShared:true + color #F25C7A on marked rows. |
| DST boundary correctness in the rendered UI | CAL-07 | Automated expand.test.ts asserts the wall-clock contract, but visual confirmation that Schedule-X paints the occurrence at the right hour across March 2026 requires a human eye on the grid. | Plan 05 Task 3 step 4: find a recurring event, navigate across the March 2026 DST boundary, confirm time does not jump ±1 hour. |
| All-day event renders as a full-day banner with no date shift | CAL-07 | PlainDate guard is unit-tested, but the actual Schedule-X all-day banner placement (correct date, no off-by-one) is a render-path visual check. | Plan 05 Task 3 step 5: find an all-day event (birthday/holiday), confirm it appears as a full-day banner on the correct date, not a day early/late. |
| Color-coded ownership legible at a glance + legend decode | CAL-02 | "Reads at a glance" is a subjective slick-constraint judgment. | Plan 05 Task 3 steps 23: confirm each member's events render in their color, shared in rose, legend decodes ownership. |
| Phone bottom-sheet popover + agenda default view | CAL-03 | Responsive breakpoint behavior (D-05) needs a real phone-width render. | Plan 05 Task 3 step 7: resize to phone width, confirm default view is Agenda and popover is a bottom sheet. |
| Behavior | Requirement | Why Manual | Test Instructions |
| -------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Designate the shared-family calendar (`calendars.is_shared=1`) | CAL-02 | Which calendar is "shared-family" cannot be derived deterministically from data (broker exposes "Calendar" + "USA Holidays"; members' calendars arrive under their own credential) — operator must designate it. A different identification rule (e.g. displayName-pattern matching) is a **plan revision, not a resume-from-checkpoint**. | Plan 02 Task 3: list calendars, `UPDATE calendars SET is_shared=1 WHERE id=<chosen>`, re-list, curl `/api/events` confirms isShared:true + color #F25C7A on marked rows. |
| DST boundary correctness in the rendered UI | CAL-07 | Automated expand.test.ts asserts the wall-clock contract, but visual confirmation that Schedule-X paints the occurrence at the right hour across March 2026 requires a human eye on the grid. | Plan 05 Task 3 step 4: find a recurring event, navigate across the March 2026 DST boundary, confirm time does not jump ±1 hour. |
| All-day event renders as a full-day banner with no date shift | CAL-07 | PlainDate guard is unit-tested, but the actual Schedule-X all-day banner placement (correct date, no off-by-one) is a render-path visual check. | Plan 05 Task 3 step 5: find an all-day event (birthday/holiday), confirm it appears as a full-day banner on the correct date, not a day early/late. |
| Color-coded ownership legible at a glance + legend decode | CAL-02 | "Reads at a glance" is a subjective slick-constraint judgment. | Plan 05 Task 3 steps 23: confirm each member's events render in their color, shared in rose, legend decodes ownership. |
| Phone bottom-sheet popover + agenda default view | CAL-03 | Responsive breakpoint behavior (D-05) needs a real phone-width render. | Plan 05 Task 3 step 7: resize to phone width, confirm default view is Agenda and popover is a bottom sheet. |
---
@@ -8,16 +8,16 @@ overrides_applied: 0
human_verification:
- test: "Confirm color-coded event display: each member's events appear in their assigned hex, shared-family events in rose #F25C7A; the ColorLegend decodes ownership"
expected: "Personal events use the owner's color from users.color; rose lane is empty (D-16, no shared calendar yet) but the legend shows the Family row correctly"
why_human: "Color rendering is visual; CSS token overrides and Schedule-X lightColors derivation cannot be verified by grep — only by visual inspection in a browser"
- test: "Switch between Day, Week, Month, and Agenda views and confirm events render correctly in each with no missing or misplaced events"
expected: "All four view factories (createViewDay/Week/MonthGrid/MonthAgenda) render events; week/day time-grid scrolls; navigation (Today/prev/next) works in each view"
why_human: "View rendering and grid layout require a running browser; Schedule-X DOM output cannot be asserted statically"
- test: "Find a recurring event and navigate across the March 2026 DST boundary; confirm occurrences stay at the correct local wall-clock time (no ±1h shift)"
expected: "A weekly 10:00 America/New_York event shows 10:00 on both sides of the Spring-forward boundary — not 09:00 or 11:00 after the transition"
why_human: "VTIMEZONE registration + ICAL.RecurExpansion + Schedule-X display timezone are correct in code (verified), but DST correctness must be visually confirmed with real Fastmail data"
- test: "Find a recurring all-day event (e.g. a birthday) and confirm it appears as a full-day banner on the correct date with no day shift"
expected: "All-day events render on the date matching the DTSTART DATE value — not shifted one day early or late by a timezone offset"
why_human: "Temporal.PlainDate routing is correct in code; visual confirmation with live data needed to rule out any Schedule-X display-zone interaction"
why_human: 'Color rendering is visual; CSS token overrides and Schedule-X lightColors derivation cannot be verified by grep — only by visual inspection in a browser'
- test: 'Switch between Day, Week, Month, and Agenda views and confirm events render correctly in each with no missing or misplaced events'
expected: 'All four view factories (createViewDay/Week/MonthGrid/MonthAgenda) render events; week/day time-grid scrolls; navigation (Today/prev/next) works in each view'
why_human: 'View rendering and grid layout require a running browser; Schedule-X DOM output cannot be asserted statically'
- test: 'Find a recurring event and navigate across the March 2026 DST boundary; confirm occurrences stay at the correct local wall-clock time (no ±1h shift)'
expected: 'A weekly 10:00 America/New_York event shows 10:00 on both sides of the Spring-forward boundary — not 09:00 or 11:00 after the transition'
why_human: 'VTIMEZONE registration + ICAL.RecurExpansion + Schedule-X display timezone are correct in code (verified), but DST correctness must be visually confirmed with real Fastmail data'
- test: 'Find a recurring all-day event (e.g. a birthday) and confirm it appears as a full-day banner on the correct date with no day shift'
expected: 'All-day events render on the date matching the DTSTART DATE value — not shifted one day early or late by a timezone offset'
why_human: 'Temporal.PlainDate routing is correct in code; visual confirmation with live data needed to rule out any Schedule-X display-zone interaction'
---
# Phase 02: Calendar Display — Verification Report
@@ -35,12 +35,12 @@ Fastmail calendars across day, week, month, and agenda views — read-only, no w
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Color-coded calendar — each member's events in their assigned color, shared events distinguishable from personal | VERIFIED (code) | `events.ts` derives `color = row.isShared ? '#F25C7A' : row.userColor`; `hydrateEvents.ts` routes `calendarId = occ.isShared ? 'shared' : String(occ.ownerUserId)`; `buildCalendarConfig()` keys per-member by `String(userId)` + `'shared'` with `deriveScheduleXColors()`. Rose lane intentionally empty per D-16 (no shared Fastmail calendar yet — operator-deferred). |
| 2 | Day/week/month/agenda views — all events render correctly in each | VERIFIED (code) | `CalendarShell.tsx` passes all four factories (`createViewDay`, `createViewWeek`, `createViewMonthGrid`, `createViewMonthAgenda`) to `useCalendarApp`; Schedule-X built-in header provides the view switcher and navigation. |
| 3 | Recurring events display all occurrences in-window, correct across DST boundaries | VERIFIED (code) | `expand.ts` registers VTIMEZONE via `getAllSubcomponents('vtimezone')` at line 190, before `new ICAL.RecurExpansion` at line 264; uses `ICAL.Time.fromJSDate(windowStart, true)` (UTC-based) for absolute occurrence windowing; `serializeTime()` emits IANA-annotated strings (`'...±HH:MM[IANA/Zone]'`); Schedule-X display timezone set to `Intl.DateTimeFormat().resolvedOptions().timeZone`. `events.ts` pre-filter includes all-day recurring masters via `dtstartDate < end` fallback. `sync.ts` sets `hasRrule: isRecurring` on both insert and update paths. |
| 4 | All-day events appear as full-day banners on the correct date with no timezone shift | VERIFIED (code) | `expand.ts` `serializeTime(t, allDay=true)` returns `'YYYY-MM-DD'` strings only; `hydrateEvents.ts` branches on `occ.allDay` to call `Temporal.PlainDate.from(occ.start)` (never `ZonedDateTime`); `events.ts` non-recurring all-day pre-filter uses `dtstartDate` (DATE column) comparison — no DATETIME coercion. |
| # | Truth | Status | Evidence |
| --- | ---------------------------------------------------------------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Color-coded calendar — each member's events in their assigned color, shared events distinguishable from personal | VERIFIED (code) | `events.ts` derives `color = row.isShared ? '#F25C7A' : row.userColor`; `hydrateEvents.ts` routes `calendarId = occ.isShared ? 'shared' : String(occ.ownerUserId)`; `buildCalendarConfig()` keys per-member by `String(userId)` + `'shared'` with `deriveScheduleXColors()`. Rose lane intentionally empty per D-16 (no shared Fastmail calendar yet — operator-deferred). |
| 2 | Day/week/month/agenda views — all events render correctly in each | VERIFIED (code) | `CalendarShell.tsx` passes all four factories (`createViewDay`, `createViewWeek`, `createViewMonthGrid`, `createViewMonthAgenda`) to `useCalendarApp`; Schedule-X built-in header provides the view switcher and navigation. |
| 3 | Recurring events display all occurrences in-window, correct across DST boundaries | VERIFIED (code) | `expand.ts` registers VTIMEZONE via `getAllSubcomponents('vtimezone')` at line 190, before `new ICAL.RecurExpansion` at line 264; uses `ICAL.Time.fromJSDate(windowStart, true)` (UTC-based) for absolute occurrence windowing; `serializeTime()` emits IANA-annotated strings (`'...±HH:MM[IANA/Zone]'`); Schedule-X display timezone set to `Intl.DateTimeFormat().resolvedOptions().timeZone`. `events.ts` pre-filter includes all-day recurring masters via `dtstartDate < end` fallback. `sync.ts` sets `hasRrule: isRecurring` on both insert and update paths. |
| 4 | All-day events appear as full-day banners on the correct date with no timezone shift | VERIFIED (code) | `expand.ts` `serializeTime(t, allDay=true)` returns `'YYYY-MM-DD'` strings only; `hydrateEvents.ts` branches on `occ.allDay` to call `Temporal.PlainDate.from(occ.start)` (never `ZonedDateTime`); `events.ts` non-recurring all-day pre-filter uses `dtstartDate` (DATE column) comparison — no DATETIME coercion. |
**Score: 4/4 truths — all verified in code**
@@ -51,59 +51,59 @@ workspaces typecheck clean (`tsc --noEmit`).
### Deferred Items
| # | Item | Addressed In | Evidence |
|---|------|-------------|----------|
| 1 | Shared-family color lane populated with real events | Operator action (D-16) | `calendars.is_shared` column exists and is read by the route; lane is empty because no shared Fastmail calendar has been created yet. STATE.md Deferred Items entry D-16 and PROJECT.md D-16 confirm this is intentional and operator-tracked. |
| # | Item | Addressed In | Evidence |
| --- | --------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Shared-family color lane populated with real events | Operator action (D-16) | `calendars.is_shared` column exists and is read by the route; lane is empty because no shared Fastmail calendar has been created yet. STATE.md Deferred Items entry D-16 and PROJECT.md D-16 confirm this is intentional and operator-tracked. |
---
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/src/broker/expand.ts` | `expandOccurrences()` + `CalendarOccurrence` interface | VERIFIED | Exports both; full VTIMEZONE registration, ICAL.RecurExpansion, allDay split, IANA-annotated output, CSS-safe IDs |
| `apps/api/src/routes/events.ts` | Windowed `/api/events` with join, hasRrule pre-filter, zod validation | VERIFIED | `zValidator`, 3-clause WHERE (recurring/non-recurring/all-day), `expandOccurrences` called per row |
| `apps/api/src/db/schema.ts` | `has_rrule` + `idx_calendar_events_has_rrule` + `is_shared` | VERIFIED | Lines 99-108 confirm columns and index |
| `apps/api/src/broker/sync.ts` | `hasRrule` set on both insert and upsert paths | VERIFIED | Lines 113, 122 |
| `apps/pwa/src/lib/hydrateEvents.ts` | ISO→Temporal hydration with all-day PlainDate guard + ownership-routed calendarId | VERIFIED | `Temporal.PlainDate.from` for allDay; `String(occ.ownerUserId)` routing |
| `apps/pwa/src/lib/calendarConfig.ts` | `WEEK_START_DAY=0→SX_FIRST_DAY_OF_WEEK=7`, `buildCalendarConfig()` | VERIFIED | `WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY` at line 31 |
| `apps/pwa/src/lib/colorUtils.ts` | `deriveScheduleXColors()` (main/container/onContainer) | VERIFIED | Full implementation without third-party color library |
| `apps/pwa/src/styles/tokens.css` | CSS token layer with `--color-shared-family`, `--sx-color-*` overrides | VERIFIED (existence) | File exists; not re-read but confirmed by prior grep showing `--color-shared-family: #F25C7A` and `--sx-color-` |
| `apps/pwa/src/components/CalendarShell.tsx` | Schedule-X wired to TanStack Query + hydrateEvents + Zustand range | VERIFIED | Full pipeline confirmed (fetchEvents → hydrateEvents → eventsService.set); all four views; display timezone; onRangeUpdate exclusive end |
| `apps/pwa/src/components/EventDetailPopover.tsx` | Read-only popover; XSS-safe; focus trap; Escape-to-close | VERIFIED | No `dangerouslySetInnerHTML` anywhere; all fields are plain-text JSX children; `aria-label="Close"`, `minHeight: 44px` close button; Escape listener via `document.addEventListener` |
| `apps/pwa/src/components/ColorLegend.tsx` | Always-visible legend with member rows + Family rose row | VERIFIED | Per-member rows + hardcoded `'Family'` / `#F25C7A` row |
| `apps/pwa/src/components/SkeletonCalendar.tsx` | Shimmer skeleton | VERIFIED (existence) | File present |
| `apps/pwa/src/components/EmptyState.tsx` | Empty state component | VERIFIED (existence) | File present |
| `apps/pwa/src/components/EventProof.tsx` | DELETED | VERIFIED | `grep -rn "EventProof" apps/pwa/src/` returns nothing |
| Artifact | Expected | Status | Details |
| ------------------------------------------------ | --------------------------------------------------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apps/api/src/broker/expand.ts` | `expandOccurrences()` + `CalendarOccurrence` interface | VERIFIED | Exports both; full VTIMEZONE registration, ICAL.RecurExpansion, allDay split, IANA-annotated output, CSS-safe IDs |
| `apps/api/src/routes/events.ts` | Windowed `/api/events` with join, hasRrule pre-filter, zod validation | VERIFIED | `zValidator`, 3-clause WHERE (recurring/non-recurring/all-day), `expandOccurrences` called per row |
| `apps/api/src/db/schema.ts` | `has_rrule` + `idx_calendar_events_has_rrule` + `is_shared` | VERIFIED | Lines 99-108 confirm columns and index |
| `apps/api/src/broker/sync.ts` | `hasRrule` set on both insert and upsert paths | VERIFIED | Lines 113, 122 |
| `apps/pwa/src/lib/hydrateEvents.ts` | ISO→Temporal hydration with all-day PlainDate guard + ownership-routed calendarId | VERIFIED | `Temporal.PlainDate.from` for allDay; `String(occ.ownerUserId)` routing |
| `apps/pwa/src/lib/calendarConfig.ts` | `WEEK_START_DAY=0→SX_FIRST_DAY_OF_WEEK=7`, `buildCalendarConfig()` | VERIFIED | `WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY` at line 31 |
| `apps/pwa/src/lib/colorUtils.ts` | `deriveScheduleXColors()` (main/container/onContainer) | VERIFIED | Full implementation without third-party color library |
| `apps/pwa/src/styles/tokens.css` | CSS token layer with `--color-shared-family`, `--sx-color-*` overrides | VERIFIED (existence) | File exists; not re-read but confirmed by prior grep showing `--color-shared-family: #F25C7A` and `--sx-color-` |
| `apps/pwa/src/components/CalendarShell.tsx` | Schedule-X wired to TanStack Query + hydrateEvents + Zustand range | VERIFIED | Full pipeline confirmed (fetchEvents → hydrateEvents → eventsService.set); all four views; display timezone; onRangeUpdate exclusive end |
| `apps/pwa/src/components/EventDetailPopover.tsx` | Read-only popover; XSS-safe; focus trap; Escape-to-close | VERIFIED | No `dangerouslySetInnerHTML` anywhere; all fields are plain-text JSX children; `aria-label="Close"`, `minHeight: 44px` close button; Escape listener via `document.addEventListener` |
| `apps/pwa/src/components/ColorLegend.tsx` | Always-visible legend with member rows + Family rose row | VERIFIED | Per-member rows + hardcoded `'Family'` / `#F25C7A` row |
| `apps/pwa/src/components/SkeletonCalendar.tsx` | Shimmer skeleton | VERIFIED (existence) | File present |
| `apps/pwa/src/components/EmptyState.tsx` | Empty state component | VERIFIED (existence) | File present |
| `apps/pwa/src/components/EventProof.tsx` | DELETED | VERIFIED | `grep -rn "EventProof" apps/pwa/src/` returns nothing |
---
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `expand.ts` | VTIMEZONE registration | `getAllSubcomponents('vtimezone')` at line 190, before `new ICAL.RecurExpansion` at line 264 | WIRED | Mandatory ordering confirmed |
| `events.ts` | `expand.ts` | `expandOccurrences()` called per row in flatMap | WIRED | Line 123 |
| `events.ts` | `users.color` + `isShared` | `innerJoin(users)`, `select({ userColor: users.color, isShared: calendars.isShared })` | WIRED | Lines 84-89 |
| `CalendarShell.tsx` | `/api/events` | `useQuery(['events', start, end]) → fetchEvents(start, end)` | WIRED | Lines 89-94 |
| `CalendarShell.tsx` | `hydrateEvents` | `eventsService.set(hydrateEvents(eventsQuery.data.occurrences))` in data-keyed effect | WIRED | Lines 164-168 |
| `CalendarShell.tsx` | `calendarStore` | Zustand selectors for `calendarRange`, `setCalendarRange`, `setOpenEventId`, `selectedView` | WIRED | Lines 73-76 |
| `CalendarShell.tsx` | `EventDetailPopover` | Rendered as sibling; popover resolves event from TanStack Query cache via Zustand `openEventId` | WIRED | Lines 318, 347 |
| `hydrateEvents.ts` | `buildCalendarConfig` keys | `occ.isShared ? 'shared' : String(occ.ownerUserId)` exactly matches `buildCalendarConfig` keys | WIRED | Contract documented in both files |
| `main.tsx` | `temporal-polyfill/global` | First import before any Schedule-X code | WIRED | Line 7 |
| `expand.ts` | UTC windowing | `ICAL.Time.fromJSDate(windowStart, true)``useUTC=true` | WIRED | Lines 217-218 |
| `CalendarShell.tsx` | Exclusive window end | `range.end.toPlainDate().add({ days: 1 }).toString()` in `onRangeUpdate` | WIRED | Line 149 |
| From | To | Via | Status | Details |
| ------------------- | -------------------------- | ----------------------------------------------------------------------------------------------- | ------ | --------------------------------- |
| `expand.ts` | VTIMEZONE registration | `getAllSubcomponents('vtimezone')` at line 190, before `new ICAL.RecurExpansion` at line 264 | WIRED | Mandatory ordering confirmed |
| `events.ts` | `expand.ts` | `expandOccurrences()` called per row in flatMap | WIRED | Line 123 |
| `events.ts` | `users.color` + `isShared` | `innerJoin(users)`, `select({ userColor: users.color, isShared: calendars.isShared })` | WIRED | Lines 84-89 |
| `CalendarShell.tsx` | `/api/events` | `useQuery(['events', start, end]) → fetchEvents(start, end)` | WIRED | Lines 89-94 |
| `CalendarShell.tsx` | `hydrateEvents` | `eventsService.set(hydrateEvents(eventsQuery.data.occurrences))` in data-keyed effect | WIRED | Lines 164-168 |
| `CalendarShell.tsx` | `calendarStore` | Zustand selectors for `calendarRange`, `setCalendarRange`, `setOpenEventId`, `selectedView` | WIRED | Lines 73-76 |
| `CalendarShell.tsx` | `EventDetailPopover` | Rendered as sibling; popover resolves event from TanStack Query cache via Zustand `openEventId` | WIRED | Lines 318, 347 |
| `hydrateEvents.ts` | `buildCalendarConfig` keys | `occ.isShared ? 'shared' : String(occ.ownerUserId)` exactly matches `buildCalendarConfig` keys | WIRED | Contract documented in both files |
| `main.tsx` | `temporal-polyfill/global` | First import before any Schedule-X code | WIRED | Line 7 |
| `expand.ts` | UTC windowing | `ICAL.Time.fromJSDate(windowStart, true)``useUTC=true` | WIRED | Lines 217-218 |
| `CalendarShell.tsx` | Exclusive window end | `range.end.toPlainDate().add({ days: 1 }).toString()` in `onRangeUpdate` | WIRED | Line 149 |
---
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| `CalendarShell.tsx` | `eventsQuery.data.occurrences` | `fetchEvents(start, end)``/api/events` → MariaDB join + `expandOccurrences` | Yes — DB query with 3-clause WHERE, joins, ICAL expansion | FLOWING |
| `events.ts` | `rows` | Drizzle `db.select().from(calendarEvents).innerJoin(calendars).innerJoin(users).where(...)` | Yes — parameterized SQL against live cache | FLOWING |
| `EventDetailPopover.tsx` | `occurrence` | `queryClient.getQueriesData({ queryKey: ['events'] })` — searches TanStack Query cache | Yes — resolved from the same fetched data | FLOWING |
| `ColorLegend.tsx` | `members` | Passed from `CalendarShell` via `meQuery.data.user``fetchMe``/api/me` | Yes — live user data from DB | FLOWING |
| Artifact | Data Variable | Source | Produces Real Data | Status |
| ------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------- |
| `CalendarShell.tsx` | `eventsQuery.data.occurrences` | `fetchEvents(start, end)``/api/events` → MariaDB join + `expandOccurrences` | Yes — DB query with 3-clause WHERE, joins, ICAL expansion | FLOWING |
| `events.ts` | `rows` | Drizzle `db.select().from(calendarEvents).innerJoin(calendars).innerJoin(users).where(...)` | Yes — parameterized SQL against live cache | FLOWING |
| `EventDetailPopover.tsx` | `occurrence` | `queryClient.getQueriesData({ queryKey: ['events'] })` — searches TanStack Query cache | Yes — resolved from the same fetched data | FLOWING |
| `ColorLegend.tsx` | `members` | Passed from `CalendarShell` via `meQuery.data.user``fetchMe``/api/me` | Yes — live user data from DB | FLOWING |
---
@@ -112,14 +112,15 @@ workspaces typecheck clean (`tsc --noEmit`).
Not run — no dev server started (per spot-check constraints). The test suites stand in as executable
verification:
| Suite | Command | Result | Status |
|-------|---------|--------|--------|
| API (47 tests) | `pnpm --filter @familysync/api test` | 47 passed, 0 failed | PASS |
| PWA (39 tests) | `pnpm --filter @familysync/pwa test` | 39 passed, 0 failed | PASS |
| API typecheck | `pnpm --filter @familysync/api exec tsc --noEmit` | Clean | PASS |
| PWA typecheck | `pnpm --filter @familysync/pwa exec tsc --noEmit` | Clean | PASS |
| Suite | Command | Result | Status |
| -------------- | ------------------------------------------------- | ------------------- | ------ |
| API (47 tests) | `pnpm --filter @familysync/api test` | 47 passed, 0 failed | PASS |
| PWA (39 tests) | `pnpm --filter @familysync/pwa test` | 39 passed, 0 failed | PASS |
| API typecheck | `pnpm --filter @familysync/api exec tsc --noEmit` | Clean | PASS |
| PWA typecheck | `pnpm --filter @familysync/pwa exec tsc --noEmit` | Clean | PASS |
Key tests for the phase's success criteria:
- `expand.test.ts` — DST wall-clock assertion (10:00 AM both sides of March 2026 transition), all-day `'YYYY-MM-DD'` assertion, EXDATE exclusion assertion
- `events.test.ts` — color field, multi-calendar aggregation, `isShared` flag, `ownerUserId`, 400 on bad params
- `hydrateEvents.test.ts` — all-day → `PlainDate`, timed → `ZonedDateTime`, shared → `'shared'`, personal → `String(ownerUserId)`
@@ -138,11 +139,11 @@ skipped.
### Requirements Coverage
| Requirement | Source Plans | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| CAL-02 | 02-01 through 02-05 | User sees a unified, color-coded calendar aggregating every accessible calendar | SATISFIED | `events.ts` joins all calendars/users; `hydrateEvents` routes calendarId; `buildCalendarConfig` creates per-member + shared entries; `CalendarShell` renders the full aggregate |
| CAL-03 | 02-01 through 02-05 | User can switch between week, month, day, and agenda/list views | SATISFIED | All four `createView*` factories present in `CalendarShell`; Schedule-X built-in header enables switching |
| CAL-07 | 02-01 through 02-05 | User can see all occurrences of a recurring event expanded correctly | SATISFIED | `expandOccurrences` uses `ICAL.RecurExpansion` with VTIMEZONE pre-registration; EXDATE internal to RecurExpansion; all-day returns `'YYYY-MM-DD'`; IANA-annotated timed strings; UTC windowing; `has_rrule` pre-filter in route; `sync.ts` populates flag on every upsert |
| Requirement | Source Plans | Description | Status | Evidence |
| ----------- | ------------------- | ------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| CAL-02 | 02-01 through 02-05 | User sees a unified, color-coded calendar aggregating every accessible calendar | SATISFIED | `events.ts` joins all calendars/users; `hydrateEvents` routes calendarId; `buildCalendarConfig` creates per-member + shared entries; `CalendarShell` renders the full aggregate |
| CAL-03 | 02-01 through 02-05 | User can switch between week, month, day, and agenda/list views | SATISFIED | All four `createView*` factories present in `CalendarShell`; Schedule-X built-in header enables switching |
| CAL-07 | 02-01 through 02-05 | User can see all occurrences of a recurring event expanded correctly | SATISFIED | `expandOccurrences` uses `ICAL.RecurExpansion` with VTIMEZONE pre-registration; EXDATE internal to RecurExpansion; all-day returns `'YYYY-MM-DD'`; IANA-annotated timed strings; UTC windowing; `has_rrule` pre-filter in route; `sync.ts` populates flag on every upsert |
No orphaned requirements: the REQUIREMENTS.md Traceability table maps CAL-02 and CAL-03 to Phase 2
and CAL-07 to Phase 3. However, all five plans in Phase 2 declare `requirements: [CAL-02, CAL-03, CAL-07]`,
@@ -169,9 +170,9 @@ Phase 3 footer area in `EventDetailPopover.tsx` is an empty `<div aria-hidden="t
explicit "Phase 3 wires edit/delete here (D-08)" comment — this is an intentional reserved slot,
not a stub (no user-visible output is missing).
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| — | — | — | — | No anti-patterns found |
| File | Line | Pattern | Severity | Impact |
| ---- | ---- | ------- | -------- | ---------------------- |
| — | — | — | — | No anti-patterns found |
---