Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d5c61aa5 | ||
|
|
8bd52c6923 | ||
|
|
fc4cc2ccf8 | ||
|
|
b842955612 | ||
|
|
5e14413024 | ||
|
|
a707f8d9ca | ||
|
|
e9fcd7a372 | ||
|
|
5b98fc097d | ||
|
|
f1604b5057 | ||
|
|
79a18b9ea2 |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"timestamp": "2026-06-04T19:28:41.530Z",
|
||||
"phase": "02",
|
||||
"phase_name": "calendar-display",
|
||||
"phase_dir": ".planning/phases/02-calendar-display",
|
||||
"plan": 1,
|
||||
"task": 0,
|
||||
"total_tasks": 5,
|
||||
"status": "paused",
|
||||
"stage": "planned-not-executed",
|
||||
"completed_tasks": [
|
||||
{"id": "research", "name": "Phase 2 research (RESEARCH.md) + Nyquist VALIDATION.md", "status": "done", "commit": "a707f8d"},
|
||||
{"id": "patterns", "name": "Pattern map (PATTERNS.md) — 23 files, 19 analogs", "status": "done", "commit": "5e14413"},
|
||||
{"id": "plans", "name": "5 PLAN.md files in 4 waves; plan-checker PASSED on iteration 2", "status": "done", "commit": "fc4cc2c"},
|
||||
{"id": "backlog", "name": "Backlog item 999.1 — treat Fastmail as a calendar provider, support adding more", "status": "done", "commit": "8bd52c6"},
|
||||
{"id": "config", "name": "GSD config: adaptive profile, TDD on, per-milestone branching, auto-advance on; saved as global defaults", "status": "done", "commit": "uncommitted"}
|
||||
],
|
||||
"remaining_tasks": [
|
||||
{"id": "execute", "name": "Execute Phase 2 — run all 5 plans across 4 waves", "status": "not_started"}
|
||||
],
|
||||
"blockers": [],
|
||||
"human_actions_pending": [
|
||||
{"action": "Wave 2 checkpoint: designate the shared-family calendar via UPDATE calendars SET is_shared=1 WHERE id=<chosen>", "context": "Plan 02-02 Task 3 is autonomous:false — which calendar is 'shared-family' cannot be derived from data; the broker account exposes multiple calendars", "blocking": true},
|
||||
{"action": "Wave 4 checkpoint: visual + functional verification of the 4 success criteria (color coding, 4 views, DST recurrence, all-day no-shift)", "context": "Plan 02-05 Task 3 is autonomous:false — human-verify of the rendered calendar", "blocking": true}
|
||||
],
|
||||
"decisions": [
|
||||
{"decision": "Server-side recurrence expansion via ICAL.RecurExpansion with VTIMEZONE registered before expansion", "rationale": "rrule alone needs manual EXDATE handling; VTIMEZONE registration is mandatory or DST events show wrong wall-clock time", "phase": "02"},
|
||||
{"decision": "Schedule-X calendarId routed by occ.isShared ? 'shared' : String(occ.ownerUserId), never String(occ.calendarId)", "rationale": "calendars config is keyed by userId; using DB calendar-row id breaks color routing for members owning multiple calendars", "phase": "02"},
|
||||
{"decision": "Resolve shared-calendar identity via a calendars.isShared column + operator checkpoint", "rationale": "Cannot derive 'shared' deterministically from Fastmail data; chose explicit marking over fragile displayName matching", "phase": "02"},
|
||||
{"decision": "Adopted Adaptive model profile, TDD mode on, per-milestone git branching, auto-advance on; saved as global defaults", "rationale": "User preference set via /gsd-config this session", "phase": "global"}
|
||||
],
|
||||
"uncommitted_files": [".planning/config.json"],
|
||||
"next_action": "Run /gsd-execute-phase 2 to execute the 5 verified plans. NOTE: auto-advance is now ON — execution may chain stages; interrupt if you want to inspect output between waves.",
|
||||
"context_notes": "Phase 2 (calendar-display) is fully planned and verified (plan-checker PASSED iteration 2). MVP vertical-slice mode: Wave 1 foundation (schema has_rrule/is_shared + BLOCKING drizzle-kit push + dev-auth bypass + PWA vitest harness + ICS fixtures + RED stubs), Wave 2 backend slice ∥ frontend foundation, Wave 3 first real vertical slice (CalendarShell renders real events, 4 views), Wave 4 UX polish + human verify. Phase 1 already shipped (apps/api + apps/pwa monorepo). TDD was turned on AFTER Phase 2 was planned, so Phase 2 plans do NOT carry TDD gates — only Phase 3+ will. Critical correctness constraints (DST/VTIMEZONE, all-day no-shift, calendarId routing, firstDayOfWeek 0->7, has_rrule pre-filter) are baked into the plans."
|
||||
}
|
||||
+29
-1
@@ -58,7 +58,24 @@ Plans:
|
||||
3. A recurring event (e.g., weekly meeting) displays all its occurrences correctly in the current view window, including correct behavior across DST boundaries
|
||||
4. All-day events (birthdays, holidays) appear as full-day banners on the correct date with no timezone shift
|
||||
|
||||
**Plans**: TBD
|
||||
**Plans**: 5 plansPlans:
|
||||
**Wave 1**
|
||||
|
||||
- [ ] 02-01-PLAN.md — Foundation: schema (hasRrule + calendars.isShared, pushed) + dev-auth bypass + PWA vitest/jsdom harness + ICS fixtures + RED test stubs
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
|
||||
- [ ] 02-02-PLAN.md — Backend slice: expandOccurrences() (VTIMEZONE/DST + all-day + EXDATE) + windowed/joined/zod-validated /api/events + shared-calendar checkpoint (CAL-02/CAL-07)
|
||||
- [ ] 02-03-PLAN.md — Frontend foundation: CSS token layer + colorUtils + calendarConfig (firstDayOfWeek 0→7) + hydrateEvents (Temporal/PlainDate guard) + Zustand store + windowed fetchEvents
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
|
||||
- [ ] 02-04-PLAN.md — Vertical slice: CalendarShell mounts Schedule-X, renders real windowed Fastmail events color-coded across all four views (CAL-02/CAL-03)
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
|
||||
- [ ] 02-05-PLAN.md — UX completion: read-only EventDetailPopover + ColorLegend + nav/toolbar + skeleton/empty/error states + human visual verification
|
||||
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 3: Event Write-Back + PWA Install
|
||||
@@ -123,3 +140,14 @@ Note: Phase 4 depends only on Phase 1 and can begin as soon as Phase 1 is comple
|
||||
| 3. Event Write-Back + PWA Install | 0/? | Not started | - |
|
||||
| 4. Shared Lists + Live Sync | 0/? | Not started | - |
|
||||
| 5. Web Push Notifications | 0/? | Not started | - |
|
||||
|
||||
## Backlog
|
||||
|
||||
### Phase 999.1: Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
|
||||
|
||||
**Goal:** [Captured for future planning] Abstract the calendar backend behind a provider interface so Fastmail/CalDAV is one implementation among potentially many. Shipping with a single provider is fine, but the broker, sync, and event-expansion layers should be structured so additional providers (e.g. other CalDAV hosts, Google Calendar, generic ICS feeds) can be added without rework. Captures the "provider" seam as an explicit architectural concern.
|
||||
**Requirements:** TBD
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (promote with /gsd-review-backlog when ready)
|
||||
|
||||
+6
-6
@@ -3,8 +3,8 @@ gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: executing
|
||||
stopped_at: Phase 2 context gathered
|
||||
last_updated: "2026-06-04T18:14:07.859Z"
|
||||
stopped_at: Phase 2 UI-SPEC approved
|
||||
last_updated: "2026-06-04T19:09:54.699Z"
|
||||
last_activity: 2026-06-04 -- Phase 01 execution started
|
||||
progress:
|
||||
total_phases: 5
|
||||
@@ -27,7 +27,7 @@ See: .planning/PROJECT.md (updated 2026-06-03)
|
||||
|
||||
Phase: 01 (foundation-broker-spike) — EXECUTING
|
||||
Plan: 1 of 4
|
||||
Status: Executing Phase 01
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-06-04 -- Phase 01 execution started
|
||||
|
||||
Progress: [░░░░░░░░░░] 0%
|
||||
@@ -92,6 +92,6 @@ None yet.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-06-04T18:14:07.854Z
|
||||
Stopped at: Phase 2 context gathered
|
||||
Resume file: .planning/phases/02-calendar-display/02-CONTEXT.md
|
||||
Last session: 2026-06-04T18:24:20.759Z
|
||||
Stopped at: Phase 2 UI-SPEC approved
|
||||
Resume file: .planning/phases/02-calendar-display/02-UI-SPEC.md
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"model_profile": "balanced",
|
||||
"model_profile": "adaptive",
|
||||
"commit_docs": true,
|
||||
"parallelization": true,
|
||||
"search_gitignored": false,
|
||||
@@ -7,7 +7,7 @@
|
||||
"firecrawl": false,
|
||||
"exa_search": false,
|
||||
"git": {
|
||||
"branching_strategy": "none",
|
||||
"branching_strategy": "milestone",
|
||||
"create_tag": true,
|
||||
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
||||
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
||||
@@ -18,13 +18,13 @@
|
||||
"plan_check": true,
|
||||
"verifier": true,
|
||||
"nyquist_validation": true,
|
||||
"auto_advance": false,
|
||||
"auto_advance": true,
|
||||
"node_repair": true,
|
||||
"node_repair_budget": 2,
|
||||
"ui_phase": true,
|
||||
"ui_safety_gate": true,
|
||||
"ai_integration_phase": true,
|
||||
"tdd_mode": false,
|
||||
"tdd_mode": true,
|
||||
"human_verify_mode": "end-of-phase",
|
||||
"text_mode": false,
|
||||
"research_before_questions": false,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
context: phase
|
||||
phase: 02-calendar-display
|
||||
task: 0
|
||||
total_tasks: 5
|
||||
status: planned-not-executed
|
||||
last_updated: 2026-06-04T19:28:41.530Z
|
||||
---
|
||||
|
||||
<current_state>
|
||||
Phase 2 (calendar-display) is **fully planned and verified, not yet executed**. The
|
||||
plan-checker PASSED on iteration 2 (the initial check found 2 blockers + 3 warnings;
|
||||
all were fixed). 5 PLAN.md files exist across 4 waves. Phase 1 already shipped a working
|
||||
pnpm monorepo (apps/api Hono+Drizzle+CalDAV broker, apps/pwa React+Vite).
|
||||
|
||||
The immediate next step is execution: `/gsd-execute-phase 2`.
|
||||
|
||||
One uncommitted file: `.planning/config.json` (this session's settings changes).
|
||||
</current_state>
|
||||
|
||||
<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)
|
||||
- Requirements coverage 3/3 (CAL-02, CAL-03, CAL-07); decision coverage 10/10
|
||||
- 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>
|
||||
|
||||
<remaining_work>
|
||||
|
||||
- Execute Phase 2 — run all 5 plans across 4 waves:
|
||||
- Wave 1: 02-01 — schema (has_rrule/is_shared) + **[BLOCKING] drizzle-kit push** + dev-auth
|
||||
bypass + PWA vitest/jsdom harness + ICS fixtures + RED stubs
|
||||
- Wave 2: 02-02 (backend: expandOccurrences + windowed /api/events) ∥ 02-03 (frontend
|
||||
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>
|
||||
|
||||
<decisions_made>
|
||||
|
||||
- Server-side recurrence expansion via `ICAL.RecurExpansion`, with VTIMEZONE registered
|
||||
BEFORE expansion (or DST events render at wrong wall-clock time).
|
||||
- Schedule-X `calendarId = occ.isShared ? 'shared' : String(occ.ownerUserId)` — NOT
|
||||
`String(occ.calendarId)`. The calendars config is keyed by userId; using the DB
|
||||
calendar-row id silently breaks color routing for members owning multiple calendars.
|
||||
- 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>
|
||||
|
||||
<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)
|
||||
3. `.planning/phases/02-calendar-display/02-VALIDATION.md` — per-task verification map (Nyquist)
|
||||
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.
|
||||
- Do NOT expand recurrences before registering VTIMEZONE; do NOT shift all-day events through
|
||||
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.
|
||||
Only Phase 3+ will get TDD. (Re-plan Phase 2 if you want TDD gates here.)
|
||||
|
||||
<context>
|
||||
Everything went smoothly — no failures discovered, no rework beyond the one planned
|
||||
revision loop. The plans are execution-ready. The only thing a fresh agent must internalize
|
||||
is the auto-advance + per-milestone branching change made this session, and that TDD won't
|
||||
retroactively apply to Phase 2's already-written plans.
|
||||
</context>
|
||||
|
||||
<next_action>
|
||||
Start with: `/gsd-execute-phase 2`. Two human checkpoints will pause execution — marking the
|
||||
shared-family calendar (Wave 2, plan 02-02) and final visual verification of the 4 success
|
||||
criteria (Wave 4, plan 02-05). Consider committing `.planning/config.json` first.
|
||||
</next_action>
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
phase: 02-calendar-display
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- apps/api/src/db/schema.ts
|
||||
- apps/api/src/auth/devBypass.ts
|
||||
- apps/api/src/index.ts
|
||||
- apps/api/tests/fixtures/weekly-dst.ics
|
||||
- apps/api/tests/fixtures/allday-birthday.ics
|
||||
- apps/api/tests/fixtures/exdate-series.ics
|
||||
- apps/api/tests/broker/expand.test.ts
|
||||
- apps/api/tests/routes/events.test.ts
|
||||
- apps/api/tests/auth/devBypass.test.ts
|
||||
- apps/pwa/vitest.config.ts
|
||||
- apps/pwa/package.json
|
||||
- apps/pwa/src/lib/hydrateEvents.test.ts
|
||||
- apps/pwa/src/lib/calendarConfig.test.ts
|
||||
- .env.example
|
||||
- docs/deployment.md
|
||||
autonomous: true
|
||||
requirements: [CAL-02, CAL-03, CAL-07]
|
||||
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"
|
||||
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"
|
||||
min_lines: 10
|
||||
key_links:
|
||||
- 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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Lay the verifiable foundation for the Phase 2 calendar slice: add the two schema columns the
|
||||
display pipeline depends on (`calendar_events.hasRrule`, `calendars.isShared`), push them to the
|
||||
live MariaDB, stand up the PWA test runner, write the dev-auth bypass so the UI can be built
|
||||
without live Authelia (D-14), and create the failing test stubs + ICS fixtures that all later
|
||||
waves turn green.
|
||||
|
||||
Purpose: Every later plan (expansion engine, windowed route, hydration, calendar render) needs
|
||||
these columns, the test harness, and the dev-auth bypass to exist first. This is the only
|
||||
horizontal-foundation plan in the phase — kept minimal so the next plan delivers a real slice.
|
||||
Output: Migrated schema (pushed), PWA vitest harness, dev-auth bypass middleware, ICS fixtures,
|
||||
RED test stubs with concrete behavioral contracts.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-calendar-display/02-RESEARCH.md
|
||||
@.planning/phases/02-calendar-display/02-PATTERNS.md
|
||||
@.planning/phases/02-calendar-display/02-CONTEXT.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add schema columns + PWA test harness + ICS fixtures + RED test stubs</name>
|
||||
<files>apps/api/src/db/schema.ts, apps/pwa/vitest.config.ts, apps/pwa/package.json, apps/api/tests/fixtures/weekly-dst.ics, apps/api/tests/fixtures/allday-birthday.ics, apps/api/tests/fixtures/exdate-series.ics, apps/api/tests/broker/expand.test.ts, apps/api/tests/routes/events.test.ts, apps/pwa/src/lib/hydrateEvents.test.ts, apps/pwa/src/lib/calendarConfig.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/db/schema.ts (current calendarEvents + calendars table definitions; column + index style to copy)
|
||||
- apps/api/vitest.config.ts (analog for PWA config — change environment node→jsdom)
|
||||
- apps/pwa/package.json (current scripts + devDependencies block)
|
||||
- apps/api/tests/broker/poller.test.ts (test file structure: describe/it/expect, vi.mock hoisting)
|
||||
- apps/api/tests/health.test.ts (Hono app.request() route-test pattern)
|
||||
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Validation Architecture" (fixture corpus + Wave 0 gaps table) + §"Pattern 1" (CalendarOccurrence carries calendarId, ownerUserId, isShared) + §"Pattern 2" (hydrateEvents calendarId routing)
|
||||
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/vitest.config.ts" and §"apps/api/tests/broker/expand.test.ts"
|
||||
</read_first>
|
||||
<behavior>
|
||||
- expand.test.ts (CONCRETE — not a bare failing import): load weekly-dst.ics, call expandOccurrences for a window spanning the March 2026 America/New_York EST→EDT transition (e.g. 2026-03-01..2026-03-31), and assert that every returned occurrence's local wall-clock time is 10:00 America/New_York on BOTH sides of the DST boundary (i.e. the pre-transition and post-transition occurrences share the same 10:00 local hour — NOT shifted ±1h by a UTC fallback). The stub must encode this exact assertion so Plan 02's GREEN path has a real contract; do NOT settle for asserting only that ICAL.parse() succeeds.
|
||||
- expand.test.ts: all-day birthday fixture returns allDay:true with start as 'YYYY-MM-DD' (e.g. '2026-06-15') and no time component
|
||||
- expand.test.ts: exdate-series fixture omits the single EXDATE-excluded occurrence (returned array length is one fewer than an un-excluded expansion)
|
||||
- events.test.ts: GET /api/events?start=&end= returns occurrences each carrying a color field and isShared flag; missing/malformed start or end → 400 (RED — route not evolved yet)
|
||||
- hydrateEvents.test.ts: all-day occurrence (allDay:true, start '2026-06-15') → start is Temporal.PlainDate; timed occurrence → Temporal.ZonedDateTime
|
||||
- hydrateEvents.test.ts (CONCRETE routing contract): a shared occurrence (isShared:true) → Schedule-X calendarId === 'shared'; a personal occurrence (isShared:false, ownerUserId:7, calendarId:99) → Schedule-X calendarId === '7' (String(ownerUserId)), explicitly NOT '99' (String(calendarId)). This assertion locks the Plan 03 routing fix.
|
||||
- calendarConfig.test.ts: WEEK_START_DAY=0 translates to Schedule-X firstDayOfWeek 7
|
||||
</behavior>
|
||||
<action>
|
||||
Add to `calendarEvents` in schema.ts: `hasRrule: boolean('has_rrule').default(false).notNull()`, and a new index `index('idx_calendar_events_has_rrule').on(t.hasRrule)` in the table's index array (copy the exact style of `idx_calendar_events_dtstart_utc`). Add to `calendars`: `isShared: boolean('is_shared').default(false).notNull()`. `boolean` and `index` are already imported.
|
||||
|
||||
Create `apps/pwa/vitest.config.ts` mirroring `apps/api/vitest.config.ts` but with `environment: 'jsdom'` and `globals: true`. In `apps/pwa/package.json` add `"test": "vitest run"` to scripts and add devDependencies `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` (use versions compatible with the workspace's existing vitest major; match the version in apps/api). Install via `pnpm install` at repo root.
|
||||
|
||||
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>
|
||||
<automated>cd /home/luc/Projects/familysync && grep -q jsdom apps/pwa/vitest.config.ts && grep -q '"test": "vitest run"' apps/pwa/package.json && echo PWA_HARNESS_OK</automated>
|
||||
<automated>cd apps/api && node -e "const I=require('ical.js');for(const f of ['weekly-dst','allday-birthday','exdate-series']){I.parse(require('fs').readFileSync('tests/fixtures/'+f+'.ics','utf8'))};console.log('FIXTURES_PARSE_OK')"</automated>
|
||||
<automated>cd /home/luc/Projects/familysync && grep -q "10:00" apps/api/tests/broker/expand.test.ts && grep -q "shared" apps/pwa/src/lib/hydrateEvents.test.ts && grep -q "ownerUserId" apps/pwa/src/lib/hydrateEvents.test.ts && echo STUB_CONTRACTS_PRESENT</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- apps/api/src/db/schema.ts contains `hasRrule: boolean('has_rrule')` and `idx_calendar_events_has_rrule`
|
||||
- apps/api/src/db/schema.ts contains `isShared: boolean('is_shared')` on the calendars table
|
||||
- apps/pwa/vitest.config.ts contains `environment: 'jsdom'`
|
||||
- apps/pwa/package.json scripts contains `"test": "vitest run"` and devDependencies include vitest, @testing-library/react, jsdom
|
||||
- All three fixture .ics files parse via ICAL.parse without throwing
|
||||
- weekly-dst.ics contains a VTIMEZONE block with both STANDARD and DAYLIGHT subcomponents
|
||||
- expand.test.ts asserts 10:00 local wall-clock on both sides of the March 2026 DST boundary (concrete contract, not just ICAL.parse success)
|
||||
- hydrateEvents.test.ts asserts shared→'shared' and personal→String(ownerUserId) (NOT String(calendarId)) for the Schedule-X calendarId
|
||||
- expand.test.ts, events.test.ts, hydrateEvents.test.ts, calendarConfig.test.ts exist and reference their target modules (RED is expected — modules not yet built)
|
||||
</acceptance_criteria>
|
||||
<done>Schema columns + index added; PWA vitest/jsdom harness installed and runnable; three ICS fixtures parse; four RED test stubs exist with concrete behavioral assertions (DST wall-clock + calendarId routing) referencing not-yet-built modules.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Dev-auth bypass middleware + production guard + env docs</name>
|
||||
<files>apps/api/src/auth/devBypass.ts, apps/api/src/index.ts, apps/api/tests/auth/devBypass.test.ts, .env.example, docs/deployment.md</files>
|
||||
<read_first>
|
||||
- apps/api/src/auth/middleware.ts (re-export pattern; getAuth/oidcAuthMiddleware surface)
|
||||
- apps/api/src/index.ts (current middleware mount order: callback → /health → oidcAuthMiddleware on /api/* → routes)
|
||||
- apps/api/src/routes/me.ts (how getAuth(c) is consumed downstream — the injected user must satisfy it)
|
||||
- apps/api/src/auth/user.ts (DEV_USER shape: id, displayName, color; COLOR_PALETTE[0])
|
||||
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 5: Dev-Auth Bypass Middleware" and §"Pitfall 7"
|
||||
- docs/deployment.md (existing dev-auth-bypass / Gate 2 context to extend)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- devBypass.test.ts: with NODE_ENV='production' the middleware is a pure passthrough and never sets a user, even if DEV_AUTH_BYPASS='true'
|
||||
- devBypass.test.ts: with NODE_ENV='test' and DEV_AUTH_BYPASS unset, middleware is passthrough (no user injected)
|
||||
- devBypass.test.ts: with NODE_ENV!='production' and DEV_AUTH_BYPASS='true', a fixed dev user is injected into the Hono context
|
||||
</behavior>
|
||||
<action>
|
||||
Create `apps/api/src/auth/devBypass.ts` exporting `devAuthBypass(): MiddlewareHandler`. FIRST check `process.env.NODE_ENV === 'production'` and return a no-op passthrough (`async (_c, next) => next()`) before reading any other env var — this hard guard is mandatory (Pitfall 7). Then if `process.env.DEV_AUTH_BYPASS !== 'true'`, also return passthrough. Otherwise return a handler that calls `c.set('user', DEV_USER)` then `await next()`. DEV_USER = a fixed object `{ id, oidcIss: 'dev', oidcSub: 'dev-user', displayName: 'Dev User', color }` where color is `COLOR_PALETTE[0]` ('#4A90D9'). Match the context key (`'user'`) and shape that `getAuth(c)` consumers in me.ts expect — read me.ts to confirm whether downstream reads `getAuth(c)` or `c.get('user')`; if me.ts uses `getAuth(c)` from @hono/oidc-auth, set both the auth claim and `c.set('user', DEV_USER)` so the events route (which will read the resolved user) works. Document the exact mechanism in a top-of-file comment.
|
||||
|
||||
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>
|
||||
<automated>cd apps/api && grep -q "NODE_ENV === 'production'" src/auth/devBypass.ts && grep -q "devAuthBypass()" src/index.ts && echo BYPASS_WIRED</automated>
|
||||
<automated>cd /home/luc/Projects/familysync && grep -q "DEV_AUTH_BYPASS" .env.example && echo ENV_DOCUMENTED</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- apps/api/src/auth/devBypass.ts exports devAuthBypass and the FIRST conditional checks NODE_ENV === 'production'
|
||||
- apps/api/src/index.ts calls devAuthBypass() on /api/* immediately before oidcAuthMiddleware()
|
||||
- tests/auth/devBypass.test.ts passes: production guard, unset-flag passthrough, and active-injection cases all green
|
||||
- .env.example documents DEV_AUTH_BYPASS with the production warning comment
|
||||
- docs/deployment.md notes the production hard guard and prod-compose prohibition
|
||||
</acceptance_criteria>
|
||||
<done>devAuthBypass middleware exists with production hard guard, is mounted before oidcAuthMiddleware, all three behavior tests pass, env + deployment docs updated.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" gate="blocking">
|
||||
<name>Task 3: [BLOCKING] Push Drizzle schema to live MariaDB</name>
|
||||
<files>apps/api (drizzle-kit push — no source file)</files>
|
||||
<read_first>
|
||||
- apps/api/src/db/schema.ts (the columns added in Task 1 that must reach the live DB)
|
||||
- apps/api/drizzle.config.ts (push target / credentials config)
|
||||
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Environment Availability" (MariaDB Docker confirmed up)
|
||||
</read_first>
|
||||
<action>
|
||||
Run `npx drizzle-kit push` in `apps/api` to apply the `has_rrule`, `idx_calendar_events_has_rrule`, and `calendars.is_shared` schema changes to the live MariaDB. This is MANDATORY and BLOCKING: type checks and builds pass without it (types come from the Drizzle config, not the live DB), so skipping it produces a false-positive verification state where the windowed query in Plan 02 fails at runtime with "unknown column has_rrule". The MariaDB container must be running (Phase 1 confirmed it up with 503 cached events). If drizzle-kit push emits an interactive confirmation prompt that cannot be auto-confirmed, stop and surface it — do not guess answers to destructive prompts.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd apps/api && node -e "const m=require('mysql2/promise');(async()=>{const c=await m.createConnection(process.env.DATABASE_URL);const [r]=await c.query('SHOW COLUMNS FROM calendar_events LIKE \'has_rrule\'');const [s]=await c.query('SHOW COLUMNS FROM calendars LIKE \'is_shared\'');if(r.length&&s.length){console.log('PUSH_OK')}else{process.exit(1)};await c.end()})()"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `SHOW COLUMNS FROM calendar_events LIKE 'has_rrule'` returns one row against the live MariaDB
|
||||
- `SHOW COLUMNS FROM calendars LIKE 'is_shared'` returns one row against the live MariaDB
|
||||
- drizzle-kit push completed without destructive data loss on the existing 503-event cache
|
||||
</acceptance_criteria>
|
||||
<done>has_rrule (+ index) and calendars.is_shared exist in the live MariaDB schema; existing event cache intact.</done>
|
||||
</task>
|
||||
|
||||
</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 |
|
||||
|
||||
## 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_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/api test` runs (devBypass test green; expand/events stubs RED as designed)
|
||||
- `pnpm --filter @familysync/pwa test` runner executes under jsdom
|
||||
- Live MariaDB has has_rrule + is_shared columns
|
||||
- `tsc --noEmit` clean in apps/api after schema edits
|
||||
</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>
|
||||
|
||||
<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
|
||||
- `DEV_USER` (const, internal to devBypass.ts)
|
||||
- apps/pwa/vitest.config.ts (new)
|
||||
- apps/pwa `test` npm script + vitest/@testing-library/jsdom devDependencies
|
||||
- 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>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-calendar-display/02-01-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
phase: 02-calendar-display
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["02-01"]
|
||||
files_modified:
|
||||
- apps/api/src/broker/expand.ts
|
||||
- apps/api/src/routes/events.ts
|
||||
- apps/api/tests/broker/expand.test.ts
|
||||
- apps/api/tests/routes/events.test.ts
|
||||
autonomous: false
|
||||
requirements: [CAL-02, CAL-03, CAL-07]
|
||||
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"
|
||||
- "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"
|
||||
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"
|
||||
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"
|
||||
pattern: "users\\.color"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Evolve `/api/events` from the Phase 1 raw-row dump into the display-ready windowed endpoint:
|
||||
filter cached events to the requested date window, join owner color + shared-family flag, expand
|
||||
recurring masters server-side via `ICAL.RecurExpansion` (with VTIMEZONE registered for DST
|
||||
correctness), and serialize concrete occurrences as JSON. This is the backend half of the
|
||||
calendar slice — it makes real, color-tagged, DST-correct, all-day-safe occurrences available to
|
||||
the UI.
|
||||
|
||||
Purpose: CAL-02 (color aggregation), CAL-07 (recurrence + DST + all-day + EXDATE display) live
|
||||
here. The frontend slice (Plan 04) consumes this exact JSON shape.
|
||||
Output: `expandOccurrences()` helper, windowed/joined/validated `/api/events`, green expand + route tests.
|
||||
|
||||
Note (autonomous: false): includes a blocking checkpoint to resolve which calendar is the
|
||||
shared-family calendar (open question A3) — the operator marks it.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-calendar-display/02-RESEARCH.md
|
||||
@.planning/phases/02-calendar-display/02-PATTERNS.md
|
||||
@.planning/phases/01-foundation-broker-spike/CAL-08-DECISION.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: expandOccurrences() — server-side expansion with VTIMEZONE + allDay split</name>
|
||||
<files>apps/api/src/broker/expand.ts, apps/api/tests/broker/expand.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/broker/sync.ts (ICAL.parse → Component → vevent pipeline lines 78–115; allDay = dtstart.isDate; D-13 dtstartDate/dtstartUtc split)
|
||||
- apps/api/tests/broker/expand.test.ts (the RED stub from Plan 01 — its DST wall-clock + all-day + EXDATE assertions are the contract)
|
||||
- apps/api/tests/fixtures/weekly-dst.ics, allday-birthday.ics, exdate-series.ics (Plan 01 fixtures)
|
||||
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 1" + §"Code Examples: VTIMEZONE Registration + ICAL.RecurExpansion" + §"Pitfall 2/3"
|
||||
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/api/src/broker/expand.ts" + §"D-13 allDay Discrimination"
|
||||
</read_first>
|
||||
<behavior>
|
||||
- weekly-dst.ics: occurrences across the March 2026 EST→EDT transition stay at 10:00 America/New_York local wall-clock (not shifted ±1h by a UTC fallback)
|
||||
- allday-birthday.ics: returns allDay:true, start='2026-06-15' (DATE form, no time), and the yearly occurrence falls within a window containing June 15
|
||||
- exdate-series.ics: the single EXDATE-excluded occurrence is absent from the returned array
|
||||
- non-recurring event inside the window returns exactly one occurrence; non-recurring event outside the window returns none
|
||||
- each occurrence id is `${uid}::${startIso}` (stable identity)
|
||||
- each occurrence carries ownerUserId and isShared (passed through from meta) so the client can route color
|
||||
</behavior>
|
||||
<action>
|
||||
Create `apps/api/src/broker/expand.ts` exporting interface `CalendarOccurrence` (fields: id, uid, calendarId:number, calendarName:string, ownerUserId:number, color:string, isShared:boolean, title:string, start:string, end:string, allDay:boolean, location:string|null, description:string|null) and `expandOccurrences(rawVevent, windowStart: Date, windowEnd: Date, meta: { calendarId, calendarName, ownerUserId, color, isShared }): CalendarOccurrence[]`. Both `ownerUserId` and `isShared` are required occurrence fields (the client routes Schedule-X color by `isShared ? 'shared' : String(ownerUserId)`, NOT by calendarId) — stamp them on every emitted occurrence from `meta`.
|
||||
|
||||
Implementation contract (per RESEARCH Pattern 1 and Code Examples):
|
||||
1. `ICAL.parse(rawVevent)` wrapped in try/catch — on parse failure return `[]` (do not throw; match sync.ts skip-on-malformed behavior).
|
||||
2. BEFORE constructing RecurExpansion, iterate `comp.getAllSubcomponents('vtimezone')`; for each, read `tzid`, and if `!ICAL.TimezoneService.has(tzid)` call `ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtz, tzid }))`. Skipping this makes DST occurrences ±1h wrong (Pitfall 3) — this registration is mandatory.
|
||||
3. Get the first `vevent`; if none, return `[]`. Build `ICAL.Event(vevent)`; read `dtstart`. `allDay = dtstart.isDate`.
|
||||
4. Non-recurring (`!event.isRecurring()`): emit a single occurrence if dtstart is within [windowStart, windowEnd).
|
||||
5. Recurring: use `new ICAL.RecurExpansion({ component: vevent, dtstart })`. Iterate `expand.next()` while `next.compare(rangeEnd) < 0`; skip while `next.compare(rangeStart) < 0`. RecurExpansion handles RRULE+RDATE+EXDATE internally — do NOT manually filter EXDATE (A1, RESEARCH). Compute occurrence end from `event.duration`.
|
||||
6. allDay serialization: for allDay occurrences, `start`/`end` are 'YYYY-MM-DD' strings (slice from the ICAL.Time DATE form) — NEVER a midnight-UTC datetime (Pitfall 2). For timed occurrences, emit a timezone-offset-aware ISO string the client can pass to `Temporal.ZonedDateTime.from()`.
|
||||
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>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- apps/api/src/broker/expand.ts exports `expandOccurrences` and `CalendarOccurrence`
|
||||
- CalendarOccurrence carries ownerUserId:number and isShared:boolean; every emitted occurrence has them populated from meta
|
||||
- expand.test.ts passes including the DST wall-clock assertion, all-day 'YYYY-MM-DD' assertion, and EXDATE-exclusion assertion
|
||||
- The VTIMEZONE registration loop runs before any RecurExpansion construction (grep: getAllSubcomponents('vtimezone') appears before new ICAL.RecurExpansion)
|
||||
- No `import ... 'rrule'` on the primary expansion path (rrule only in a guarded fallback branch, if any)
|
||||
- Malformed rawVevent input returns [] without throwing
|
||||
</acceptance_criteria>
|
||||
<done>expandOccurrences() produces DST-correct, all-day-safe, EXDATE-aware concrete occurrences carrying ownerUserId + isShared; expand.test.ts green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Windowed /api/events with color/owner join, hasRrule pre-filter, zod validation</name>
|
||||
<files>apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/routes/events.ts (current raw-dump implementation to replace; the broker-boundary invariant comment must be preserved)
|
||||
- apps/api/src/routes/me.ts (Hono router structure, getAuth/c.json patterns)
|
||||
- apps/api/src/db/schema.ts (calendarEvents, calendars, users columns incl. new hasRrule + calendars.isShared; foreign keys for the join)
|
||||
- apps/api/src/broker/expand.ts (CalendarOccurrence shape + expandOccurrences signature from Task 1)
|
||||
- apps/api/tests/routes/events.test.ts (RED stub from Plan 01 — its assertions are the contract)
|
||||
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Backend: /api/events Evolution" + §"Pitfall 5" + §"Open Questions 3" (hasRrule pre-filter SQL)
|
||||
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/api/src/routes/events.ts" (zod validator + Drizzle join + health.ts error handling)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- GET /api/events?start=2026-06-01&end=2026-07-01 returns { occurrences: CalendarOccurrence[] }, each with a color field
|
||||
- occurrences aggregate events from multiple calendars/users (CAL-02 aggregation)
|
||||
- shared-family calendar (calendars.isShared=true) occurrences carry isShared:true and color '#F25C7A'; member calendars carry the owner users.color and ownerUserId=users.id
|
||||
- missing or malformed start/end (not /^\d{4}-\d{2}-\d{2}$/) → 400 before any SQL
|
||||
- a window wider than 90 days → 400 (DoS guard)
|
||||
- a recurring master whose dtstartUtc predates the window still contributes in-window occurrences (hasRrule pre-filter)
|
||||
</behavior>
|
||||
<action>
|
||||
Rewrite `eventsRouter.get('/')` in events.ts. Keep the top-of-file broker-boundary invariant comment (no tsdav, cache-only). Add zod query validation via `@hono/zod-validator`: `z.object({ start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) })` and reject (the validator returns 400 automatically). After parsing, compute the window span and return 400 if `end - start > 90 days` (V5 input validation / DoS cap).
|
||||
|
||||
Query: `db.select(...).from(calendarEvents).innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)).innerJoin(users, eq(calendars.userId, users.id))` selecting the columns expandOccurrences needs plus `calendars.isShared`, `calendars.displayName`, `users.color`, `users.id`. WHERE pre-filter (RESEARCH Open Q3): `(NOT hasRrule AND dtstartUtc BETWEEN start AND end) OR (hasRrule AND dtstartUtc < windowEnd) OR (dtstartDate BETWEEN start AND end)` — recurring masters predating the window must not be dropped (Pitfall 5 / Open Q3).
|
||||
|
||||
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>
|
||||
<automated>cd apps/api && grep -q "zValidator" src/routes/events.ts && grep -q "innerJoin" src/routes/events.ts && grep -q "expandOccurrences" src/routes/events.ts && echo ROUTE_WIRED</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- events.test.ts passes: color-field assertion, multi-calendar aggregation, 400-on-bad-params, isShared flag, ownerUserId present
|
||||
- events.ts uses zValidator('query', ...) with the YYYY-MM-DD regex and a ≤90-day window cap
|
||||
- events.ts inner-joins calendarEvents→calendars→users and selects users.color + users.id + calendars.isShared
|
||||
- events.ts calls expandOccurrences per row with ownerUserId: row.userId and returns { occurrences }
|
||||
- No tsdav / createFastmailClient import in events.ts (broker-boundary invariant preserved)
|
||||
</acceptance_criteria>
|
||||
<done>/api/events is windowed, validated (zod + 90-day cap), joined for color/isShared/ownerUserId, and expands recurrences; events.test.ts green.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: [CHECKPOINT] Resolve & mark the shared-family calendar (A3)</name>
|
||||
<action>Operator-only manual step: inspect the calendars table, decide which row(s) are the shared-family calendar, and set is_shared=1 on them. No code is written in this task — the route logic already reads is_shared. Follow the steps in how-to-verify exactly. NOTE: if the operator wants a DIFFERENT shared-calendar identification rule than the manual `calendars.isShared` UPDATE encoded here (e.g. automatic displayName-pattern matching, or keying off the broker user id), that is a PLAN REVISION — re-run `/gsd-plan-phase 2` with the new rule — NOT a resume-from-checkpoint. The "approved" resume path only covers the manual is_shared marking already built.</action>
|
||||
<what-built>
|
||||
Plan 01 added `calendars.isShared` (default false). The route colors any calendar with
|
||||
isShared=true rose (#F25C7A) and tags its occurrences isShared:true; all other calendars use
|
||||
the owner's member color. Research open question A3 (which calendar is "shared-family") cannot
|
||||
be resolved deterministically from data — the broker account exposes "Calendar" and "USA
|
||||
Holidays", and each member's personal calendars arrive under their own app password. The
|
||||
operator must designate the shared-family calendar(s).
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
1. List current calendars: `cd apps/api && node -e "const m=require('mysql2/promise');(async()=>{const c=await m.createConnection(process.env.DATABASE_URL);const [r]=await c.query('SELECT id, display_name, user_id, is_shared FROM calendars');console.table(r);await c.end()})()"`
|
||||
2. Decide which calendar row(s) are the shared-family calendar (the household-shared one — e.g. "Calendar" on the broker account per CAL-08-DECISION; NOT "USA Holidays", NOT a member's personal calendar).
|
||||
3. Mark it: `UPDATE calendars SET is_shared = 1 WHERE id = <chosen id>;` (run via the same mysql2 connection or a DB client).
|
||||
4. Re-run step 1 and confirm exactly the intended row(s) show is_shared=1.
|
||||
5. Hit the endpoint in dev (DEV_AUTH_BYPASS=true): `curl 'http://localhost:3000/api/events?start=2026-06-01&end=2026-07-01'` and confirm occurrences from the marked calendar carry "isShared":true and "color":"#F25C7A".
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" with the chosen calendar id(s) to confirm the manual is_shared marking. If you instead want a different identification rule encoded in code, that is a plan revision (re-run /gsd-plan-phase 2), not a resume — say so and describe the rule.</resume-signal>
|
||||
</task>
|
||||
|
||||
</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 |
|
||||
|
||||
## 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_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/api test` green (expand + events tests)
|
||||
- `tsc --noEmit` clean in apps/api
|
||||
- Manual dev curl returns windowed occurrences with color + isShared + ownerUserId (checkpoint)
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-calendar-display/02-02-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
phase: 02-calendar-display
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["02-01"]
|
||||
files_modified:
|
||||
- apps/pwa/src/styles/tokens.css
|
||||
- apps/pwa/src/styles/tokens.ts
|
||||
- apps/pwa/src/styles/index.css
|
||||
- apps/pwa/src/lib/colorUtils.ts
|
||||
- apps/pwa/src/lib/colorUtils.test.ts
|
||||
- apps/pwa/src/lib/calendarConfig.ts
|
||||
- apps/pwa/src/lib/calendarConfig.test.ts
|
||||
- apps/pwa/src/lib/hydrateEvents.ts
|
||||
- apps/pwa/src/lib/hydrateEvents.test.ts
|
||||
- apps/pwa/src/store/calendarStore.ts
|
||||
- apps/pwa/src/api/client.ts
|
||||
- apps/pwa/src/main.tsx
|
||||
- apps/pwa/package.json
|
||||
autonomous: true
|
||||
requirements: [CAL-02, CAL-03, CAL-07]
|
||||
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"
|
||||
- "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 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"
|
||||
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"]
|
||||
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"
|
||||
via: "calendarId = isShared ? 'shared' : String(ownerUserId) matches buildCalendarConfig keys"
|
||||
pattern: "ownerUserId"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Build the frontend foundation the calendar render slice depends on: the CSS custom-property token
|
||||
layer (D-01/D-02 — the load-bearing deliverable), the color-derivation utility, the Schedule-X
|
||||
calendar config with the firstDayOfWeek translation, the ISO→Temporal hydration util with the
|
||||
all-day PlainDate guard, the Zustand UI-state store, the windowed fetchEvents client, and the
|
||||
Temporal-polyfill + theme-CSS imports in main.tsx.
|
||||
|
||||
Purpose: These are pure PWA library/style/store files with zero overlap with the backend plan, so
|
||||
this runs in parallel with Plan 02. Plan 04 mounts Schedule-X and wires all of this into a
|
||||
rendering calendar.
|
||||
Output: tokens.css/ts/index.css, colorUtils, calendarConfig, hydrateEvents, calendarStore,
|
||||
windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-calendar-display/02-UI-SPEC.md
|
||||
@.planning/phases/02-calendar-display/02-RESEARCH.md
|
||||
@.planning/phases/02-calendar-display/02-PATTERNS.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install Schedule-X deps + token layer (tokens.css/ts/index.css) + main.tsx imports</name>
|
||||
<files>apps/pwa/package.json, apps/pwa/src/styles/tokens.css, apps/pwa/src/styles/tokens.ts, apps/pwa/src/styles/index.css, apps/pwa/src/main.tsx</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/main.tsx (current import order + QueryClientProvider setup)
|
||||
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"Token Layer", §"Color Tokens", §"Spacing Scale", §"Typography", §"Breakpoints", §"Schedule-X CSS Override Strategy"
|
||||
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Installation (PWA only)" (exact package versions) + §"Schedule-X CSS Token Override Pattern"
|
||||
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/main.tsx" (Temporal-polyfill-first import order)
|
||||
</read_first>
|
||||
<action>
|
||||
Install the Schedule-X stack in apps/pwa at the pinned versions from RESEARCH: `@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` (via pnpm add in apps/pwa). These are the Approved packages from RESEARCH §Package Legitimacy.
|
||||
|
||||
Create `apps/pwa/src/styles/tokens.css` declaring on `:root` every token from UI-SPEC §Color Tokens (--color-surface, --color-surface-dim, --color-surface-raised, --color-border, --color-border-subtle, --color-text-primary/secondary/muted, --color-focus-ring, --color-overlay, --color-member-0..5 with the exact UI-SPEC hexes, --color-shared-family:#F25C7A, --color-destructive:#DC2626), §Spacing Scale (--space-1..12), §Typography (--font-family-base, --text-body/label/heading/display sizes+weights+line-heights), and §Breakpoints (--bp-phone/tablet/desktop). Then add the Schedule-X override block mapping --sx-color-* vars to these tokens per UI-SPEC §Schedule-X CSS Override Strategy (--sx-color-primary→--color-member-0, surface/on-surface/outline/neutral, --sx-font-family→--font-family-base). Include the `@keyframes shimmer` from PATTERNS for the skeleton.
|
||||
|
||||
Create `apps/pwa/src/styles/tokens.ts` exporting a typed object mirroring the same token values (so components can use them in inline-style props). Keep names aligned with the CSS var names.
|
||||
|
||||
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>
|
||||
<automated>cd apps/pwa && grep -q -- "--color-shared-family: #F25C7A" src/styles/tokens.css && grep -q -- "--sx-color-" src/styles/tokens.css && echo TOKENS_OK</automated>
|
||||
<automated>cd apps/pwa && node -e "require('@schedule-x/calendar');require('temporal-polyfill');require('lucide-react');console.log('DEPS_OK')"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- apps/pwa/package.json dependencies include all six @schedule-x/* + temporal-polyfill + lucide-react at the RESEARCH-pinned versions
|
||||
- tokens.css declares --color-shared-family:#F25C7A, all --color-member-0..5, the spacing/typography/breakpoint tokens, and a --sx-color-* override block
|
||||
- main.tsx imports temporal-polyfill/global FIRST, then theme-default CSS, then styles/index.css
|
||||
- tokens.ts exports a token object mirroring the CSS var values
|
||||
</acceptance_criteria>
|
||||
<done>Schedule-X stack installed; clean-theme token layer + Schedule-X var overrides present; main.tsx imports Temporal polyfill + theme + tokens in correct order.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: colorUtils + calendarConfig (firstDayOfWeek translation) — turn RED stubs green</name>
|
||||
<files>apps/pwa/src/lib/colorUtils.ts, apps/pwa/src/lib/colorUtils.test.ts, apps/pwa/src/lib/calendarConfig.ts, apps/pwa/src/lib/calendarConfig.test.ts</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/lib/calendarConfig.test.ts (RED stub from Plan 01 — its 0→7 assertion is the contract)
|
||||
- apps/pwa/src/App.tsx (ColorSwatch inline-style pattern, lines 18–34, the colorUtils analog)
|
||||
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"Color derivation rule for event chips" (container=15% over white; onContainer=darken 40%) + §"Calendar config constant"
|
||||
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 3: Schedule-X Calendar Configuration" + §"Pitfall 1" (firstDayOfWeek 0→7) + §"Pitfall 6" (limit to confirmed Schedule-X API surface)
|
||||
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/lib/colorUtils.ts" + §"apps/pwa/src/lib/calendarConfig.ts"
|
||||
</read_first>
|
||||
<behavior>
|
||||
- colorUtils: deriveScheduleXColors('#4A90D9') returns { main:'#4A90D9', container: <15% over white>, onContainer: <darkened 40%> }
|
||||
- calendarConfig: WEEK_START_DAY === 0 translates to Schedule-X firstDayOfWeek 7
|
||||
- calendarConfig: buildCalendarConfig([{id:'1',name:'Lucas',color:'#4A90D9'}]) yields calendars['1'] with lightColors and a 'shared' entry colored from #F25C7A
|
||||
</behavior>
|
||||
<action>
|
||||
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>
|
||||
<automated>cd apps/pwa && grep -q "WEEK_START_DAY === 0 ? 7" src/lib/calendarConfig.ts && echo WEEKSTART_TRANSLATED</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- colorUtils.ts exports deriveScheduleXColors, hexToContainer, hexToOnContainer; colorUtils.test.ts green
|
||||
- calendarConfig.ts exports WEEK_START_DAY (=0), the 0→7 firstDayOfWeek translation, and buildCalendarConfig
|
||||
- buildCalendarConfig output keys per-member by String(userId) and includes a 'shared' entry from #F25C7A
|
||||
- calendarConfig.test.ts (Plan 01 RED stub) passes the 0→7 assertion
|
||||
</acceptance_criteria>
|
||||
<done>colorUtils + calendarConfig built; firstDayOfWeek 0→7 translation encoded; both test files green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: hydrateEvents (Temporal, all-day guard, ownership-routed calendarId) + Zustand store + windowed fetchEvents</name>
|
||||
<files>apps/pwa/src/lib/hydrateEvents.ts, apps/pwa/src/lib/hydrateEvents.test.ts, apps/pwa/src/store/calendarStore.ts, apps/pwa/src/api/client.ts</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/lib/hydrateEvents.test.ts (RED stub from Plan 01 — PlainDate-vs-ZonedDateTime AND the calendarId='shared'/String(ownerUserId) routing contract)
|
||||
- apps/pwa/src/api/client.ts (existing fetchMe pattern + the OLD unwindowed fetchEvents/EventsResponse to replace)
|
||||
- apps/pwa/src/lib/calendarConfig.ts (Task 2 — buildCalendarConfig keys: String(userId) + 'shared'; hydrateEvents must produce calendarId values that match these keys)
|
||||
- apps/api/src/broker/expand.ts CalendarOccurrence shape (if Plan 02 merged first: fields incl. calendarId, ownerUserId, isShared) OR .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 1" interface (the server JSON contract)
|
||||
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 2" (hydrateEvents PlainDate/ZonedDateTime) + §"Pitfall 2/4"
|
||||
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/store/calendarStore.ts" (Zustand state shape) + §"apps/pwa/src/api/client.ts"
|
||||
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"State Management Contract" + §"View default logic (D-05)"
|
||||
</read_first>
|
||||
<behavior>
|
||||
- hydrateEvents: occurrence with allDay:true and start '2026-06-15' → start is Temporal.PlainDate (NOT ZonedDateTime — guards Pitfall 2)
|
||||
- hydrateEvents: timed occurrence → start/end Temporal.ZonedDateTime from the offset-aware ISO string
|
||||
- hydrateEvents: a shared occurrence (isShared:true) → Schedule-X calendarId === 'shared'
|
||||
- hydrateEvents: a personal occurrence (isShared:false, ownerUserId:7) → Schedule-X calendarId === '7' (String(ownerUserId)), NOT String(occ.calendarId)
|
||||
- hydrateEvents: passes uid/color/isShared through on a _familySync field
|
||||
- calendarStore: setSelectedView persists to localStorage keyed by breakpoint group ('phone' | 'tablet-desktop')
|
||||
</behavior>
|
||||
<action>
|
||||
Create `apps/pwa/src/lib/hydrateEvents.ts` exporting `ScheduleXEvent` interface and `hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[]`. Branch on `occ.allDay`: if true use `Temporal.PlainDate.from(occ.start)` for start/end (never construct a ZonedDateTime from midnight UTC — Pitfall 2); else `Temporal.ZonedDateTime.from(occ.start/end)`.
|
||||
|
||||
CRITICAL — calendarId routing: set the Schedule-X `calendarId` to `occ.isShared ? 'shared' : String(occ.ownerUserId)`. Do NOT use `String(occ.calendarId)` — `occ.calendarId` is the DB calendar-row id, but `buildCalendarConfig` keys the calendars config by `String(userId)` plus `'shared'`. A member who owns multiple calendars (e.g. the broker account exposes "Calendar" and "USA Holidays") would otherwise produce a calendarId that matches no config key, and Schedule-X would render those events with no color. Routing by `'shared' | String(ownerUserId)` is the contract that aligns with buildCalendarConfig's keys.
|
||||
|
||||
Carry uid/color/isShared on `_familySync`. Temporal is global via the main.tsx polyfill import; in tests import `'temporal-polyfill/global'` at the top of hydrateEvents.test.ts. Turn the Plan 01 RED hydrateEvents.test.ts green — including its shared→'shared' and personal→String(ownerUserId) assertions.
|
||||
|
||||
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>
|
||||
<automated>cd apps/pwa && grep -q "Temporal.PlainDate.from" src/lib/hydrateEvents.ts && grep -q "ownerUserId" src/lib/hydrateEvents.ts && grep -q "credentials: 'include'" src/api/client.ts && grep -q "calendarRange" src/store/calendarStore.ts && echo SLICE_LIB_OK</automated>
|
||||
<automated>cd apps/pwa && pnpm exec tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- hydrateEvents.test.ts (Plan 01 RED stub) passes: all-day→PlainDate, timed→ZonedDateTime, shared→'shared', personal→String(ownerUserId)
|
||||
- hydrateEvents.ts uses Temporal.PlainDate.from for all-day and never ZonedDateTime for all-day
|
||||
- hydrateEvents.ts sets calendarId to `occ.isShared ? 'shared' : String(occ.ownerUserId)` (NOT String(occ.calendarId))
|
||||
- calendarStore exports useCalendarStore with selectedView/selectedDate/openEventId/calendarRange and persists selectedView to localStorage per breakpoint group
|
||||
- client.ts fetchEvents takes (start,end), hits /api/events?start=&end= with credentials:'include', returns OccurrencesResponse; CalendarOccurrence includes ownerUserId + isShared
|
||||
- tsc --noEmit clean in apps/pwa
|
||||
</acceptance_criteria>
|
||||
<done>hydrateEvents (all-day PlainDate guard + ownership-routed calendarId), Zustand UI store, and windowed fetchEvents all built; hydrateEvents.test.ts green; PWA typechecks.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| 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_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/pwa test` green (colorUtils, calendarConfig, hydrateEvents)
|
||||
- `tsc --noEmit` clean in apps/pwa
|
||||
- main.tsx import order correct (Temporal first)
|
||||
</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>
|
||||
|
||||
<artifacts_produced>
|
||||
## Artifacts this phase produces (Plan 03)
|
||||
|
||||
- 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
|
||||
- main.tsx Temporal-polyfill-first import block
|
||||
</artifacts_produced>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-calendar-display/02-03-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
phase: 02-calendar-display
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["02-02", "02-03"]
|
||||
files_modified:
|
||||
- apps/pwa/src/components/CalendarShell.tsx
|
||||
- apps/pwa/src/App.tsx
|
||||
- apps/pwa/src/components/CalendarShell.test.tsx
|
||||
autonomous: true
|
||||
requirements: [CAL-02, CAL-03, CAL-07]
|
||||
user_setup: []
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "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)"
|
||||
artifacts:
|
||||
- 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"
|
||||
key_links:
|
||||
- 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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Deliver the phase's first true end-to-end user-facing slice: mount Schedule-X in a CalendarShell,
|
||||
fetch the visible window from /api/events via TanStack Query, hydrate the occurrences to Temporal
|
||||
events, feed them to Schedule-X's events service, and render the unified color-coded calendar with
|
||||
all four views switchable. After this plan a household member can open the app and SEE their real
|
||||
Fastmail calendar — color-coded, recurring + all-day correct — across day/week/month/agenda.
|
||||
|
||||
Purpose: This is where CAL-02, CAL-03, and the CAL-07 display path become observable to the user.
|
||||
It consumes the Plan 02 endpoint and the Plan 03 token layer / config / hydration / store.
|
||||
Output: CalendarShell.tsx wired to the full data pipeline; App.tsx renders it; a render smoke test.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-calendar-display/02-UI-SPEC.md
|
||||
@.planning/phases/02-calendar-display/02-RESEARCH.md
|
||||
@.planning/phases/02-calendar-display/02-PATTERNS.md
|
||||
@.planning/phases/02-calendar-display/02-03-SUMMARY.md
|
||||
@.planning/phases/02-calendar-display/02-02-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: CalendarShell — Schedule-X mounted + wired to TanStack Query + hydrate + Zustand range</name>
|
||||
<files>apps/pwa/src/components/CalendarShell.tsx, apps/pwa/src/App.tsx</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/App.tsx (current root: MemberBadge + EventProof + meQuery useQuery pattern lines 58–92; this becomes the CalendarShell host)
|
||||
- apps/pwa/src/api/client.ts (fetchEvents(start,end), fetchMe — from Plan 03)
|
||||
- apps/pwa/src/lib/hydrateEvents.ts (hydrateEvents signature + its calendarId routing: 'shared' | String(ownerUserId) — Plan 03)
|
||||
- apps/pwa/src/lib/calendarConfig.ts (buildCalendarConfig keyed by String(userId) + 'shared', SX_FIRST_DAY_OF_WEEK, view factories — Plan 03)
|
||||
- apps/pwa/src/store/calendarStore.ts (useCalendarStore: calendarRange, selectedView, setCalendarRange, setOpenEventId — Plan 03)
|
||||
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 4: TanStack Query + onRangeUpdate Wiring" + §"Pitfall 4" + §"A4 note" (do not depend on onRangeUpdate for first fetch)
|
||||
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"Component Inventory: CalendarShell" + §"View default logic (D-05)" + §"View Layout Specification"
|
||||
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/components/CalendarShell.tsx"
|
||||
</read_first>
|
||||
<action>
|
||||
Create `apps/pwa/src/components/CalendarShell.tsx`. Read the current user via `useQuery(['me'], fetchMe)` to source member colors, then build the Schedule-X calendars config with `buildCalendarConfig(members)` where members come from /api/me (current user). CRITICAL — the calendars config keys are `String(userId)` for each member plus the reserved `'shared'` entry; these MUST match the `calendarId` that hydrateEvents stamps on each event, which is `occ.isShared ? 'shared' : String(occ.ownerUserId)` (Plan 03 routing fix). Do NOT key the config by the DB calendar-row id (`occ.calendarId`) — events from a member who owns multiple calendars would then render with no color.
|
||||
|
||||
Create the events service and event-modal plugins ONCE via `useState(() => createEventsServicePlugin())[0]` / `useState(() => createEventModalPlugin())[0]` (stable across renders). Build the app with `useCalendarApp({ views: [createViewDay(), createViewWeek(), createViewMonthGrid(), createViewMonthAgenda()], defaultView: <month-agenda on phone, month-grid on tablet/desktop per D-05>, firstDayOfWeek: SX_FIRST_DAY_OF_WEEK, calendars, plugins: [eventsService, eventModal], onRangeUpdate(range){ setCalendarRange({start, end}) } })`.
|
||||
|
||||
Fetch events with `useQuery({ queryKey: ['events', calendarRange.start, calendarRange.end], queryFn: () => fetchEvents(calendarRange.start, calendarRange.end), retry: 2, staleTime: 5*60*1000 })`. The initial calendarRange comes from the Zustand default (current month ± 1 week) — do NOT rely on onRangeUpdate firing on mount (A4 / Open Q2). In a `useEffect` keyed on `eventsQuery.data`, call `eventsService.set(hydrateEvents(eventsQuery.data.occurrences))` (Pitfall 4: must hydrate to Temporal before set). Wire event click to `setOpenEventId` (popover is built in Plan 05; here just store the id — keep the customComponents.eventModal slot reserved for Plan 05).
|
||||
|
||||
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>
|
||||
<automated>cd apps/pwa && grep -q "createViewDay" src/components/CalendarShell.tsx && grep -q "createViewWeek" src/components/CalendarShell.tsx && grep -q "createViewMonthGrid" src/components/CalendarShell.tsx && grep -q "createViewMonthAgenda" src/components/CalendarShell.tsx && echo ALL_FOUR_VIEWS</automated>
|
||||
<automated>cd apps/pwa && pnpm exec tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- CalendarShell.tsx mounts ScheduleXCalendar with all four view factories (day/week/month-grid/month-agenda)
|
||||
- CalendarShell uses useQuery(['events', start, end]) → fetchEvents and calls eventsService.set(hydrateEvents(...)) in a data-keyed effect
|
||||
- The calendars config is keyed by String(userId) + 'shared' (matching hydrateEvents' calendarId routing), NOT by the DB calendar-row id
|
||||
- defaultView resolves to month-agenda on phone and month-grid on tablet/desktop (D-05)
|
||||
- firstDayOfWeek passed as SX_FIRST_DAY_OF_WEEK (=7), not 0
|
||||
- App.tsx renders CalendarShell as root; EventProof landing content removed from the render path
|
||||
- No hard-coded hex/px in CalendarShell.tsx (token vars only); tsc --noEmit clean
|
||||
</acceptance_criteria>
|
||||
<done>Schedule-X renders real windowed Fastmail occurrences (color-coded via userId/shared-keyed config, all four views switchable) as the app root; PWA typechecks.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: CalendarShell render smoke test (CAL-03 — four views, real-data render path)</name>
|
||||
<files>apps/pwa/src/components/CalendarShell.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/components/CalendarShell.tsx (Task 1 output — the component under test)
|
||||
- apps/pwa/vitest.config.ts (jsdom env — Plan 01)
|
||||
- apps/api → .planning/phases/02-calendar-display/02-RESEARCH.md §"Phase Requirements → Test Map" (CAL-03 smoke row) + §"Pitfall 6" (test the @schedule-x/react + @schedule-x/calendar integration in Wave 0)
|
||||
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/vitest.config.ts" (jsdom test pattern)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- CalendarShell renders without throwing when all four views are configured (CAL-03 smoke; validates @schedule-x/react@4.1.0 + @schedule-x/calendar@4.6.0 compatibility — Pitfall 6/A2)
|
||||
- Given a mocked fetchEvents returning one timed + one all-day occurrence, eventsService receives hydrated Temporal events (no ISO-string rejection — Pitfall 4)
|
||||
</behavior>
|
||||
<action>
|
||||
Create `apps/pwa/src/components/CalendarShell.test.tsx` using @testing-library/react under jsdom. Import `'temporal-polyfill/global'` at top. Mock `../api/client` so `fetchMe` returns a member and `fetchEvents` returns `{ occurrences: [<one timed>, <one all-day>] }`. Wrap render in a QueryClientProvider with retry:false. Assert the component renders without throwing (the CAL-03 smoke from the test map) and that the Schedule-X root mounts. If asserting on eventsService internals is impractical, assert that hydrateEvents is invoked with the mocked occurrences (spy) and that no error is thrown for the all-day PlainDate event — this guards Pitfall 4 (Temporal hydration) and Pitfall 6 (adapter/core version compatibility) per the Wave 0 mandate.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd apps/pwa && pnpm test -- src/components/CalendarShell.test.tsx</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- CalendarShell.test.tsx renders the component under jsdom without throwing with all four views configured
|
||||
- The test exercises both a timed and an all-day occurrence through the hydrate→eventsService path
|
||||
- Test passes, confirming @schedule-x/react@4.1.0 ↔ @schedule-x/calendar@4.6.0 compatibility (A2/Pitfall 6 resolved)
|
||||
</acceptance_criteria>
|
||||
<done>CAL-03 render smoke test green; Schedule-X integration and Temporal hydration path validated under test.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| 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_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/pwa test` green (CalendarShell smoke)
|
||||
- `tsc --noEmit` clean in apps/pwa
|
||||
- Manual (dev-auth bypass): app shows real color-coded events; all four views switch and render
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-calendar-display/02-04-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
phase: 02-calendar-display
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["02-04"]
|
||||
files_modified:
|
||||
- apps/pwa/src/components/EventDetailPopover.tsx
|
||||
- apps/pwa/src/components/ColorLegend.tsx
|
||||
- apps/pwa/src/components/AppNav.tsx
|
||||
- apps/pwa/src/components/ViewToolbar.tsx
|
||||
- apps/pwa/src/components/SkeletonCalendar.tsx
|
||||
- apps/pwa/src/components/EmptyState.tsx
|
||||
- apps/pwa/src/components/CalendarShell.tsx
|
||||
- apps/pwa/src/components/EventProof.tsx
|
||||
- apps/pwa/src/components/EventDetailPopover.test.tsx
|
||||
autonomous: false
|
||||
requirements: [CAL-02, CAL-03, CAL-07]
|
||||
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"
|
||||
- "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"
|
||||
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"]
|
||||
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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Complete the calendar UX: the read-only EventDetailPopover (built for Phase 3 reuse), the
|
||||
always-visible ColorLegend, the AppNav + ViewToolbar chrome, and the loading / empty / error states
|
||||
— all token-styled, accessible, and touch-friendly. Wire the popover and state branches into
|
||||
CalendarShell, retire the Phase 1 EventProof, and gate the phase on a human visual verification.
|
||||
|
||||
Purpose: D-07 (legend), D-08 (tap-to-expand popover reusable in Phase 3), and the "slick"
|
||||
constraint (skeleton/empty/error polish) land here. This closes the four phase success criteria
|
||||
into a verifiable, glanceable calendar.
|
||||
Output: popover + legend + nav + toolbar + skeleton + empty/error states wired into CalendarShell;
|
||||
EventProof removed; human-verify checkpoint.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-calendar-display/02-UI-SPEC.md
|
||||
@.planning/phases/02-calendar-display/02-PATTERNS.md
|
||||
@.planning/phases/02-calendar-display/02-04-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: EventDetailPopover (read-only, accessible, XSS-safe, Phase-3-reusable) + wire into CalendarShell</name>
|
||||
<files>apps/pwa/src/components/EventDetailPopover.tsx, apps/pwa/src/components/CalendarShell.tsx, apps/pwa/src/components/EventDetailPopover.test.tsx</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/components/CalendarShell.tsx (Plan 04: openEventId via setOpenEventId, reserved customComponents.eventModal slot, the hydrated events in the TanStack Query cache)
|
||||
- apps/pwa/src/App.tsx MemberBadge (component prop + inline-style analog, lines 36–55)
|
||||
- apps/pwa/src/store/calendarStore.ts (openEventId / setOpenEventId)
|
||||
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"EventDetailPopover" + §"Interaction Contract: Keyboard / accessibility" + §"Copywriting Contract" (close = "×", aria-label="Close")
|
||||
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/components/EventDetailPopover.tsx" (focus trap, Escape, never dangerouslySetInnerHTML)
|
||||
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 4" (customComponents eventModal) + §Security (XSS via event fields)
|
||||
</read_first>
|
||||
<behavior>
|
||||
- popover renders title (heading), date/time line, location line when present, description block when present, and calendar name + owner color swatch
|
||||
- Escape closes the popover and clears openEventId; clicking the backdrop closes it
|
||||
- event title/description rendered as plain text children (no dangerouslySetInnerHTML)
|
||||
- close button has aria-label="Close" and a ≥44px touch target
|
||||
</behavior>
|
||||
<action>
|
||||
Create `apps/pwa/src/components/EventDetailPopover.tsx` exporting `EventDetailPopover`. It resolves the open event from the TanStack Query events cache by `openEventId` (Zustand) — or accepts the Schedule-X eventModal `calendarEvent` prop when used as `customComponents.eventModal`. Render per UI-SPEC §EventDetailPopover: color chip + title (--text-heading), date/time (--text-label, secondary), location with a lucide-react MapPin icon if present, description block (max 4 lines then scroll), and a calendar-name + owner-color-swatch footer. Reserve an empty footer action area with a comment noting Phase 3 adds edit/delete here (D-08). On phone render as a bottom sheet; on tablet/desktop as an anchored popover (max-width 360px) — use the --bp-* tokens. Implement: focus trap while open, Escape closes (calls setOpenEventId(null)), focus returns to the triggering element, close "×" button aria-label="Close" with min 44px target, backdrop tap closes. NEVER use dangerouslySetInnerHTML for any event field (XSS guard). Token-styled only.
|
||||
|
||||
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>
|
||||
<automated>cd apps/pwa && grep -q "customComponents" src/components/CalendarShell.tsx && grep -q "EventDetailPopover" src/components/CalendarShell.tsx && ! grep -q "dangerouslySetInnerHTML" src/components/EventDetailPopover.tsx && echo POPOVER_WIRED_XSS_SAFE</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- EventDetailPopover.tsx exports EventDetailPopover and renders title/time/location/description/calendar-name+color
|
||||
- Escape closes and clears openEventId; close button has aria-label="Close" and ≥44px target
|
||||
- No dangerouslySetInnerHTML anywhere in EventDetailPopover.tsx
|
||||
- CalendarShell passes customComponents.eventModal = EventDetailPopover
|
||||
- EventDetailPopover.test.tsx green incl. the escaped-HTML-as-text assertion
|
||||
</acceptance_criteria>
|
||||
<done>Tap-to-expand read-only popover (accessible, XSS-safe, Phase-3-reusable) wired into the calendar; test green.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: ColorLegend + AppNav + ViewToolbar + skeleton/empty/error states; retire EventProof</name>
|
||||
<files>apps/pwa/src/components/ColorLegend.tsx, apps/pwa/src/components/AppNav.tsx, apps/pwa/src/components/ViewToolbar.tsx, apps/pwa/src/components/SkeletonCalendar.tsx, apps/pwa/src/components/EmptyState.tsx, apps/pwa/src/components/CalendarShell.tsx, apps/pwa/src/components/EventProof.tsx</files>
|
||||
<read_first>
|
||||
- apps/pwa/src/components/CalendarShell.tsx (Task 1 + Plan 04: eventsQuery isLoading/isError/data, calendars config for legend, selectedView/setSelectedView)
|
||||
- apps/pwa/src/App.tsx (MemberBadge + ColorSwatch analogs for legend swatches)
|
||||
- apps/pwa/src/components/EventProof.tsx (Phase 1 proof component to delete; confirm no remaining imports)
|
||||
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"Component Inventory" (ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState) + §"Copywriting Contract" + §"Interaction Contract" + §"Post-Verification Reviewer Notes" (grid is primary focal point; phone-nav avatar needs aria-label+title)
|
||||
- .planning/phases/02-calendar-display/02-PATTERNS.md §"ColorLegend", §"SkeletonCalendar" (shimmer keyframe), §"CSS Token Usage in Components"
|
||||
</read_first>
|
||||
<action>
|
||||
Create the chrome and state components, token-styled, 44px touch targets, accessible per UI-SPEC:
|
||||
- `ColorLegend.tsx`: one row per member (12px color circle + display name) plus a "Family" row using --color-shared-family. Always rendered, non-interactive (filter deferred, D-07). Swatch aria-label="{name}: {hex}". Members sourced from the calendars config / /api/me.
|
||||
- `AppNav.tsx`: phone = 48px top bar (app name "FamilySync" left, user color swatch right with aria-label + title per reviewer note); tablet/desktop = 240px left sidebar (app name + ColorLegend). Accent colors NOT used on chrome (UI-SPEC 60/30/10).
|
||||
- `ViewToolbar.tsx`: Today | < | > | [Day][Week][Month][Agenda]. Buttons role="button", keyboard-activatable, 44px min height, --text-label. Active view uses a subtle surface tint (NOT accent). Calls setSelectedView + drives Schedule-X view; prev/next/today drive Schedule-X navigation.
|
||||
- `SkeletonCalendar.tsx`: shimmer (the @keyframes shimmer from tokens.css), month variant = 6×7 placeholder grid, agenda variant = 4 date-group blocks; root aria-busy="true", aria-label="Loading calendar". No spinner.
|
||||
- `EmptyState.tsx`: centered lucide-react CalendarDays (32px, --color-text-muted) + heading "Nothing here" + body "No events in this period. Try a different date or switch views." (UI-SPEC copy).
|
||||
|
||||
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>
|
||||
<automated>cd /home/luc/Projects/familysync && ! test -f apps/pwa/src/components/EventProof.tsx && ! grep -rq "EventProof" apps/pwa/src && echo EVENTPROOF_REMOVED</automated>
|
||||
<automated>cd apps/pwa && pnpm exec tsc --noEmit && pnpm exec eslint src --max-warnings=0 2>/dev/null || pnpm exec tsc --noEmit</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState exist and are token-styled (no hard-coded hex/px)
|
||||
- CalendarShell renders the chrome and branches loading→Skeleton, empty→EmptyState, error→error state with working Retry (refetchQueries(['events']))
|
||||
- ViewToolbar buttons are 44px min height, keyboard-activatable; active view uses a surface tint not accent
|
||||
- EventProof.tsx is deleted and no references to it remain anywhere in apps/pwa/src
|
||||
- tsc --noEmit clean in apps/pwa
|
||||
</acceptance_criteria>
|
||||
<done>Legend, nav, toolbar, and loading/empty/error states are wired and token-styled; EventProof retired; PWA typechecks.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking">
|
||||
<name>Task 3: [CHECKPOINT] Visual + functional verification of the calendar (4 success criteria)</name>
|
||||
<action>Operator-only manual verification: run the dev stack behind the dev-auth bypass and confirm all four phase success criteria against the live calendar UI. No code is written in this task. Follow the steps in how-to-verify exactly and report pass/fail per criterion.</action>
|
||||
<what-built>
|
||||
The complete read-only calendar: unified color-coded events across day/week/month/agenda, the
|
||||
always-visible color legend, tap-to-expand read-only detail popover, and polished
|
||||
skeleton/empty/error states — all on the clean token theme, behind the dev-auth bypass.
|
||||
</what-built>
|
||||
<how-to-verify>
|
||||
1. Start the stack in dev with the bypass: ensure `NODE_ENV` is not production and `DEV_AUTH_BYPASS=true`, then run the API + PWA dev servers (e.g. `pnpm -r dev` or the project's documented dev command). Confirm the shared-family calendar was marked is_shared (Plan 02 checkpoint).
|
||||
2. Open the PWA in a desktop browser. CONFIRM (success criterion 1): events appear color-coded — each member's events in their assigned color, shared-family events in the rose; the legend decodes which color is whom.
|
||||
3. Switch Day / Week / Month / Agenda (success criterion 2): all events render correctly in each view; no missing or misplaced events.
|
||||
4. Find a recurring event (e.g. a weekly meeting) and confirm (success criterion 3) all its occurrences show in the current window; navigate across a DST boundary (March 2026) and confirm the time does not jump ±1 hour.
|
||||
5. Find an all-day event (birthday/holiday) and confirm (success criterion 4) it appears as a full-day banner on the correct date — not shifted a day early/late.
|
||||
6. Tap an event: the read-only popover opens with title/time/location/description; Escape and backdrop-tap both close it.
|
||||
7. Resize to a phone width (or open on a phone via the dev URL): confirm the default view is Agenda and the popover is a bottom sheet.
|
||||
8. Force the empty state (navigate to a far-future empty window) and the error state (stop the API, hit Retry) and confirm both render as specified.
|
||||
</how-to-verify>
|
||||
<resume-signal>Type "approved" if all four success criteria hold, or describe the specific view/event/state that is wrong.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| 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_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/pwa test` green (popover test)
|
||||
- `pnpm -r test` + `tsc --noEmit` clean in both workspaces (phase gate)
|
||||
- Human-verify checkpoint passes all four success criteria
|
||||
</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>
|
||||
|
||||
<artifacts_produced>
|
||||
## Artifacts this phase produces (Plan 05)
|
||||
|
||||
- `EventDetailPopover` (React component, Phase-3-reusable edit surface) — EventDetailPopover.tsx
|
||||
- `ColorLegend`, `AppNav`, `ViewToolbar`, `SkeletonCalendar`, `EmptyState` (React components)
|
||||
- CalendarShell: chrome + loading/empty/error branches + customComponents.eventModal wiring
|
||||
- EventProof.tsx DELETED (Phase 1 proof component retired)
|
||||
- EventDetailPopover.test.tsx
|
||||
</artifacts_produced>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/02-calendar-display/02-05-SUMMARY.md` when done
|
||||
</output>
|
||||
@@ -0,0 +1,723 @@
|
||||
# Phase 2: Calendar Display - Pattern Map
|
||||
|
||||
**Mapped:** 2026-06-04
|
||||
**Files analyzed:** 17 new/modified files
|
||||
**Analogs found:** 15 / 17
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `apps/api/src/db/schema.ts` (model — modify existing)
|
||||
|
||||
**Analog:** self
|
||||
|
||||
**Add to `calendarEvents` table — Drizzle column pattern** (lines 84–105 of current file):
|
||||
```typescript
|
||||
// New columns to add — follow existing column declaration style exactly:
|
||||
hasRrule: boolean('has_rrule').default(false).notNull(),
|
||||
isShared: boolean('is_shared').default(false).notNull(), // on calendars table, not calendarEvents
|
||||
|
||||
// On calendars table — add alongside existing columns:
|
||||
isShared: boolean('is_shared').default(false).notNull(),
|
||||
|
||||
// Index pattern to copy for hasRrule (copy idx_calendar_events_dtstart_utc style):
|
||||
index('idx_calendar_events_has_rrule').on(t.hasRrule),
|
||||
```
|
||||
|
||||
**Import pattern** (lines 1–11 of existing schema.ts):
|
||||
```typescript
|
||||
import {
|
||||
mysqlTable,
|
||||
varchar,
|
||||
text,
|
||||
int,
|
||||
date,
|
||||
timestamp,
|
||||
boolean,
|
||||
index,
|
||||
unique,
|
||||
} from 'drizzle-orm/mysql-core'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/broker/expand.ts` (utility, transform — new file)
|
||||
|
||||
**Analog:** `apps/api/src/broker/sync.ts`
|
||||
|
||||
**Imports pattern** (lines 1–8 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'
|
||||
```
|
||||
|
||||
**ICAL.parse + Component pipeline pattern** (lines 78–91 of sync.ts):
|
||||
```typescript
|
||||
let parsed: ReturnType<typeof ICAL.parse>
|
||||
try {
|
||||
parsed = ICAL.parse(obj.data as string)
|
||||
} catch {
|
||||
// Malformed VCALENDAR — skip but do not crash the sync
|
||||
continue
|
||||
}
|
||||
|
||||
const comp = new ICAL.Component(parsed)
|
||||
const vevent = comp.getFirstSubcomponent('vevent')
|
||||
if (!vevent) continue
|
||||
|
||||
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null
|
||||
```
|
||||
|
||||
**allDay detection pattern** (lines 88–98 of sync.ts):
|
||||
```typescript
|
||||
// D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
|
||||
const allDay: boolean = dtstart?.isDate ?? false
|
||||
```
|
||||
|
||||
**Error handling pattern** (lines 75–78 of sync.ts):
|
||||
```typescript
|
||||
try {
|
||||
parsed = ICAL.parse(obj.data as string)
|
||||
} catch {
|
||||
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
|
||||
if (tzid && !ICAL.TimezoneService.has(tzid)) {
|
||||
ICAL.TimezoneService.register(
|
||||
tzid,
|
||||
new ICAL.Timezone({ component: vtz, tzid }),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/routes/events.ts` (route, request-response — modify existing)
|
||||
|
||||
**Analog:** `apps/api/src/routes/me.ts` + current `events.ts`
|
||||
|
||||
**Route file structure pattern** (lines 1–29 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()
|
||||
|
||||
meRouter.get('/', async (c) => {
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
// ... business logic
|
||||
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'
|
||||
|
||||
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')
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
**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)
|
||||
// .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||
// .innerJoin(users, eq(calendars.userId, users.id))
|
||||
// .where(...)
|
||||
```
|
||||
|
||||
**Error handling pattern** (lines 16–26 of health.ts):
|
||||
```typescript
|
||||
try {
|
||||
// ...
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/auth/devBypass.ts` (middleware — new file)
|
||||
|
||||
**Analog:** `apps/api/src/auth/middleware.ts`
|
||||
|
||||
**Middleware export pattern** (lines 24–26 of middleware.ts):
|
||||
```typescript
|
||||
// middleware.ts uses re-export; devBypass.ts uses named function export
|
||||
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'
|
||||
|
||||
export function devAuthBypass(): MiddlewareHandler {
|
||||
// Hard production guard FIRST — before reading any env var
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return async (_c, next) => next()
|
||||
}
|
||||
if (process.env.DEV_AUTH_BYPASS !== 'true') {
|
||||
return async (_c, next) => next()
|
||||
}
|
||||
return async (c, next) => {
|
||||
c.set('user', DEV_USER)
|
||||
await next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/src/index.ts` (config — modify existing)
|
||||
|
||||
**Analog:** self
|
||||
|
||||
**Middleware mount order pattern** (lines 14–29 of index.ts):
|
||||
```typescript
|
||||
// OIDC callback BEFORE auth guard (T-02-02)
|
||||
app.get('/callback', (c) => processOAuthCallback(c))
|
||||
|
||||
// Unauthenticated routes BEFORE the guard
|
||||
app.route('/health', healthRouter)
|
||||
|
||||
// Auth guard on /api/*
|
||||
app.use('/api/*', oidcAuthMiddleware())
|
||||
|
||||
// Protected routes after guard
|
||||
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())
|
||||
// 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.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/tests/broker/expand.test.ts` (test — new file)
|
||||
|
||||
**Analog:** `apps/api/tests/broker/poller.test.ts`
|
||||
|
||||
**Test file structure** (lines 1–14 of poller.test.ts):
|
||||
```typescript
|
||||
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 71–121 of poller.test.ts):
|
||||
```typescript
|
||||
describe('broker poller — runPoll', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// reset arrays and mock implementations
|
||||
})
|
||||
|
||||
it('skips syncCalendar when ctag is unchanged', async () => {
|
||||
const { runPoll } = await import('../../src/broker/poller.js')
|
||||
// arrange
|
||||
await runPoll()
|
||||
// assert
|
||||
expect(mockSyncCalendar).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Error resilience test pattern** (lines 176–195 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()
|
||||
})
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
### `apps/api/tests/routes/events.test.ts` (test — new file)
|
||||
|
||||
**Analog:** `apps/api/tests/health.test.ts`
|
||||
|
||||
**Route test pattern** (lines 1–38 of health.test.ts):
|
||||
```typescript
|
||||
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)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/vitest.config.ts` (config — new file)
|
||||
|
||||
**Analog:** `apps/api/vitest.config.ts`
|
||||
|
||||
```typescript
|
||||
// Copy this exactly, add jsdom environment for React:
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom', // differs from API (node)
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/api/client.ts` (utility, request-response — modify existing)
|
||||
|
||||
**Analog:** self
|
||||
|
||||
**Existing function pattern to copy** (lines 22–34 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}`)
|
||||
}
|
||||
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 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}`)
|
||||
}
|
||||
return res.json() as Promise<OccurrencesResponse>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/lib/hydrateEvents.ts` (utility, transform — new file)
|
||||
|
||||
**Analog:** `apps/pwa/src/api/client.ts` (typed transform pattern)
|
||||
|
||||
**Interface definition pattern** (lines 12–16 of client.ts):
|
||||
```typescript
|
||||
export interface MeUser {
|
||||
id: number
|
||||
displayName: string | null
|
||||
color: string
|
||||
}
|
||||
```
|
||||
|
||||
**Function export pattern** (lines 22–34 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/lib/calendarConfig.ts` (utility, transform — new file)
|
||||
|
||||
**Analog:** `apps/pwa/src/api/client.ts` (typed constants + factory function)
|
||||
|
||||
**Exported constant pattern** (lines 12–16 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)
|
||||
```
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/lib/colorUtils.ts` (utility, transform — new file)
|
||||
|
||||
**Analog:** `apps/pwa/src/App.tsx` (ColorSwatch inline style, lines 18–34)
|
||||
|
||||
**Color inline style pattern to extend** (lines 18–34 of App.tsx):
|
||||
```typescript
|
||||
function ColorSwatch({ color }: { color: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
background: color,
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
}}
|
||||
aria-label={`Color: ${color}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Target function signatures:**
|
||||
```typescript
|
||||
// container = main hex at 15% opacity blended over white
|
||||
export function hexToContainer(hex: string): string // returns CSS hex or rgba
|
||||
|
||||
// onContainer = main hex darkened 40%
|
||||
export function hexToOnContainer(hex: string): string
|
||||
|
||||
// convenience: all three for Schedule-X lightColors
|
||||
export function deriveScheduleXColors(main: string): { main: string; container: string; onContainer: string }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/store/calendarStore.ts` (store, event-driven — new file)
|
||||
|
||||
**No existing Zustand analog in codebase.** Follow RESEARCH.md state contract:
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/components/CalendarShell.tsx` (component, request-response — new file)
|
||||
|
||||
**Analog:** `apps/pwa/src/App.tsx`
|
||||
|
||||
**TanStack Query usage pattern** (lines 58–63 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 77–92 of App.tsx):
|
||||
```typescript
|
||||
{meQuery.isLoading && (
|
||||
<div style={{ color: '#666', marginBottom: '1rem' }}>Loading...</div>
|
||||
)}
|
||||
{meQuery.isError && (
|
||||
<div style={{ color: '#991b1b', ... }}>Sign-in required</div>
|
||||
)}
|
||||
{meQuery.data && (
|
||||
<MemberBadge user={meQuery.data.user} />
|
||||
)}
|
||||
```
|
||||
|
||||
**Component file structure** (App.tsx overall shape):
|
||||
- Inline interfaces at top
|
||||
- Sub-components declared before default export
|
||||
- Default export is the root component
|
||||
- No CSS modules yet — inline styles or className with `var(--token)` strings
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/components/EventDetailPopover.tsx` (component — new file)
|
||||
|
||||
**Analog:** `apps/pwa/src/App.tsx` (MemberBadge component, lines 36–55)
|
||||
|
||||
**Component prop interface pattern** (lines 36–38 of App.tsx):
|
||||
```typescript
|
||||
function MemberBadge({ user }: { user: MeUser }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', ... }}>
|
||||
```
|
||||
|
||||
**Target interface:**
|
||||
```typescript
|
||||
interface EventDetailPopoverProps {
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/components/SkeletonCalendar.tsx` (component — new file)
|
||||
|
||||
**Analog:** `apps/pwa/src/App.tsx` loading state (lines 77–80)
|
||||
|
||||
**Loading pattern to replace:**
|
||||
```typescript
|
||||
{meQuery.isLoading && (
|
||||
<div style={{ color: '#666', marginBottom: '1rem' }}>Loading...</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**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; }
|
||||
}
|
||||
/* 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 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `apps/pwa/src/main.tsx` (config — modify existing)
|
||||
|
||||
**Analog:** self
|
||||
|
||||
**Current structure** (lines 1–21 of main.tsx):
|
||||
```typescript
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App.js'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
**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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Authentication Guard (all API routes)
|
||||
**Source:** `apps/api/src/index.ts` lines 24–29
|
||||
```typescript
|
||||
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 16–26
|
||||
```typescript
|
||||
try {
|
||||
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)
|
||||
}
|
||||
```
|
||||
**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 39–56
|
||||
```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 88–98
|
||||
```typescript
|
||||
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
|
||||
```
|
||||
**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 58–70
|
||||
```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 22–34
|
||||
```typescript
|
||||
const res = await fetch('/api/me', { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
throw new Error(`GET /api/me failed: ${res.status}`)
|
||||
}
|
||||
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 37–55 (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 |
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `apps/api/src/`, `apps/api/tests/`, `apps/pwa/src/`
|
||||
**Files read:** 15
|
||||
**Pattern extraction date:** 2026-06-04
|
||||
@@ -0,0 +1,919 @@
|
||||
# Phase 2: Calendar Display - Research
|
||||
|
||||
**Researched:** 2026-06-04
|
||||
**Domain:** React PWA calendar rendering · CalDAV recurrence expansion · Timezone/DST correctness · CSS token theming
|
||||
**Confidence:** HIGH (locked stack verified against live codebase and official docs)
|
||||
|
||||
---
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
- **D-01:** Design-token layer — color, spacing, density, typography as CSS custom properties + TypeScript token object. No hard-coded colors/spacing in components.
|
||||
- **D-02:** Ship only the "clean" theme in Phase 2. Token architecture must make a future kiosk theme a token-set swap, not a refactor.
|
||||
- **D-03:** Clean theme is tuned for legibility at tablet distance (information density first-class).
|
||||
- **D-04:** All four views: day, week, month, agenda.
|
||||
- **D-05:** Device-adaptive default: phone → Agenda; tablet/desktop → Month. Persist last-used view per device.
|
||||
- **D-06:** Per-member color fill from `users.color` (6-color palette). Shared-family calendar uses `#F25C7A`.
|
||||
- **D-07:** Color legend always visible. Per-member show/hide filter deferred.
|
||||
- **D-08:** Informational + tap-to-expand. Month = colored bars with title. EventDetailPopover is read-only in Phase 2 but must be reusable as Phase 3 edit surface.
|
||||
- **D-09:** Recurring events expanded server-side. Client renders concrete occurrences.
|
||||
- **D-10:** Single local timezone for v1. All-day events render with no date shift (D-13 schema already splits allDay/timed).
|
||||
- **Calendar rendering library:** Schedule-X (`@schedule-x/react` + `@schedule-x/calendar`) — selected by UI-SPEC. CSS token override strategy documented there.
|
||||
- **Week start day:** Sunday (WEEK_START_DAY = 0). `calendarConfig.ts` constant.
|
||||
- **TanStack Query = server state; Zustand = UI state.** Server events never enter Zustand.
|
||||
- **Broker boundary:** Routes never call tsdav. `/api/events` reads MariaDB cache only.
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
- Skeleton/loading states: build polished versions.
|
||||
- Dev-auth bypass: documented env-flagged middleware injecting a fixed dev user. Must be off by default and impossible in production builds.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
|
||||
- Tablet/wall-display kiosk mode + Skylight display theme + runtime theme-switcher UI (v2).
|
||||
- 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>
|
||||
|
||||
---
|
||||
|
||||
<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 |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 2 is a read-only calendar display layer built on top of the Phase 1 broker and schema. The backend work is evolving `/api/events` from a raw table dump to a display-ready shape: a windowed query that joins `calendarEvents → calendars → users.color`, expands recurring events via `ICAL.RecurExpansion`, and serializes concrete occurrences as JSON. The frontend work is building a React calendar shell around Schedule-X, wiring TanStack Query to the windowed endpoint, and implementing the CSS token layer.
|
||||
|
||||
The hardest technical areas are (1) the recurrence expansion pipeline — specifically extracting and registering VTIMEZONE components before calling `ICAL.RecurExpansion` so DST boundaries produce correct wall-clock times — and (2) the Schedule-X v4 event format, which requires `Temporal.ZonedDateTime` and `Temporal.PlainDate` objects rather than ISO strings. The server returns JSON; the frontend must hydrate those strings into Temporal objects before passing them to Schedule-X. Both conversions have well-known pitfall patterns documented below.
|
||||
|
||||
All-day event correctness is already partially solved by the D-13 schema split (Phase 1): `allDay=true` events have a `dtstartDate` DATE column with no time component. The API must pass `Temporal.PlainDate` for these, not a `Temporal.ZonedDateTime` derived from midnight UTC, or Schedule-X will shift the date.
|
||||
|
||||
**Primary recommendation:** Expand recurrences on the server using `ICAL.RecurExpansion` with registered VTIMEZONE, serialize occurrences as plain ISO date strings in JSON, and hydrate to Temporal objects in the React client layer before feeding Schedule-X.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### 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 |
|
||||
|
||||
### No New Backend Dependencies Needed
|
||||
|
||||
Phase 1 already installed all required API packages: `ical.js`, `hono`, `drizzle-orm`, `mysql2`, `zod`. The recurrence expansion work (`ICAL.RecurExpansion`) uses ical.js already present. No new API npm packages are required.
|
||||
|
||||
### Installation (PWA only)
|
||||
|
||||
```bash
|
||||
cd apps/pwa
|
||||
pnpm add @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
|
||||
```
|
||||
|
||||
**Note on version mismatch:** `@schedule-x/react` tops out at 4.1.0 (last published 2026-01-21) while `@schedule-x/calendar` is at 4.6.0 (2026-05-12). The React adapter peer-depends on `^3.1.0 || ^4.0.0` for `@schedule-x/calendar` — 4.6.0 satisfies `^4.0.0`. They are **compatible**. [VERIFIED: npm registry]
|
||||
|
||||
**Note on `firstDayOfWeek`:** Schedule-X v4 uses Temporal numbering where `7 = Sunday`, NOT `0 = Sunday`. [VERIFIED: schedule-x.dev/docs/calendar/configuration]. The UI-SPEC sets `WEEK_START_DAY = 0` as a constant — the constant must be converted: pass `7` to Schedule-X when the constant is `0`. Document this translation in `calendarConfig.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
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 |
|
||||
|
||||
**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.*
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
Fastmail CalDAV
|
||||
│
|
||||
▼
|
||||
[Broker Poller] ──5min ctag──▶ [MariaDB: calendarEvents]
|
||||
│
|
||||
▼
|
||||
[GET /api/events?start=&end=]
|
||||
┌────┴────┐
|
||||
│ Window │
|
||||
│ filter │
|
||||
│ (SQL) │
|
||||
└────┬────┘
|
||||
│ rawVevent rows + calendar.userId + users.color
|
||||
▼
|
||||
[expandOccurrences()]
|
||||
┌─────────────────────┐
|
||||
│ ICAL.parse(rawVevent) │
|
||||
│ ICAL.RecurExpansion │
|
||||
│ VTIMEZONE register │
|
||||
│ EXDATE filter │
|
||||
│ allDay / timed split │
|
||||
└──────────┬────────────┘
|
||||
│ JSON: CalendarOccurrence[]
|
||||
▼
|
||||
[TanStack Query: ['events', start, end]]
|
||||
│
|
||||
▼
|
||||
[hydrateEvents()] ← converts ISO→Temporal
|
||||
│
|
||||
▼
|
||||
[Schedule-X eventsService.set()]
|
||||
│
|
||||
┌───────────┴────────────┐
|
||||
│ ScheduleXCalendar │
|
||||
│ Day | Week | Month | │
|
||||
│ Agenda │
|
||||
└────────────────────────-┘
|
||||
│
|
||||
▼
|
||||
[EventDetailPopover] (custom eventModal)
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
apps/
|
||||
├── api/src/
|
||||
│ ├── routes/
|
||||
│ │ └── events.ts ← evolve: windowed query + expansion
|
||||
│ └── broker/
|
||||
│ └── expand.ts ← new: expandOccurrences() helper
|
||||
└── pwa/src/
|
||||
├── styles/
|
||||
│ ├── tokens.css ← CSS custom properties (clean theme)
|
||||
│ ├── tokens.ts ← TypeScript token object
|
||||
│ └── index.css ← imports tokens.css + global resets
|
||||
├── lib/
|
||||
│ ├── calendarConfig.ts ← WEEK_START_DAY + Schedule-X config factory
|
||||
│ └── colorUtils.ts ← derive container/onContainer from main hex
|
||||
├── components/
|
||||
│ ├── CalendarShell.tsx ← layout: AppNav + ViewToolbar + ColorLegend + SX
|
||||
│ ├── AppNav.tsx
|
||||
│ ├── ViewToolbar.tsx
|
||||
│ ├── ColorLegend.tsx
|
||||
│ ├── EventDetailPopover.tsx ← read-only; Phase 3 adds edit actions in footer
|
||||
│ └── SkeletonCalendar.tsx
|
||||
├── store/
|
||||
│ └── calendarStore.ts ← Zustand: selectedView, selectedDate, openEventId, calendarRange
|
||||
└── api/
|
||||
└── client.ts ← extend with fetchEvents(start, end)
|
||||
```
|
||||
|
||||
### Pattern 1: `/api/events` Windowed Query with Expansion
|
||||
|
||||
**What:** `GET /api/events?start=2026-06-01&end=2026-07-01` returns a flat array of concrete occurrences (no recurring master events, no raw VCALENDAR blobs). Each occurrence has all fields the UI needs: title, start (ISO string), end (ISO string), allDay, color, calendarId, calendarName, uid, occurrenceId (uid + dtstart for identity), location, description.
|
||||
|
||||
**Server implementation shape:**
|
||||
|
||||
```typescript
|
||||
// apps/api/src/broker/expand.ts
|
||||
// Source: https://github.com/kewisch/ical.js/wiki/Common-Use-Cases
|
||||
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
|
||||
}
|
||||
|
||||
export function expandOccurrences(
|
||||
rawVevent: string,
|
||||
windowStart: Date,
|
||||
windowEnd: Date,
|
||||
calendarId: number,
|
||||
calendarName: string,
|
||||
ownerUserId: number,
|
||||
color: string,
|
||||
isShared: boolean,
|
||||
): CalendarOccurrence[] {
|
||||
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
|
||||
if (tzid && !ICAL.TimezoneService.has(tzid)) {
|
||||
ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtimezone, tzid }))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Non-recurring event: single occurrence check
|
||||
if (!event.isRecurring()) {
|
||||
// ... check if within window, return single occurrence
|
||||
}
|
||||
|
||||
// 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 occurrences: CalendarOccurrence[] = []
|
||||
let next: ICAL.Time | null
|
||||
|
||||
while ((next = expand.next()) && next.compare(rangeEnd) < 0) {
|
||||
if (next.compare(rangeStart) < 0) continue
|
||||
// Build occurrence, compute end from duration
|
||||
// ...
|
||||
}
|
||||
return occurrences
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight:** `ICAL.RecurExpansion` handles EXDATE exclusions internally — you do not need to extract and compare EXDATEs manually when using the high-level API. [CITED: github.com/kewisch/ical.js/wiki/Common-Use-Cases]
|
||||
|
||||
### Pattern 2: Schedule-X Event Format (Temporal, not ISO strings)
|
||||
|
||||
**What:** Schedule-X v4 requires `Temporal.ZonedDateTime` for timed events and `Temporal.PlainDate` for all-day events. The backend returns ISO strings; the client hydrates them.
|
||||
|
||||
**Critical finding:** Schedule-X v3 changed from the old `"2024-01-15 09:00"` ISO string format to Temporal objects. v4 continues this. You cannot pass plain strings. [VERIFIED: schedule-x.dev/blog/schedule-x-v3-temporal-api]
|
||||
|
||||
```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
|
||||
|
||||
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
|
||||
// custom business fields pass through
|
||||
_familySync?: { uid: string; color: string; isShared: boolean }
|
||||
}
|
||||
|
||||
export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[] {
|
||||
return occurrences.map((occ) => {
|
||||
if (occ.allDay) {
|
||||
// All-day: use PlainDate — do NOT construct ZonedDateTime from midnight UTC
|
||||
return {
|
||||
id: occ.id,
|
||||
title: occ.title,
|
||||
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 {
|
||||
id: occ.id,
|
||||
title: occ.title,
|
||||
start: Temporal.ZonedDateTime.from(occ.start),
|
||||
end: Temporal.ZonedDateTime.from(occ.end),
|
||||
calendarId: String(occ.calendarId),
|
||||
location: occ.location ?? undefined,
|
||||
description: occ.description ?? undefined,
|
||||
_familySync: { uid: occ.uid, color: occ.color, isShared: occ.isShared },
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Schedule-X Calendar Configuration
|
||||
|
||||
```typescript
|
||||
// apps/pwa/src/lib/calendarConfig.ts
|
||||
// Source: https://schedule-x.dev/docs/calendar/calendars
|
||||
// Source: https://schedule-x.dev/docs/calendar/configuration
|
||||
import {
|
||||
createViewDay,
|
||||
createViewWeek,
|
||||
createViewMonthGrid,
|
||||
createViewMonthAgenda,
|
||||
} 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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
export interface MemberCalendarConfig {
|
||||
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 } }> = {}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
||||
// 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
|
||||
onContainer: darkenHex(main, 0.4),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`firstDayOfWeek` translation note:** The UI-SPEC declares `WEEK_START_DAY = 0` (Sunday in JS/date-fns convention). Schedule-X v4 uses Temporal convention where Sunday = 7. Pass `7` to Schedule-X when `WEEK_START_DAY === 0`. [VERIFIED: schedule-x.dev/docs/calendar/configuration]
|
||||
|
||||
### Pattern 4: TanStack Query + onRangeUpdate Wiring
|
||||
|
||||
```typescript
|
||||
// apps/pwa/src/components/CalendarShell.tsx (sketch)
|
||||
import { useCalendarApp, ScheduleXCalendar } from '@schedule-x/react'
|
||||
import { createViewDay, createViewWeek, createViewMonthGrid, createViewMonthAgenda } from '@schedule-x/calendar'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
function CalendarShell() {
|
||||
const rangeStore = useCalendarStore() // Zustand
|
||||
const { start, end } = rangeStore.calendarRange
|
||||
|
||||
// TanStack Query — key includes the visible window
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ['events', start, end],
|
||||
queryFn: () => fetchEvents(start, end),
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000, // 5 min — poller refreshes broker every 5 min
|
||||
})
|
||||
|
||||
const eventsService = useState(() => createEventsServicePlugin())[0]
|
||||
const eventModal = useState(() => createEventModalPlugin())[0]
|
||||
|
||||
const calendar = useCalendarApp({
|
||||
views: [createViewDay(), createViewWeek(), createViewMonthGrid(), createViewMonthAgenda()],
|
||||
defaultView: isMobile ? 'month-agenda' : 'month-grid', // D-05
|
||||
firstDayOfWeek: 7, // Sunday — Temporal convention
|
||||
calendars: buildCalendarConfig(members).calendars,
|
||||
plugins: [eventsService, eventModal],
|
||||
onRangeUpdate(range) {
|
||||
// Schedule-X fires this when the user navigates to a new window
|
||||
// range.start and range.end are ISO date strings in v4
|
||||
rangeStore.setCalendarRange({ start: range.start, end: range.end })
|
||||
// Setting Zustand range triggers queryKey change → TanStack Query re-fetches
|
||||
},
|
||||
})
|
||||
|
||||
// Sync TanStack Query result into Schedule-X eventsService
|
||||
useEffect(() => {
|
||||
if (eventsQuery.data) {
|
||||
const sxEvents = hydrateEvents(eventsQuery.data.occurrences)
|
||||
eventsService.set(sxEvents)
|
||||
}
|
||||
}, [eventsQuery.data])
|
||||
|
||||
return (
|
||||
<ScheduleXCalendar
|
||||
calendarApp={calendar}
|
||||
customComponents={{ eventModal: EventDetailPopover }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Dev-Auth Bypass Middleware
|
||||
|
||||
```typescript
|
||||
// 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'
|
||||
|
||||
const DEV_USER = {
|
||||
id: 1,
|
||||
oidcIss: 'dev',
|
||||
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()
|
||||
}
|
||||
if (process.env.DEV_AUTH_BYPASS !== 'true') {
|
||||
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()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mount in `index.ts` BEFORE `oidcAuthMiddleware` on `/api/*` when bypass is active. The bypass must short-circuit the OIDC redirect — `oidcAuthMiddleware` must be conditionally swapped out, not just prepended. Cleanest pattern: `app.use('/api/*', devAuthBypass() ?? oidcAuthMiddleware())`.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Putting timed events on CalendarShell with a plain ISO string:** Schedule-X v4 rejects ISO strings. Hydrate to Temporal before calling `eventsService.set()`.
|
||||
- **Constructing `Temporal.ZonedDateTime` for all-day events:** All-day events must use `Temporal.PlainDate`. Using `ZonedDateTime` midnight UTC will shift the date in non-UTC local timezones.
|
||||
- **Expanding RRULE in the client:** D-09 locks server-side expansion. Never import rrule or call `ICAL.RecurExpansion` in the React frontend.
|
||||
- **Fetching all events without a window:** With 503+ cached events and recurring series expanding infinitely, an unwindowed `/api/events` call will time out or exhaust memory. The `?start=&end=` window is mandatory.
|
||||
- **Skipping VTIMEZONE registration:** If `ICAL.TimezoneService.register()` is not called before `ICAL.RecurExpansion`, ical.js falls back to UTC for timezone-aware events. Events in summer DST will appear one hour off.
|
||||
- **Using rrule directly when ical.js RecurExpansion is available:** `ICAL.RecurExpansion` handles EXDATE, RDATE, and VTIMEZONE in one integrated call. rrule only handles the RRULE string — you must separately handle EXDATEs and VTIMEZONE registration. Use rrule only as a fallback for parsing RRULE strings that `ICAL.RecurExpansion` cannot handle.
|
||||
- **Hard-coding `0` as `firstDayOfWeek` in Schedule-X config:** Schedule-X v4 uses Temporal numbering (7 = Sunday, not 0). Passing `0` will default to Monday.
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: `firstDayOfWeek` Temporal Numbering Mismatch
|
||||
|
||||
**What goes wrong:** Passing `0` to Schedule-X `firstDayOfWeek` instead of `7` causes week views to start on Monday (the default), silently ignoring Sunday.
|
||||
|
||||
**Why it happens:** JS/date-fns use `0 = Sunday`; Temporal (and therefore Schedule-X v4) uses `1 = Monday ... 7 = Sunday`.
|
||||
|
||||
**How to avoid:** The constant `WEEK_START_DAY = 0` in `calendarConfig.ts` is in the JS convention. Translate it: `const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY`.
|
||||
|
||||
**Warning signs:** Week view starts on Monday even after setting Sunday; calendar headers show Mon as the first column.
|
||||
|
||||
### Pitfall 2: All-Day Events Shifting by One Day
|
||||
|
||||
**What goes wrong:** An all-day event for "June 15" appears on "June 14" (or "June 16") in the calendar.
|
||||
|
||||
**Why it happens:** If the all-day event's ISO string (`2026-06-15`) is passed to `Temporal.ZonedDateTime.from('2026-06-15T00:00:00Z')`, the UTC midnight maps to June 14 in timezones west of UTC. Schedule-X expects `Temporal.PlainDate` for all-day events.
|
||||
|
||||
**How to avoid:** In `hydrateEvents()`, check `occ.allDay`. If `true`, use `Temporal.PlainDate.from(occ.start)` where `occ.start` is already `'YYYY-MM-DD'`. Never construct a ZonedDateTime for an all-day event. The D-13 schema split (Phase 1) already separates `dtstartDate` (DATE column, all-day) from `dtstartUtc` (TIMESTAMP, timed) — the API must expose this as a clean flag.
|
||||
|
||||
**Warning signs:** Birthday/holiday events appear one day early; affects users in UTC-N timezones (Americas).
|
||||
|
||||
### Pitfall 3: Missing VTIMEZONE Registration Causes DST-Shifted Occurrences
|
||||
|
||||
**What goes wrong:** A weekly meeting at 10:00 America/New_York produces occurrences at 10:00 UTC during EST (correct) and 10:00 UTC during EDT (one hour off — shows as 11:00 local time).
|
||||
|
||||
**Why it happens:** `ICAL.RecurExpansion` defers to `ICAL.TimezoneService` for timezone-aware ICAL.Time conversion. If the TZID is not registered, ical.js silently falls back to UTC, treating all occurrences as UTC regardless of DST.
|
||||
|
||||
**How to avoid:** Before calling `new ICAL.RecurExpansion(...)`, iterate `comp.getAllSubcomponents('vtimezone')` and call `ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component, tzid }))` for each one. Guard with `!ICAL.TimezoneService.has(tzid)` to avoid double-registration.
|
||||
|
||||
**Warning signs:** Recurring events across DST transitions display at the wrong wall-clock time by exactly ±1 hour.
|
||||
|
||||
### Pitfall 4: Schedule-X ISO String Events Silently Fail
|
||||
|
||||
**What goes wrong:** Events appear to not render, or Schedule-X throws a runtime error.
|
||||
|
||||
**Why it happens:** Schedule-X v4 dropped ISO string event format in v3. The old format was `{ start: "2024-01-15 09:00", end: "2024-01-15 10:00" }`. Passing these strings now produces a type error or silent failure.
|
||||
|
||||
**How to avoid:** All events passed to `eventsService.set()` must have `Temporal.ZonedDateTime` or `Temporal.PlainDate` for start/end. The `hydrateEvents()` function must run before `eventsService.set()`. Ensure `temporal-polyfill/global` is imported in `main.tsx` before any Schedule-X component mounts.
|
||||
|
||||
**Warning signs:** Calendar renders with no events even when the query returns data; console shows `Temporal is not defined`.
|
||||
|
||||
### Pitfall 5: Unwindowed `/api/events` Endpoint
|
||||
|
||||
**What goes wrong:** First page load fetches all 503+ events (as of the Phase 1 spike) plus all recurring occurrences expanded to "all time", causing the request to time out or return a 10 MB payload.
|
||||
|
||||
**Why it happens:** Phase 1 `/api/events` returns `db.select().from(calendarEvents)` — no window filter. This was fine as a proof-of-concept; it is unsuitable for the display layer.
|
||||
|
||||
**How to avoid:** The new `/api/events?start=YYYY-MM-DD&end=YYYY-MM-DD` endpoint filters `dtstartUtc BETWEEN start AND end` (for timed events) and `dtstartDate BETWEEN start AND end` (for all-day), then expands recurring masters within the window. The SQL filter is a pre-filter; `ICAL.RecurExpansion` does the precise window check. Non-recurring events can be filtered entirely in SQL.
|
||||
|
||||
**Warning signs:** Initial page load takes >3s; response payload >1 MB; memory usage spikes during expansion.
|
||||
|
||||
### Pitfall 6: `@schedule-x/react` Version Behind `@schedule-x/calendar`
|
||||
|
||||
**What goes wrong:** Potential API mismatch if `@schedule-x/react@4.1.0` does not expose new Schedule-X features added in `@schedule-x/calendar@4.4–4.6`.
|
||||
|
||||
**Why it happens:** The React adapter (`github.com/schedule-x/react`) has a separate release cadence from the core (`github.com/schedule-x/schedule-x`). React adapter was last published 2026-01-21; core was 2026-05-12.
|
||||
|
||||
**How to avoid:** The adapter peer-dep `^4.0.0` for `@schedule-x/calendar` is satisfied by 4.6.0 — the API contract is maintained. Limit usage to the API surface confirmed in docs: `useCalendarApp`, `ScheduleXCalendar`, `customComponents`. Test the integration in Wave 0 before building dependent components.
|
||||
|
||||
**Warning signs:** TypeScript errors on `useCalendarApp` options that are documented but not typed in `@schedule-x/react@4.1.0`.
|
||||
|
||||
### Pitfall 7: Dev-Auth Bypass Active in Production
|
||||
|
||||
**What goes wrong:** A `DEV_AUTH_BYPASS=true` env var accidentally set in production gives unauthenticated access to all `/api/*` routes.
|
||||
|
||||
**Why it happens:** Env vars leak into production containers via `.env` file copy mistakes or CI/CD misconfiguration.
|
||||
|
||||
**How to avoid:** The bypass middleware must have a hard `process.env.NODE_ENV === 'production'` guard as its FIRST check, before reading `DEV_AUTH_BYPASS`. The Docker Compose production configuration must NOT set `DEV_AUTH_BYPASS`. Document this in the env var table in `.env.example` with a warning comment.
|
||||
|
||||
**Warning signs:** `/api/me` returns a response without an Authelia session cookie in production.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### VTIMEZONE Registration + ICAL.RecurExpansion (complete pattern)
|
||||
|
||||
```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'
|
||||
|
||||
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)
|
||||
|
||||
// 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
|
||||
if (tzid && !ICAL.TimezoneService.has(tzid)) {
|
||||
ICAL.TimezoneService.register(
|
||||
tzid,
|
||||
new ICAL.Timezone({ component: vtz, tzid }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
results.push({
|
||||
dtstart: dtstart.toJSDate(),
|
||||
dtend: (dtend ?? dtstart).toJSDate(),
|
||||
allDay,
|
||||
})
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// RecurExpansion handles RRULE + RDATE + EXDATE internally
|
||||
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
|
||||
// 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 })
|
||||
}
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
### All-Day Event: Server Format to Schedule-X PlainDate
|
||||
|
||||
```typescript
|
||||
// Source: https://schedule-x.dev/docs/calendar/events
|
||||
// The server sends allDay events with start: 'YYYY-MM-DD' (from dtstartDate column)
|
||||
// The client must use Temporal.PlainDate — NOT ZonedDateTime
|
||||
|
||||
// WRONG (shifts date in negative-offset timezones):
|
||||
{ start: Temporal.ZonedDateTime.from('2026-06-15T00:00:00Z') }
|
||||
|
||||
// CORRECT:
|
||||
{ start: Temporal.PlainDate.from('2026-06-15') }
|
||||
```
|
||||
|
||||
### Schedule-X CSS Token Override Pattern
|
||||
|
||||
```css
|
||||
/* apps/pwa/src/styles/tokens.css */
|
||||
/* Source: https://schedule-x.dev — import theme-default, then override all --sx-color-* vars */
|
||||
|
||||
/* Import Schedule-X default layout CSS in main.tsx:
|
||||
import '@schedule-x/theme-default/dist/index.css'
|
||||
Import tokens.css after — these overrides take precedence */
|
||||
|
||||
:root {
|
||||
/* Map Schedule-X color vars to project tokens */
|
||||
--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);
|
||||
--sx-color-on-surface-variant: var(--color-text-secondary);
|
||||
--sx-color-outline: var(--color-border);
|
||||
--sx-color-neutral: var(--color-surface-dim);
|
||||
--sx-color-neutral-variant: var(--color-border-subtle);
|
||||
|
||||
/* Typography */
|
||||
--sx-font-family: var(--font-family-base);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend: `/api/events` Evolution
|
||||
|
||||
### Current state (Phase 1)
|
||||
|
||||
```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 })
|
||||
})
|
||||
```
|
||||
|
||||
### Target state (Phase 2)
|
||||
|
||||
```typescript
|
||||
// apps/api/src/routes/events.ts — evolved
|
||||
eventsRouter.get('/', async (c) => {
|
||||
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.
|
||||
|
||||
**Shared calendar identification:** The `calendars` table has `userId` but no explicit `isShared` flag. The shared-family calendar is identified by being the one that has its `displayName = 'Calendar'` (from CAL-08-DECISION.md) or, more robustly, by convention (the broker user is the broker account, not a household member). The Phase 2 plan should resolve this: either add a `isShared` boolean to `calendars`, or identify shared calendars by comparing `calendars.userId` to a designated broker user ID.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### 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) |
|
||||
|
||||
### 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 |
|
||||
|
||||
### 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 |
|
||||
|
||||
These fixture files can be generated from real Fastmail ICS exports or hand-crafted with known-correct VTIMEZONE blocks.
|
||||
|
||||
### Sampling Rate
|
||||
|
||||
- **Per task commit:** `pnpm --filter @familysync/api test` (API unit tests, <5s)
|
||||
- **Per wave merge:** `pnpm -r test` + `pnpm -r typecheck`
|
||||
- **Phase gate:** Full suite green + `tsc --noEmit` clean in both workspaces before `/gsd-verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
|
||||
- [ ] `apps/api/tests/broker/expand.test.ts` — covers CAL-07 recurrence + DST + EXDATE + all-day
|
||||
- [ ] `apps/api/tests/routes/events.test.ts` — covers CAL-02 windowed query + color join
|
||||
- [ ] `apps/pwa/vitest.config.ts` — Vitest not configured in PWA; needs `vitest` + `@testing-library/react` + `jsdom`
|
||||
- [ ] `apps/pwa/src/lib/hydrateEvents.test.ts` — covers Temporal hydration + all-day guard
|
||||
- [ ] `apps/pwa/src/lib/calendarConfig.test.ts` — covers `firstDayOfWeek` translation (0→7)
|
||||
- [ ] `apps/pwa/package.json` — add `"test": "vitest run"` script + `vitest`, `@testing-library/react`, `jsdom` devDependencies
|
||||
|
||||
---
|
||||
|
||||
## Security Domain
|
||||
|
||||
`security_enforcement: true`, `security_asvs_level: 1` per config.json.
|
||||
|
||||
### 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 | — |
|
||||
|
||||
### 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
|
||||
**Missing with no fallback:** None. Dev-auth bypass covers the Authelia deferral.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
**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.
|
||||
**A3 risk:** The Phase 1 spike showed Lucas's broker account has two calendars: "Calendar" and "USA Holidays". The shared-family calendar is the household-shared one. Since the per-member app-password model means each member's own calendars are fetched under their own credential, "shared" in the context of Phase 2 likely means a calendar explicitly shared at the Fastmail account level, not just a personal calendar. The plan should include a `checkpoint:human-verify` to confirm how to mark shared calendars, or default to: the calendar synced under the broker account is shared-family; calendars synced under member credentials are personal.
|
||||
**A4 note:** Schedule-X fires `onRangeUpdate` when the view changes (navigation). Initial mount may not fire it. The TanStack Query initial key should be set from Zustand's default `calendarRange` (today ± buffer), not depend on `onRangeUpdate` for the first fetch.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Identifying the shared-family calendar**
|
||||
- What we know: Phase 1 caches calendars under `calendars.userId`. Lucas's broker account has "Calendar" and "USA Holidays". The wife's personal calendar will be added.
|
||||
- What's unclear: Which calendar(s) are "shared-family" vs "personal"? Is it deterministic from displayName? From which user account synced it? The UI-SPEC assigns the rose `#F25C7A` to the shared-family calendar.
|
||||
- Recommendation: Add a `calendars.isShared` boolean column (default false). The operator marks the shared-family calendar during initial setup. Alternatively, treat "Calendar" (exact displayName match) from the broker account as shared — but this is fragile.
|
||||
|
||||
2. **`onRangeUpdate` initial mount behavior**
|
||||
- What we know: Schedule-X fires `onRangeUpdate` on navigation. Docs do not specify if it fires on mount.
|
||||
- What's unclear: Does the calendar fire `onRangeUpdate` immediately with the initial visible window, or only on user navigation?
|
||||
- Recommendation: Do not rely on `onRangeUpdate` for the initial fetch. Set Zustand `calendarRange` to a sensible default (e.g., current month ± 1 week) on store initialization; use that as the initial TanStack Query key.
|
||||
|
||||
3. **Recurring masters with `dtstartUtc` before the window**
|
||||
- What we know: A weekly meeting created 3 years ago has `dtstartUtc` from 3 years ago. The SQL pre-filter `WHERE dtstartUtc BETWEEN start AND end` will miss it entirely.
|
||||
- What's unclear: The right balance between SQL efficiency and correctness.
|
||||
- Recommendation: Add `hasRrule boolean` indexed column to `calendarEvents` (schema migration in Wave 0). Pre-filter: `WHERE (NOT hasRrule AND dtstartUtc BETWEEN start AND end) OR (hasRrule AND dtstartUtc < windowEnd)`. The expansion step then filters the precise window.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- [schedule-x.dev/docs/calendar/configuration](https://schedule-x.dev/docs/calendar/configuration) — firstDayOfWeek (Temporal: 7=Sunday), onRangeUpdate
|
||||
- [schedule-x.dev/blog/schedule-x-v3-temporal-api](https://schedule-x.dev/blog/schedule-x-v3-temporal-api) — breaking change from ISO strings to Temporal in v3
|
||||
- [github.com/kewisch/ical.js/wiki/Common-Use-Cases](https://github.com/kewisch/ical.js/wiki/Common-Use-Cases) — ICAL.RecurExpansion pattern, VTIMEZONE registration
|
||||
- [github.com/kewisch/ical.js/wiki/Parsing-iCalendar](https://github.com/kewisch/ical.js/wiki/Parsing-iCalendar) — ICAL.parse + ICAL.Component + ICAL.Event pipeline
|
||||
- Phase 1 codebase: `apps/api/src/broker/sync.ts`, `apps/api/src/db/schema.ts`, CAL-08-DECISION.md — confirmed Phase 1 foundation
|
||||
- `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
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- firstDayOfWeek translation: HIGH — verified in Schedule-X configuration docs
|
||||
- All-day event PlainDate requirement: HIGH — verified in Schedule-X events docs
|
||||
- VTIMEZONE registration for DST: MEDIUM — pattern documented in ical.js wiki; ICAL.js DST behavior not independently regression-tested
|
||||
- Shared calendar identification: LOW — depends on runtime data shape not fully inspected
|
||||
|
||||
**Research date:** 2026-06-04
|
||||
**Valid until:** 2026-09-04 (90 days — Schedule-X v4 is in active development; re-verify if minor versions change significantly before execution)
|
||||
@@ -0,0 +1,434 @@
|
||||
---
|
||||
phase: 2
|
||||
slug: calendar-display
|
||||
status: draft
|
||||
shadcn_initialized: false
|
||||
preset: none
|
||||
created: 2026-06-04
|
||||
---
|
||||
|
||||
# Phase 2 — UI Design Contract
|
||||
## Calendar Display
|
||||
|
||||
> Visual and interaction contract for Phase 2. Generated by gsd-ui-researcher.
|
||||
> Verified by gsd-ui-checker before execution begins.
|
||||
|
||||
---
|
||||
|
||||
## 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` |
|
||||
|
||||
**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
|
||||
design system primitive. shadcn may be added in Phase 3 when form components are needed.
|
||||
|
||||
---
|
||||
|
||||
## Token Layer
|
||||
|
||||
This is the load-bearing deliverable of Phase 2 (D-01, D-02). All component styles MUST be
|
||||
written against these tokens. No hard-coded hex values or px measurements in component files.
|
||||
|
||||
### File location
|
||||
|
||||
```
|
||||
apps/pwa/src/styles/tokens.css — CSS custom properties (the "clean" theme)
|
||||
apps/pwa/src/styles/tokens.ts — TypeScript token object (mirrors tokens.css)
|
||||
apps/pwa/src/styles/index.css — imports tokens.css; global resets; base body styles
|
||||
```
|
||||
|
||||
Import `tokens.css` once in `main.tsx`. Components import from `tokens.ts` for inline style
|
||||
props; they use `var(--token-name)` in CSS Modules or `className` strings.
|
||||
|
||||
### Calendar config constant
|
||||
|
||||
```ts
|
||||
// apps/pwa/src/lib/calendarConfig.ts
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Color Tokens
|
||||
|
||||
### 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 |
|
||||
|
||||
### 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 |
|
||||
|
||||
**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
|
||||
`--color-member-*` tokens are the canonical source; the Schedule-X config derives from them.
|
||||
The shared-family calendar always uses `#F25C7A` regardless of any user row.
|
||||
|
||||
### Color derivation rule for event chips
|
||||
|
||||
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 |
|
||||
|
||||
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 |
|
||||
| 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 |
|
||||
|
||||
**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 |
|
||||
|-------|-----|-------|
|
||||
| `--color-destructive` | `#DC2626` | Not used in Phase 2 (read-only). Token declared for Phase 3 reuse. |
|
||||
|
||||
---
|
||||
|
||||
## Spacing Scale
|
||||
|
||||
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 |
|
||||
|
||||
**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).
|
||||
- View toolbar height: 48px on phone; 56px on tablet/desktop.
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
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 |
|
||||
|
||||
**Weights declared:** 400 (regular) and 600 (semibold). No other weights permitted.
|
||||
|
||||
**Day-number size in month grid:** 13px label weight. Current day: semibold (600) + colored dot.
|
||||
|
||||
---
|
||||
|
||||
## Breakpoints
|
||||
|
||||
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 |
|
||||
|
||||
**View default logic (D-05):**
|
||||
```ts
|
||||
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'`).
|
||||
|
||||
---
|
||||
|
||||
## Calendar Rendering Library
|
||||
|
||||
**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.
|
||||
- `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
|
||||
is fully owned by this codebase and reusable as the Phase-3 edit surface (D-08).
|
||||
- React adapter ships as a first-class package; no wrapper hacks needed.
|
||||
- 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.
|
||||
- Custom grid: correct for simple month-only, but building reliable day/week/agenda from
|
||||
scratch in one phase introduces unacceptable schedule risk.
|
||||
|
||||
**Schedule-X CSS override strategy:**
|
||||
Import `@schedule-x/theme-default/dist/index.css`, then immediately override all
|
||||
`--sx-color-*` vars in `tokens.css` to match this spec's surface/border/text tokens.
|
||||
Result: Schedule-X internal layout engine works; all colors come from this spec's tokens.
|
||||
|
||||
---
|
||||
|
||||
## 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,
|
||||
semibold label. (Accent not used — active state uses a subtle surface tint.)
|
||||
- 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
|
||||
- Left 3px solid border: member `main` color (the `users.color` hex directly)
|
||||
- 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:
|
||||
- Color chip + title (heading, 18px/600)
|
||||
- Date/time line (label, 13px, secondary)
|
||||
- Location line, if present (label, 13px, secondary, with location icon)
|
||||
- Description block, if present (body, 15px, primary, max 4 lines before scroll)
|
||||
- Calendar name + owner color swatch (label, 13px, muted)
|
||||
- Close: X button top-right, 44px touch target; tapping backdrop dismisses
|
||||
- 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, 2–3 rows each, varying widths (60–90% of row)
|
||||
- Displayed when TanStack Query `isLoading` for initial fetch
|
||||
- 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)
|
||||
- Only shown when fetch succeeded AND zero events returned for the visible window
|
||||
|
||||
---
|
||||
|
||||
## View Layout Specification
|
||||
|
||||
### Month view (default: tablet/desktop)
|
||||
- 7-column grid, column headers: Sun–Sat (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
|
||||
- Up to 3 event chips per cell; if more: "+N more" label (13px, muted, tappable → day view)
|
||||
- All-day events: span full cell width as a chip, no time shown, `allDay: true` flag
|
||||
- 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
|
||||
- Hours displayed: 00:00–23:00 (full 24h); scroll to 08:00 on open
|
||||
- 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
|
||||
- No empty date rows; date headers only when events exist on that date
|
||||
|
||||
---
|
||||
|
||||
## 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" |
|
||||
|
||||
**Destructive actions in Phase 2:** None. Phase 2 is read-only.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
- Tap event chip: open EventDetailPopover
|
||||
- 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
|
||||
- Color legend swatches: `aria-label="{name}: {color hex}"`
|
||||
- Month grid cells: `role="gridcell"`, `aria-label="{date}"`
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
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 |
|
||||
|
||||
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 |
|
||||
|
||||
---
|
||||
|
||||
## Checker Sign-Off
|
||||
|
||||
- [x] Dimension 1 Copywriting: PASS
|
||||
- [x] Dimension 2 Visuals: FLAG (non-blocking)
|
||||
- [x] Dimension 3 Color: PASS
|
||||
- [x] Dimension 4 Typography: FLAG (non-blocking)
|
||||
- [x] Dimension 5 Spacing: PASS
|
||||
- [x] Dimension 6 Registry Safety: PASS
|
||||
|
||||
**Approval:** VERIFIED 2026-06-04 (gsd-ui-checker — 4 PASS / 2 non-blocking FLAG)
|
||||
|
||||
## Post-Verification Reviewer Notes (non-blocking — apply during implementation)
|
||||
|
||||
- **Visuals (apply):** Declare the **calendar grid (agenda list on phone) as the primary visual focal point**, ViewToolbar/AppNav as secondary chrome. Add `aria-label="{member name}"` + `title` to the phone-nav avatar/color swatch so the icon-only affordance has a text fallback.
|
||||
- **Typography (accepted as-is):** 13px label / 15px body are 2px apart; keeping 13px (separation carried by weight + context; 13px is also the EventBlock title size). If perceptual blur appears in implementation, drop labels to 12px (chip text, time meta, legend labels) without adding a 5th size.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
phase: 02
|
||||
slug: calendar-display
|
||||
status: ready
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: false
|
||||
created: 2026-06-04
|
||||
---
|
||||
|
||||
# Phase 02 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
|
||||
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`).
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run the task's quick run command (the `<automated>` in that task)
|
||||
- **After every plan wave:** Run `pnpm -r test`
|
||||
- **Before `/gsd-verify-work`:** `pnpm -r test` green + `tsc --noEmit` clean in both workspaces
|
||||
- **Max feedback latency:** 30 seconds
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
*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.
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
Wave 0 = Plan 01 Task 1, which creates the failing-but-present test stubs and fixtures the later waves turn green:
|
||||
|
||||
- [ ] `apps/api/tests/broker/expand.test.ts` — RED stub for `expandOccurrences` (CAL-07). Contract: weekly `America/New_York` RRULE across the March 2026 DST boundary keeps 10:00 local wall-clock on both sides; all-day birthday → `allDay:true` + `'YYYY-MM-DD'` start; EXDATE-excluded occurrence absent. Fails at import (`../../src/broker/expand.js` not yet built).
|
||||
- [ ] `apps/api/tests/routes/events.test.ts` — RED stub for windowed `/api/events` (CAL-02). Contract: occurrences carry `color` + `isShared`; bad/oversized params → 400.
|
||||
- [ ] `apps/pwa/src/lib/hydrateEvents.test.ts` — RED stub (CAL-07). Contract: all-day→`Temporal.PlainDate`; timed→`Temporal.ZonedDateTime`; **Schedule-X `calendarId` = `occ.isShared ? 'shared' : String(occ.ownerUserId)`** (shared occurrence → `'shared'`; personal occurrence → `String(ownerUserId)`).
|
||||
- [ ] `apps/pwa/src/lib/calendarConfig.test.ts` — RED stub (CAL-02). Contract: `WEEK_START_DAY=0` → Schedule-X `firstDayOfWeek=7`.
|
||||
- [ ] `apps/api/tests/fixtures/{weekly-dst,allday-birthday,exdate-series}.ics` — fixture corpus; `weekly-dst.ics` carries a full VTIMEZONE (STANDARD + DAYLIGHT) for the DST assertion.
|
||||
- [ ] `apps/api/tests/auth/devBypass.test.ts` — created and made green within Plan 01 Task 2 (not a cross-wave RED stub).
|
||||
- [ ] `apps/pwa/vitest.config.ts` + PWA test deps (vitest, @testing-library/react, @testing-library/jest-dom, jsdom) — installed in Plan 01 Task 1; without this the PWA RED stubs cannot run.
|
||||
- [ ] `apps/pwa/src/components/CalendarShell.test.tsx` and `apps/pwa/src/components/EventDetailPopover.test.tsx` — created in-wave by Plan 04/05 (not Wave 0 stubs; depend on components built in the same plan).
|
||||
|
||||
---
|
||||
|
||||
## 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 2–3: 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. |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references (expand, events, hydrateEvents, calendarConfig stubs + PWA harness)
|
||||
- [x] No watch-mode flags (`vitest run` / `pnpm test -- <file>`, never `--watch`)
|
||||
- [x] Feedback latency < 30s
|
||||
- [x] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** approved 2026-06-04
|
||||
Reference in New Issue
Block a user