Files
familysync/.planning/milestones/v1.0-phases/02-calendar-display/02-01-PLAN.md
T

19 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, user_setup, must_haves
phase plan type wave depends_on files_modified autonomous requirements user_setup must_haves
02-calendar-display 01 execute 1
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
true
CAL-02
CAL-03
CAL-07
truths artifacts key_links
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
path provides contains
apps/api/src/db/schema.ts hasRrule + isShared columns + idx_calendar_events_has_rrule index has_rrule
path provides exports
apps/api/src/auth/devBypass.ts devAuthBypass() middleware with hard production guard
devAuthBypass
path provides contains
apps/pwa/vitest.config.ts jsdom-environment vitest config for PWA jsdom
path provides min_lines
apps/api/tests/fixtures/weekly-dst.ics DST-spanning weekly RRULE fixture for CAL-07 tests 10
from to via pattern
apps/api/src/index.ts apps/api/src/auth/devBypass.ts app.use('/api/*', devAuthBypass()) before oidcAuthMiddleware devAuthBypass
from to via pattern
apps/pwa/package.json vitest test script + devDependencies "test".*vitest
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 Task 1: Add schema columns + PWA test harness + ICS fixtures + RED test stubs 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 - 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" - 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 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.
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 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 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')" 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 - 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) 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. Task 2: Dev-auth bypass middleware + production guard + env docs apps/api/src/auth/devBypass.ts, apps/api/src/index.ts, apps/api/tests/auth/devBypass.test.ts, .env.example, docs/deployment.md - 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) - 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 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.
cd apps/api && pnpm test -- tests/auth/devBypass.test.ts cd apps/api && grep -q "NODE_ENV === 'production'" src/auth/devBypass.ts && grep -q "devAuthBypass()" src/index.ts && echo BYPASS_WIRED cd /home/luc/Projects/familysync && grep -q "DEV_AUTH_BYPASS" .env.example && echo ENV_DOCUMENTED - 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 devAuthBypass middleware exists with production hard guard, is mounted before oidcAuthMiddleware, all three behavior tests pass, env + deployment docs updated. Task 3: [BLOCKING] Push Drizzle schema to live MariaDB apps/api (drizzle-kit push — no source file) - 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) 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. 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()})()" - `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 has_rrule (+ index) and calendars.is_shared exist in the live MariaDB schema; existing event cache intact.

<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>
- `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

<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>
Create `.planning/phases/02-calendar-display/02-01-SUMMARY.md` when done