docs(02): create phase plan

This commit is contained in:
Lucas Berger
2026-06-04 15:09:55 -04:00
parent b842955612
commit fc4cc2ccf8
7 changed files with 134 additions and 76 deletions
+11 -2
View File
@@ -58,13 +58,22 @@ 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 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 4. All-day events (birthdays, holidays) appear as full-day banners on the correct date with no timezone shift
**Plans**: 5 plans **Plans**: 5 plansPlans:
Plans: **Wave 1**
- [ ] 02-01-PLAN.md — Foundation: schema (hasRrule + calendars.isShared, pushed) + dev-auth bypass + PWA vitest/jsdom harness + ICS fixtures + RED test stubs - [ ] 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-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 - [ ] 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) - [ ] 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 - [ ] 02-05-PLAN.md — UX completion: read-only EventDetailPopover + ColorLegend + nav/toolbar + skeleton/empty/error states + human visual verification
**UI hint**: yes **UI hint**: yes
+2 -2
View File
@@ -4,7 +4,7 @@ milestone: v1.0
milestone_name: milestone milestone_name: milestone
status: executing status: executing
stopped_at: Phase 2 UI-SPEC approved stopped_at: Phase 2 UI-SPEC approved
last_updated: "2026-06-04T18:24:20.762Z" last_updated: "2026-06-04T19:09:54.699Z"
last_activity: 2026-06-04 -- Phase 01 execution started last_activity: 2026-06-04 -- Phase 01 execution started
progress: progress:
total_phases: 5 total_phases: 5
@@ -27,7 +27,7 @@ See: .planning/PROJECT.md (updated 2026-06-03)
Phase: 01 (foundation-broker-spike) — EXECUTING Phase: 01 (foundation-broker-spike) — EXECUTING
Plan: 1 of 4 Plan: 1 of 4
Status: Executing Phase 01 Status: Ready to execute
Last activity: 2026-06-04 -- Phase 01 execution started Last activity: 2026-06-04 -- Phase 01 execution started
Progress: [░░░░░░░░░░] 0% Progress: [░░░░░░░░░░] 0%
@@ -29,7 +29,7 @@ must_haves:
- "Drizzle schema has an indexed hasRrule boolean on calendar_events and an isShared boolean on calendars, both pushed to the live MariaDB" - "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" - "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" - "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)" - "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: artifacts:
- path: "apps/api/src/db/schema.ts" - path: "apps/api/src/db/schema.ts"
provides: "hasRrule + isShared columns + idx_calendar_events_has_rrule index" provides: "hasRrule + isShared columns + idx_calendar_events_has_rrule index"
@@ -65,7 +65,7 @@ Purpose: Every later plan (expansion engine, windowed route, hydration, calendar
these columns, the test harness, and the dev-auth bypass to exist first. This is the only 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. 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, Output: Migrated schema (pushed), PWA vitest harness, dev-auth bypass middleware, ICS fixtures,
RED test stubs. RED test stubs with concrete behavioral contracts.
</objective> </objective>
<execution_context> <execution_context>
@@ -93,15 +93,16 @@ RED test stubs.
- apps/pwa/package.json (current scripts + devDependencies block) - 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/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) - 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) - .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" - .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/vitest.config.ts" and §"apps/api/tests/broker/expand.test.ts"
</read_first> </read_first>
<behavior> <behavior>
- expand.test.ts: weekly America/New_York RRULE expanded across the March 2026 DST boundary returns occurrences whose wall-clock time stays 10:00 local on both sides of the transition (currently RED — expandOccurrences does not exist yet) - 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' and no time component - 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 - 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 (RED — route not evolved yet) - 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 Temporal.PlainDate; timed occurrence → Temporal.ZonedDateTime - 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 - calendarConfig.test.ts: WEEK_START_DAY=0 translates to Schedule-X firstDayOfWeek 7
</behavior> </behavior>
<action> <action>
@@ -111,12 +112,13 @@ RED test stubs.
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 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. Each test imports the not-yet-existing module (`../../src/broker/expand.js`, etc.) so the file fails to resolve — 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. 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> </action>
<verify> <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 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 /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 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> </verify>
<acceptance_criteria> <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 `hasRrule: boolean('has_rrule')` and `idx_calendar_events_has_rrule`
@@ -125,9 +127,11 @@ RED test stubs.
- apps/pwa/package.json scripts contains `"test": "vitest run"` and devDependencies include vitest, @testing-library/react, 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 - All three fixture .ics files parse via ICAL.parse without throwing
- weekly-dst.ics contains a VTIMEZONE block with both STANDARD and DAYLIGHT subcomponents - 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) - 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> </acceptance_criteria>
<done>Schema columns + index added; PWA vitest/jsdom harness installed and runnable; three ICS fixtures parse; four RED test stubs exist referencing not-yet-built modules.</done> <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>
<task type="auto" tdd="true"> <task type="auto" tdd="true">
@@ -220,7 +224,7 @@ RED test stubs.
- Schema columns added, pushed, and verified against the live DB - Schema columns added, pushed, and verified against the live DB
- PWA test runner operational - PWA test runner operational
- Dev-auth bypass green with production hard guard - Dev-auth bypass green with production hard guard
- ICS fixtures parse; RED stubs in place for later waves - ICS fixtures parse; RED stubs in place for later waves with concrete DST + calendarId-routing contracts
</success_criteria> </success_criteria>
<artifacts_produced> <artifacts_produced>
@@ -16,9 +16,9 @@ user_setup: []
must_haves: must_haves:
truths: 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" - "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" - "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" - "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)" - "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" - "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" - "Invalid or missing start/end query params are rejected (zod) before any SQL runs; window capped at 90 days"
artifacts: artifacts:
@@ -35,7 +35,7 @@ must_haves:
pattern: "expandOccurrences" pattern: "expandOccurrences"
- from: "apps/api/src/routes/events.ts" - from: "apps/api/src/routes/events.ts"
to: "users.color" to: "users.color"
via: "innerJoin calendars→users, select color + isShared" via: "innerJoin calendars→users, select color + isShared + users.id"
pattern: "users\\.color" pattern: "users\\.color"
--- ---
@@ -76,7 +76,7 @@ shared-family calendar (open question A3) — the operator marks it.
<files>apps/api/src/broker/expand.ts, apps/api/tests/broker/expand.test.ts</files> <files>apps/api/src/broker/expand.ts, apps/api/tests/broker/expand.test.ts</files>
<read_first> <read_first>
- apps/api/src/broker/sync.ts (ICAL.parse → Component → vevent pipeline lines 78115; allDay = dtstart.isDate; D-13 dtstartDate/dtstartUtc split) - apps/api/src/broker/sync.ts (ICAL.parse → Component → vevent pipeline lines 78115; allDay = dtstart.isDate; D-13 dtstartDate/dtstartUtc split)
- apps/api/tests/broker/expand.test.ts (the RED stub from Plan 01 — its expected assertions are the contract) - 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) - 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-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" - .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/api/src/broker/expand.ts" + §"D-13 allDay Discrimination"
@@ -87,9 +87,10 @@ shared-family calendar (open question A3) — the operator marks it.
- exdate-series.ics: the single EXDATE-excluded occurrence is absent from the returned array - 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 - 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 id is `${uid}::${startIso}` (stable identity)
- each occurrence carries ownerUserId and isShared (passed through from meta) so the client can route color
</behavior> </behavior>
<action> <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[]`. 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): 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). 1. `ICAL.parse(rawVevent)` wrapped in try/catch — on parse failure return `[]` (do not throw; match sync.ts skip-on-malformed behavior).
@@ -107,12 +108,13 @@ shared-family calendar (open question A3) — the operator marks it.
</verify> </verify>
<acceptance_criteria> <acceptance_criteria>
- apps/api/src/broker/expand.ts exports `expandOccurrences` and `CalendarOccurrence` - 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 - 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) - 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) - No `import ... 'rrule'` on the primary expansion path (rrule only in a guarded fallback branch, if any)
- Malformed rawVevent input returns [] without throwing - Malformed rawVevent input returns [] without throwing
</acceptance_criteria> </acceptance_criteria>
<done>expandOccurrences() produces DST-correct, all-day-safe, EXDATE-aware concrete occurrences; expand.test.ts green.</done> <done>expandOccurrences() produces DST-correct, all-day-safe, EXDATE-aware concrete occurrences carrying ownerUserId + isShared; expand.test.ts green.</done>
</task> </task>
<task type="auto" tdd="true"> <task type="auto" tdd="true">
@@ -130,7 +132,7 @@ shared-family calendar (open question A3) — the operator marks it.
<behavior> <behavior>
- GET /api/events?start=2026-06-01&end=2026-07-01 returns { occurrences: CalendarOccurrence[] }, each with a color field - 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) - 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 - 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 - 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 window wider than 90 days → 400 (DoS guard)
- a recurring master whose dtstartUtc predates the window still contributes in-window occurrences (hasRrule pre-filter) - a recurring master whose dtstartUtc predates the window still contributes in-window occurrences (hasRrule pre-filter)
@@ -140,27 +142,27 @@ shared-family calendar (open question A3) — the operator marks it.
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). 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 })`. 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 })`. 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, and 400 on bad params). 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> </action>
<verify> <verify>
<automated>cd apps/api && pnpm test -- tests/routes/events.test.ts</automated> <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> <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> </verify>
<acceptance_criteria> <acceptance_criteria>
- events.test.ts passes: color-field assertion, multi-calendar aggregation, 400-on-bad-params, isShared flag - 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 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 + calendars.isShared - events.ts inner-joins calendarEvents→calendars→users and selects users.color + users.id + calendars.isShared
- events.ts calls expandOccurrences per row and returns { occurrences } - events.ts calls expandOccurrences per row with ownerUserId: row.userId and returns { occurrences }
- No tsdav / createFastmailClient import in events.ts (broker-boundary invariant preserved) - No tsdav / createFastmailClient import in events.ts (broker-boundary invariant preserved)
</acceptance_criteria> </acceptance_criteria>
<done>/api/events is windowed, validated (zod + 90-day cap), joined for color/isShared, and expands recurrences; events.test.ts green.</done> <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>
<task type="checkpoint:human-verify" gate="blocking"> <task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: [CHECKPOINT] Resolve & mark the shared-family calendar (A3)</name> <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.</action> <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> <what-built>
Plan 01 added `calendars.isShared` (default false). The route colors any calendar with 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 isShared=true rose (#F25C7A) and tags its occurrences isShared:true; all other calendars use
@@ -176,7 +178,7 @@ shared-family calendar (open question A3) — the operator marks it.
4. Re-run step 1 and confirm exactly the intended row(s) show is_shared=1. 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". 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> </how-to-verify>
<resume-signal>Type "approved" with the chosen calendar id(s), or describe a different shared-calendar rule to encode.</resume-signal> <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> </task>
</tasks> </tasks>
@@ -202,11 +204,12 @@ shared-family calendar (open question A3) — the operator marks it.
<verification> <verification>
- `pnpm --filter @familysync/api test` green (expand + events tests) - `pnpm --filter @familysync/api test` green (expand + events tests)
- `tsc --noEmit` clean in apps/api - `tsc --noEmit` clean in apps/api
- Manual dev curl returns windowed occurrences with color + isShared (checkpoint) - Manual dev curl returns windowed occurrences with color + isShared + ownerUserId (checkpoint)
</verification> </verification>
<success_criteria> <success_criteria>
- /api/events returns windowed, color-tagged, DST-correct, all-day-safe, EXDATE-aware occurrences - /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 - Bad/oversized windows rejected with 400
- Shared-family calendar marked and verified end-to-end - Shared-family calendar marked and verified end-to-end
</success_criteria> </success_criteria>
@@ -24,11 +24,12 @@ user_setup: []
must_haves: must_haves:
truths: 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" - "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" - "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" - "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'" - "calendarConfig translates WEEK_START_DAY=0 (Sunday/JS) to Schedule-X firstDayOfWeek=7 (Temporal Sunday) and builds the per-calendar config keyed by String(userId) + 'shared'"
- "hydrateEvents converts all-day occurrences to Temporal.PlainDate and timed occurrences to Temporal.ZonedDateTime" - "hydrateEvents converts all-day occurrences to Temporal.PlainDate and timed occurrences to Temporal.ZonedDateTime"
- "hydrateEvents routes each event's Schedule-X calendarId to 'shared' (isShared) or String(ownerUserId), matching the userId-keyed calendars config — never String(calendarId)"
- "calendarStore (Zustand) holds selectedView (persisted per breakpoint group), selectedDate, openEventId, calendarRange — no server data" - "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" - "fetchEvents(start,end) calls the windowed /api/events with credentials:include and returns OccurrencesResponse"
artifacts: artifacts:
@@ -39,7 +40,7 @@ must_haves:
provides: "WEEK_START_DAY, firstDayOfWeek translation, buildCalendarConfig()" provides: "WEEK_START_DAY, firstDayOfWeek translation, buildCalendarConfig()"
exports: ["WEEK_START_DAY", "buildCalendarConfig"] exports: ["WEEK_START_DAY", "buildCalendarConfig"]
- path: "apps/pwa/src/lib/hydrateEvents.ts" - path: "apps/pwa/src/lib/hydrateEvents.ts"
provides: "hydrateEvents() ISO→Temporal with all-day PlainDate guard" provides: "hydrateEvents() ISO→Temporal with all-day PlainDate guard + isShared/ownerUserId calendarId routing"
exports: ["hydrateEvents"] exports: ["hydrateEvents"]
- path: "apps/pwa/src/store/calendarStore.ts" - path: "apps/pwa/src/store/calendarStore.ts"
provides: "Zustand UI-state store with localStorage view persistence" provides: "Zustand UI-state store with localStorage view persistence"
@@ -53,6 +54,10 @@ must_haves:
to: "apps/pwa/src/lib/colorUtils.ts" to: "apps/pwa/src/lib/colorUtils.ts"
via: "deriveScheduleXColors() for lightColors" via: "deriveScheduleXColors() for lightColors"
pattern: "deriveScheduleXColors" 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> <objective>
@@ -137,7 +142,7 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
<action> <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/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). 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). 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> </action>
<verify> <verify>
<automated>cd apps/pwa && pnpm test -- src/lib/colorUtils.test.ts src/lib/calendarConfig.test.ts</automated> <automated>cd apps/pwa && pnpm test -- src/lib/colorUtils.test.ts src/lib/calendarConfig.test.ts</automated>
@@ -153,12 +158,13 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
</task> </task>
<task type="auto" tdd="true"> <task type="auto" tdd="true">
<name>Task 3: hydrateEvents (Temporal, all-day guard) + Zustand store + windowed fetchEvents</name> <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> <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> <read_first>
- apps/pwa/src/lib/hydrateEvents.test.ts (RED stub from Plan 01 — PlainDate vs ZonedDateTime contract) - 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/api/client.ts (existing fetchMe pattern + the OLD unwindowed fetchEvents/EventsResponse to replace)
- apps/api/src/broker/expand.ts CalendarOccurrence shape (if Plan 02 merged first) OR .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 1" interface (the server JSON contract) - 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-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-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)" - .planning/phases/02-calendar-display/02-UI-SPEC.md §"State Management Contract" + §"View default logic (D-05)"
@@ -166,29 +172,36 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
<behavior> <behavior>
- hydrateEvents: occurrence with allDay:true and start '2026-06-15' → start is Temporal.PlainDate (NOT ZonedDateTime — guards Pitfall 2) - 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: timed occurrence → start/end Temporal.ZonedDateTime from the offset-aware ISO string
- hydrateEvents: passes uid/color/isShared through on a _familySync field; calendarId stringified - 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') - calendarStore: setSelectedView persists to localStorage keyed by breakpoint group ('phone' | 'tablet-desktop')
</behavior> </behavior>
<action> <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)`. Stringify calendarId. 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. 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. 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), `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). 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> </action>
<verify> <verify>
<automated>cd apps/pwa && pnpm test -- src/lib/hydrateEvents.test.ts</automated> <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 "credentials: 'include'" src/api/client.ts && grep -q "calendarRange" src/store/calendarStore.ts && echo SLICE_LIB_OK</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> <automated>cd apps/pwa && pnpm exec tsc --noEmit</automated>
</verify> </verify>
<acceptance_criteria> <acceptance_criteria>
- hydrateEvents.test.ts (Plan 01 RED stub) passes: all-day→PlainDate, timed→ZonedDateTime - 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 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 - 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 - 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 - tsc --noEmit clean in apps/pwa
</acceptance_criteria> </acceptance_criteria>
<done>hydrateEvents (with all-day PlainDate guard), Zustand UI store, and windowed fetchEvents all built; hydrateEvents.test.ts green; PWA typechecks.</done> <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> </task>
</tasks> </tasks>
@@ -220,6 +233,7 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
- Clean-theme token layer + Schedule-X overrides present (D-01/D-02) - Clean-theme token layer + Schedule-X overrides present (D-01/D-02)
- firstDayOfWeek 0→7 translation encoded; per-calendar config built from member colors - firstDayOfWeek 0→7 translation encoded; per-calendar config built from member colors
- All-day Temporal PlainDate guard in place - 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 - Zustand UI store + windowed fetchEvents ready for Plan 04
</success_criteria> </success_criteria>
@@ -230,7 +244,7 @@ windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
- token object export (tokens.ts); styles/index.css global reset - token object export (tokens.ts); styles/index.css global reset
- `hexToContainer`, `hexToOnContainer`, `deriveScheduleXColors` (colorUtils.ts) - `hexToContainer`, `hexToOnContainer`, `deriveScheduleXColors` (colorUtils.ts)
- `WEEK_START_DAY`, `SX_FIRST_DAY_OF_WEEK`, `buildCalendarConfig`, `MemberCalendarConfig` (calendarConfig.ts) - `WEEK_START_DAY`, `SX_FIRST_DAY_OF_WEEK`, `buildCalendarConfig`, `MemberCalendarConfig` (calendarConfig.ts)
- `hydrateEvents`, `ScheduleXEvent` (hydrateEvents.ts) - `hydrateEvents`, `ScheduleXEvent` (hydrateEvents.ts) — calendarId routed by isShared/ownerUserId
- `useCalendarStore` Zustand store + CalendarStore state shape (calendarStore.ts) - `useCalendarStore` Zustand store + CalendarStore state shape (calendarStore.ts)
- `fetchEvents(start,end)`, `CalendarOccurrence`, `OccurrencesResponse` (client.ts — replaces old unwindowed versions) - `fetchEvents(start,end)`, `CalendarOccurrence`, `OccurrencesResponse` (client.ts — replaces old unwindowed versions)
- @schedule-x/* + temporal-polyfill + lucide-react + zustand dependencies - @schedule-x/* + temporal-polyfill + lucide-react + zustand dependencies
@@ -15,8 +15,8 @@ user_setup: []
must_haves: must_haves:
truths: truths:
- "Opening the app renders a Schedule-X calendar populated with REAL windowed Fastmail occurrences fetched from /api/events" - "Opening the app renders a Schedule-X calendar populated with REAL windowed Fastmail occurrences fetched from /api/events"
- "Events render in their owner's member color; shared-family events render in the reserved rose color" - "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" - "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" - "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" - "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)" - "Default view is agenda on phone and month on tablet/desktop (D-05)"
@@ -78,15 +78,15 @@ Output: CalendarShell.tsx wired to the full data pipeline; App.tsx renders it; a
<read_first> <read_first>
- apps/pwa/src/App.tsx (current root: MemberBadge + EventProof + meQuery useQuery pattern lines 5892; this becomes the CalendarShell host) - apps/pwa/src/App.tsx (current root: MemberBadge + EventProof + meQuery useQuery pattern lines 5892; this becomes the CalendarShell host)
- apps/pwa/src/api/client.ts (fetchEvents(start,end), fetchMe — from Plan 03) - apps/pwa/src/api/client.ts (fetchEvents(start,end), fetchMe — from Plan 03)
- apps/pwa/src/lib/hydrateEvents.ts (hydrateEvents signature — 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, SX_FIRST_DAY_OF_WEEK, view factories — 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) - 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-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-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" - .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/components/CalendarShell.tsx"
</read_first> </read_first>
<action> <action>
Create `apps/pwa/src/components/CalendarShell.tsx`. Read the current user via `useQuery(['me'], fetchMe)` to source member colors; build the Schedule-X calendars config with `buildCalendarConfig(members)` where members come from /api/me (current user) — for Phase 2 the per-calendar color comes from users.color via the API occurrences, so the calendars config keys must match the `calendarId` (String(userId) or 'shared') that hydrateEvents stamps on each event. 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}) } })`. 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}) } })`.
@@ -104,12 +104,13 @@ Output: CalendarShell.tsx wired to the full data pipeline; App.tsx renders it; a
<acceptance_criteria> <acceptance_criteria>
- CalendarShell.tsx mounts ScheduleXCalendar with all four view factories (day/week/month-grid/month-agenda) - 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 - 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) - 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 - 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 - 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 - No hard-coded hex/px in CalendarShell.tsx (token vars only); tsc --noEmit clean
</acceptance_criteria> </acceptance_criteria>
<done>Schedule-X renders real windowed Fastmail occurrences (color-coded, all four views switchable) as the app root; PWA typechecks.</done> <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>
<task type="auto" tdd="true"> <task type="auto" tdd="true">
@@ -1,8 +1,8 @@
--- ---
phase: 02 phase: 02
slug: calendar-display slug: calendar-display
status: draft status: ready
nyquist_compliant: false nyquist_compliant: true
wave_0_complete: false wave_0_complete: false
created: 2026-06-04 created: 2026-06-04
--- ---
@@ -18,19 +18,24 @@ created: 2026-06-04
| Property | Value | | Property | Value |
|----------|-------| |----------|-------|
| **Framework** | vitest | | **Framework** | vitest |
| **Config file** | {path or "none — Wave 0 installs"} | | **Config (API)** | `apps/api/vitest.config.ts` (environment: node — exists, Phase 1) |
| **Quick run command** | `{quick command}` | | **Config (PWA)** | `apps/pwa/vitest.config.ts` (environment: jsdom — created in Plan 01 Task 1) |
| **Full suite command** | `{full command}` | | **Quick run command (API)** | `cd apps/api && pnpm test -- <test-file>` |
| **Estimated runtime** | ~{N} seconds | | **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 ## Sampling Rate
- **After every task commit:** Run `{quick run command}` - **After every task commit:** Run the task's quick run command (the `<automated>` in that task)
- **After every plan wave:** Run `{full suite command}` - **After every plan wave:** Run `pnpm -r test`
- **Before `/gsd-verify-work`:** Full suite must be green - **Before `/gsd-verify-work`:** `pnpm -r test` green + `tsc --noEmit` clean in both workspaces
- **Max feedback latency:** {N} seconds - **Max feedback latency:** 30 seconds
--- ---
@@ -38,19 +43,39 @@ created: 2026-06-04
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | | Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| |---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| {N}-01-01 | 01 | 1 | REQ-{XX} | T-{N}-01 / — | {expected secure behavior or "N/A"} | unit | `{command}` | ✅ / ❌ W0 | ⬜ pending | | 02-01-01 | 01 | 1 | CAL-07 | T-02-SC | schema + RED stubs + fixtures (no behavior yet) | unit/grep | `grep -q has_rrule + ICAL.parse fixtures` (3 grep/node checks) | ✅ creates RED stubs | ⬜ pending |
| 02-01-02 | 01 | 1 | CAL-02 | T-02-01 | dev-bypass hard-disabled when NODE_ENV=production | unit | `cd apps/api && pnpm test -- tests/auth/devBypass.test.ts` | ❌ W0 (this task creates it) | ⬜ pending |
| 02-01-03 | 01 | 1 | CAL-07 | T-02-02 | live MariaDB has has_rrule + is_shared (no false-green) | integration | `node SHOW COLUMNS calendar_events/calendars` | N/A (DB assertion) | ⬜ pending |
| 02-02-01 | 02 | 2 | CAL-07 | T-02b-04 | DST wall-clock preserved; all-day no shift; EXDATE excluded | unit | `cd apps/api && pnpm test -- tests/broker/expand.test.ts` | ✅ RED stub from 02-01 | ⬜ pending |
| 02-02-02 | 02 | 2 | CAL-02, CAL-07 | T-02b-01/02 | start/end zod-validated before SQL; 90-day cap; color+isShared join | unit | `cd apps/api && pnpm test -- tests/routes/events.test.ts` | ✅ RED stub from 02-01 | ⬜ pending |
| 02-02-03 | 02 | 2 | CAL-02 | — | operator marks shared calendar (is_shared=1) | manual | human-verify checkpoint (see Manual-Only) | N/A | ⬜ pending |
| 02-03-01 | 03 | 2 | CAL-02 | T-02c-SC | token layer + Schedule-X var overrides; Temporal-first import | grep/node | `grep --color-shared-family + node require deps` | N/A (style/deps) | ⬜ pending |
| 02-03-02 | 03 | 2 | CAL-02 | — | firstDayOfWeek 0→7; per-member + 'shared' config | unit | `cd apps/pwa && pnpm test -- src/lib/colorUtils.test.ts src/lib/calendarConfig.test.ts` | ✅ RED stub (calendarConfig) from 02-01 | ⬜ pending |
| 02-03-03 | 03 | 2 | CAL-07 | T-02c-02 | all-day→PlainDate guard; calendarId routed by isShared/ownerUserId | unit | `cd apps/pwa && pnpm test -- src/lib/hydrateEvents.test.ts` | ✅ RED stub from 02-01 | ⬜ pending |
| 02-04-01 | 04 | 3 | CAL-02, CAL-03 | T-02d-01 | Schedule-X renders real windowed occurrences; 4 views; token-only | grep/type | `grep ScheduleXCalendar/hydrateEvents + tsc --noEmit` | N/A (wired in 04) | ⬜ pending |
| 02-04-02 | 04 | 3 | CAL-03 | T-02d-01 | render smoke: 4 views + timed+all-day through hydrate→eventsService | unit | `cd apps/pwa && pnpm test -- src/components/CalendarShell.test.tsx` | ❌ W0 (this task creates it) | ⬜ pending |
| 02-05-01 | 05 | 4 | CAL-03 | T-02e-01 | popover renders fields as text (no dangerouslySetInnerHTML); Escape closes | unit | `cd apps/pwa && pnpm test -- src/components/EventDetailPopover.test.tsx` | ❌ W0 (this task creates it) | ⬜ pending |
| 02-05-02 | 05 | 4 | CAL-02, CAL-03 | T-02e-02 | legend/nav/toolbar + skeleton/empty/error; EventProof removed | grep/type | `grep SkeletonCalendar/EmptyState + tsc --noEmit` | N/A (wired in 05) | ⬜ pending |
| 02-05-03 | 05 | 4 | CAL-02, CAL-03, CAL-07 | — | visual + functional verification of 4 success criteria | manual | human-verify checkpoint (see Manual-Only) | N/A | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* *Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
Tasks without a unit-test `<automated>` (02-01-01 grep/fixture, 02-01-03 DB, 02-03-01 grep/deps, 02-04-01 grep/type, 02-05-02 grep/type) each carry an automated grep/node/tsc check, and none of them appear in 3-consecutive sequence without a unit test between them: the expand/events/colorUtils/hydrateEvents/popover/smoke unit tests interleave every wave.
--- ---
## Wave 0 Requirements ## Wave 0 Requirements
- [ ] `{tests/test_file.py}` — stubs for REQ-{XX} Wave 0 = Plan 01 Task 1, which creates the failing-but-present test stubs and fixtures the later waves turn green:
- [ ] `{tests/conftest.py}` — shared fixtures
- [ ] `{framework install}` — if no framework detected
*If none: "Existing infrastructure covers all phase requirements."* - [ ] `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).
--- ---
@@ -58,19 +83,21 @@ created: 2026-06-04
| Behavior | Requirement | Why Manual | Test Instructions | | Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------| |----------|-------------|------------|-------------------|
| {behavior} | REQ-{XX} | {reason} | {steps} | | 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. |
*If none: "All phase behaviors have automated verification."* | All-day event renders as a full-day banner with no date shift | CAL-07 | PlainDate guard is unit-tested, but the actual Schedule-X all-day banner placement (correct date, no off-by-one) is a render-path visual check. | Plan 05 Task 3 step 5: find an all-day event (birthday/holiday), confirm it appears as a full-day banner on the correct date, not a day early/late. |
| Color-coded ownership legible at a glance + legend decode | CAL-02 | "Reads at a glance" is a subjective slick-constraint judgment. | Plan 05 Task 3 steps 23: confirm each member's events render in their color, shared in rose, legend decodes ownership. |
| Phone bottom-sheet popover + agenda default view | CAL-03 | Responsive breakpoint behavior (D-05) needs a real phone-width render. | Plan 05 Task 3 step 7: resize to phone width, confirm default view is Agenda and popover is a bottom sheet. |
--- ---
## Validation Sign-Off ## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies - [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify - [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references - [x] Wave 0 covers all MISSING references (expand, events, hydrateEvents, calendarConfig stubs + PWA harness)
- [ ] No watch-mode flags - [x] No watch-mode flags (`vitest run` / `pnpm test -- <file>`, never `--watch`)
- [ ] Feedback latency < {N}s - [x] Feedback latency < 30s
- [ ] `nyquist_compliant: true` set in frontmatter - [x] `nyquist_compliant: true` set in frontmatter
**Approval:** {pending / approved YYYY-MM-DD} **Approval:** approved 2026-06-04