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