Commit Graph
326 Commits
Author SHA1 Message Date
Lucas Berger 3eebfbff42 feat(02-05): EventDetailPopover read-only popover, XSS-safe, wired into CalendarShell
- EventDetailPopover: reads openEventId from Zustand, resolves occurrence from TanStack Query cache
- Dual-mode: standalone (Zustand-driven) + customComponents.eventModal (Schedule-X)
- Plain-text JSX children for all event fields (T-02e-01 XSS guard)
- Focus trap, Escape to close, backdrop-click to close, aria-label=Close (44px target)
- Phone: bottom sheet layout; tablet/desktop: centered popover (max-width 360px)
- Phase-3 footer action area reserved with comment
- CalendarShell: passes customComponents.eventModal=EventDetailPopover to ScheduleXCalendar
- test-setup.ts: import @testing-library/jest-dom for toHaveTextContent matcher
- All 36 tests pass, tsc clean
2026-06-05 10:51:57 -04:00
Lucas Berger 433fb9f900 test(02-05): add failing tests for EventDetailPopover (TDD RED)
- renders title/location/description/calendar-name as plain text
- Escape key and backdrop click close the popover via setOpenEventId(null)
- close button aria-label=Close assertion
- XSS guard: HTML-looking title and description render as escaped text not DOM elements
- renders null when openEventId is null
2026-06-05 10:47:41 -04:00
Lucas Berger f0af43c4e6 test(02-04): CalendarShell CAL-03 render smoke — Temporal hydration + Pitfall 6 guard
- CalendarShell.test.tsx: 6 tests covering render-without-throw, ScheduleXCalendar mount,
  hydrateEvents called with both timed + all-day occurrences, Temporal.PlainDate guard
  (Pitfall 4/all-day date shift), Temporal.ZonedDateTime for timed (Pitfall 4)
- test-setup.ts: window.matchMedia polyfill for jsdom (calendarStore initialises at module load)
- vitest.config.ts: adds setupFiles pointing to test-setup.ts
2026-06-05 10:42:30 -04:00
Lucas Berger b79f649ac5 feat(02-04): mount Schedule-X CalendarShell wired to TanStack Query + Zustand + hydrateEvents
- CalendarShell.tsx: all four views (day/week/month-grid/month-agenda), eventsService + eventModal plugins
- calendarId routing: 'shared' | String(ownerUserId) via hydrateEvents, matching buildCalendarConfig keys
- SX_FIRST_DAY_OF_WEEK=7 (Sunday, Temporal convention); initial range from Zustand default (A4 guard)
- onRangeUpdate updates Zustand range, triggering TanStack Query refetch on navigation
- App.tsx: replaces EventProof landing with CalendarShell; tokens only (no hardcoded hex/px)
2026-06-05 10:40:07 -04:00
Lucas Berger 9ee26c07a7 feat(02-02): evolve /api/events to windowed endpoint with color/owner join
- zValidator enforces YYYY-MM-DD regex on start/end (T-02b-01)
- 90-day window cap prevents DoS (T-02b-02)
- innerJoin calendarEvents→calendars→users for color + isShared + ownerUserId
- SQL pre-filter includes hasRrule=true rows regardless of dtstartUtc range
- expandOccurrences() called per row; shared calendar uses #F25C7A rose color
- events.test.ts: added @hono/oidc-auth mock; 4/4 assertions green
2026-06-05 10:30:36 -04:00
Lucas Berger 6736194a4a feat(02-02): add expandOccurrences() with VTIMEZONE + allDay split + EXDATE
- ICAL.TimezoneService.register() runs before RecurExpansion (DST correctness)
- All-day events serialized as YYYY-MM-DD strings (no time shift)
- EXDATE exclusions handled internally by ICAL.RecurExpansion
- Malformed rawVevent returns [] without throwing
- expand.test.ts DST, all-day, EXDATE assertions green (3/3)
2026-06-05 10:30:22 -04:00
Lucas Berger f377d7c3f8 feat(02-03): hydrateEvents + calendarStore + windowed fetchEvents; RED stubs green
- Create hydrateEvents.ts: Temporal.PlainDate for allDay, ZonedDateTime for timed
  calendarId routes via isShared ? 'shared' : String(ownerUserId) — NOT String(calendarId)
  _familySync carries uid/color/isShared through to popover
- Update hydrateEvents.test.ts: add temporal-polyfill/global import; all 4 RED stubs now GREEN
- Create calendarStore.ts: Zustand store with selectedView (localStorage per breakpoint group),
  selectedDate, openEventId, calendarRange; D-05 view defaults; calendarRange ± buffer for
  initial TanStack Query key without depending on onRangeUpdate firing on mount
- Update client.ts: add CalendarOccurrence/OccurrencesResponse, windowed fetchEvents(start,end)
  with credentials:include; keep legacy CalendarEvent/EventsResponse + fetchEventsLegacy
  as deprecated for EventProof.tsx (removed in Plan 05)
- Update EventProof.tsx: switch to fetchEventsLegacy to keep build clean until Plan 05
- tsc --noEmit clean; all 18 PWA tests pass
2026-06-05 09:47:07 -04:00
Lucas Berger 43554f491b feat(02-03): colorUtils + calendarConfig; turn RED calendarConfig stubs green
- Create colorUtils.ts: hexToContainer (15% alpha over white), hexToOnContainer
  (darken 40%), deriveScheduleXColors() returning { main, container, onContainer }
- Create colorUtils.test.ts: hex blend math assertions for #4A90D9 and #F25C7A
- Create calendarConfig.ts: WEEK_START_DAY=0, SX_FIRST_DAY_OF_WEEK=7 (0→7 translation)
  buildCalendarConfig() keyed by String(userId) + 'shared'; returns { firstDayOfWeek, calendars }
- calendarConfig.test.ts (Plan 01 RED stubs) now GREEN: all 4 assertions pass
2026-06-05 09:44:19 -04:00
Lucas Berger 0911a2330a feat(02-03): install Schedule-X stack + CSS token layer + main.tsx imports
- Install @schedule-x/{calendar,react,theme-default,event-modal,events-service}@4.x
- Install temporal-polyfill@0.3.2 and lucide-react@1.17.0
- Create src/styles/tokens.css: all color/spacing/typography/breakpoint tokens
  plus --sx-color-* Schedule-X overrides mapped to project tokens
- Create src/styles/tokens.ts: TypeScript mirror of token values for inline styles
- Create src/styles/index.css: imports tokens.css + minimal global reset
- Update main.tsx: Temporal polyfill first, then SX theme CSS, then styles/index.css
2026-06-05 09:42:34 -04:00
Lucas Berger 8bd44b33c7 feat(02-01): dev-auth bypass middleware with production hard guard
- Create apps/api/src/auth/devBypass.ts: devAuthBypass() middleware with
  NODE_ENV=production hard guard as first conditional (T-02-01 mitigation)
- Exports DEV_USER const (id:1, color:COLOR_PALETTE[0]) for test reference
- Mount devAuthBypass() before oidcAuthMiddleware on /api/* in index.ts
- Add devBypass.test.ts: all three behavioral cases pass (production guard,
  unset-flag passthrough, active-injection)
- Add DEV_AUTH_BYPASS to .env.example with production warning comment
- Extend docs/deployment.md with dev-auth bypass section and production prohibition
2026-06-05 09:32:00 -04:00
Lucas Berger 75252eb08c feat(02-01): schema columns, PWA vitest harness, ICS fixtures, RED test stubs
- Add calendarEvents.hasRrule boolean + idx_calendar_events_has_rrule index (Phase 2 pre-filter)
- Add calendars.isShared boolean for shared-family calendar identification
- Create apps/pwa/vitest.config.ts with jsdom environment
- Add vitest, @testing-library/react, jsdom, @testing-library/jest-dom to PWA devDependencies
- Add "test": "vitest run" script to apps/pwa/package.json
- Create three ICS fixtures: weekly-dst.ics (DST spanning), allday-birthday.ics, exdate-series.ics
- Create RED test stub expand.test.ts with concrete DST wall-clock assertions (10:00 local both sides of March 2026 boundary)
- Create RED test stub events.test.ts with 400 validation and color/isShared field contracts
- Create RED test stub hydrateEvents.test.ts with Temporal type and calendarId routing contracts (shared→"shared", personal→String(ownerUserId))
- Create RED test stub calendarConfig.test.ts with firstDayOfWeek 0→7 translation contract
2026-06-05 09:29:45 -04:00
Lucas Berger 48f90ceca9 feat(01-04): wire broker + routes into bootstrap, add SSE endpoint, EventProof
- Mount /api/events, /api/sse in index.ts behind oidcAuthMiddleware; /callback + /health before guard
- Call startBrokerPoller() on boot (5-min ctag-poll background schedule)
- Add sseRouter with GET /heartbeat (streamSSE, 10s interval) for Pangolin SSE smoke test (D-08, T-04-01)
- Add CAL-08 spike script (broker/spike.ts): createFastmailClient → fetchCalendars → print calendar URLs
- Add fetchEvents() to pwa/api/client.ts with typed CalendarEvent/EventsResponse shapes
- Add EventProof.tsx: React Query ['events'], renders first event title+date or empty-state (CAL-01 broker proof)
- Update App.tsx to render MemberBadge + EventProof on landing page
- Add ical.js@2.2.1 to PWA dependencies for VEVENT summary parsing in EventProof
- All 24 API unit tests green; tsc --noEmit clean in both apps/api and apps/pwa
2026-06-04 11:16:10 -04:00
Lucas Berger 23b8e5326d feat(01-03): implement ctag poller with node-cron 5-min schedule
- startBrokerPoller schedules runPoll via node-cron */5 * * * *
- runPoll loads all member_credentials, decrypts each app password
- ctag/syncToken comparison skips syncCalendar when ctag unchanged
- errors per-credential caught and logged without crashing the poller
- runPoll exported for unit-testing with vi.mock injected dependencies
2026-06-04 11:08:46 -04:00
Lucas Berger 1b3ea4e99b test(01-03): add failing tests for broker poller ctag change detection
- 5 tests: skip on unchanged ctag, sync on changed ctag, first-sync (null ctag), decrypt failure handled, all credentials processed
- Mocks db.select, createFastmailClient, syncCalendar, decryptPassword at module level
- RED gate: all fail (src/broker/poller.ts does not exist yet)
2026-06-04 10:34:12 -04:00
Lucas Berger dd02207318 feat(01-03): implement broker client, syncCalendar, and events route (CAL-01)
- client.ts: createFastmailClient(email, appPassword) → tsdav DAVClient via Basic auth
- sync.ts: syncCalendar upserts calendar row, selects ID, fetches+parses VEVENTs with ical.js
  - all-day DATE → dtstartDate (Date obj at T00:00:00Z), dtstartUtc=null, allDay=true
  - timed → dtstartUtc (JS Date), dtstartDate=null, allDay=false (D-13/Pitfall #3)
  - onDuplicateKeyUpdate on calendarId+uid composite key (idempotent)
- routes/events.ts: GET /api/events reads cache only — no tsdav import (T-03-02)
- tsc --noEmit clean; all 6 sync tests pass
2026-06-04 10:33:28 -04:00
Lucas Berger 90b99296e8 test(01-03): refine sync RED tests with richer db mock (select + insert)
- Use separate mock functions for insert/select chain to enable per-test assertions
- Reset mock implementations in beforeEach after vi.clearAllMocks
- Mock returns {id:42} from select (calendar ID for event upsert)
2026-06-04 10:30:53 -04:00
Lucas Berger ae21541e97 test(01-03): add failing tests for syncCalendar (D-13 all-day DATE handling)
- 6 tests: all-day DATE vs timed TIMESTAMP, allDay flag, upsert on dup UID, rawVevent blob, ctag update
- Mocks db singleton at module level with vi.mock hoisting pattern
- Uses SAMPLE_VEVENT_TIMED/ALLDAY fixtures from tests/helpers/db.ts
- RED gate: all fail (src/broker/sync.ts does not exist yet)
2026-06-04 10:29:29 -04:00
Lucas Berger d6d91201b1 feat(01-03): implement AES-256-GCM app-password encryption (T-03-01)
- encryptPassword: randomBytes(12) IV, aes-256-gcm, returns JSON {iv,authTag,ciphertext}
- decryptPassword: verifies GCM auth tag; throws on tamper
- Key from APP_PASSWORD_ENCRYPTION_KEY env (64-char hex); validated on each call
- No logging of plaintext or key
2026-06-04 10:27:40 -04:00
Lucas Berger 04d7c23e94 test(01-03): add failing tests for AES-256-GCM crypto helpers
- 5 tests covering roundtrip, IV uniqueness, tamper detection, payload shape
- Dynamic import with env key set in beforeAll for module-level KEY eval
- RED gate: all fail (src/broker/crypto.ts does not exist yet)
2026-06-04 10:26:59 -04:00
Lucas Berger 668ed9be0d feat(01-02): wire OIDC middleware, /api/me route, and authenticated PWA shell
- src/auth/middleware.ts: re-exports oidcAuthMiddleware, processOAuthCallback,
  getAuth from @hono/oidc-auth; documents required env vars and AUTH-02
  refresh-token rotation (no iframe, D-12)
- src/routes/me.ts: GET / calls getAuth → upsertUser(iss, sub, email) →
  returns { user: { id, displayName, color } }; identity keyed on iss+sub
- src/index.ts: /callback registered before oidcAuthMiddleware; /api/*
  guarded; /health remains unauthenticated; /api/me mounted
- apps/pwa/src/api/client.ts: typed fetchMe() with credentials: 'include'
- apps/pwa/src/App.tsx: useQuery(['me'], fetchMe); renders member name and
  color swatch; retains /health stack indicator from Plan 01
- tsc --noEmit: clean; all tests pass
2026-06-04 10:22:46 -04:00
Lucas Berger baabfce9e2 feat(01-02): implement upsertUser with stable color assignment (AUTH-03)
- Export COLOR_PALETTE (6 accessible hex hues, round-robin assignment)
- upsertUser(oidcIss, oidcSub, displayName?) keyed on iss+sub never email
- First login: COUNT existing users → assign COLOR_PALETTE[count % len]
- Re-upsert: returns existing row unchanged (idempotent, no duplicate insert)
- Uses $returningId() + re-select pattern (mysql2 no RETURNING clause)
- All 6 tests pass (GREEN)
2026-06-04 10:21:04 -04:00
Lucas Berger 61c258c021 test(01-02): add failing tests for upsertUser color assignment + identity
- Replace it.todo stubs with real failing tests (RED gate)
- Tests cover palette[0] first user, palette[1] second user, idempotent
  re-upsert, iss+sub identity key, full row shape
- Tests fail: src/auth/user.ts does not exist yet
2026-06-04 10:20:27 -04:00
Lucas Berger 00efbab107 fix(01-01): build Docker image from repo-root pnpm workspace context
The walking-skeleton Dockerfile built from a ./apps/api context and could not
work in a pnpm workspace: the lockfile lives at the repo root, pnpm 11 refused
esbuild's build script without the root pnpm-workspace.yaml, the dev stage never
compiled src->dist, and the production stage had invalid COPY syntax referencing
a path outside its context.

Switch to the correct monorepo pattern: build from the repo-root context, copy
the workspace manifest + lockfile + both package.jsons, and install with
--frozen-lockfile --filter @familysync/api...  Reorder stages so production is
default; dev reuses builder output. Fix the dev volume mount path.

Surfaced while clearing the Task 3 checkpoint (stack bring-up): drizzle-kit push
applied the 4 tables and /health returned {ok:true,db:up} end-to-end.
2026-06-04 10:17:06 -04:00
Lucas Berger 96cda58509 feat(01-01): Drizzle schema + DB client + /health slice (GREEN)
- src/db/schema.ts: users, memberCredentials, calendars, calendarEvents tables
  - users: composite unique on oidc_iss+oidc_sub (D-10 identity)
  - calendar_events: separate dtstart_utc (TIMESTAMP) and dtstart_date (DATE) + allDay boolean (D-13)
- src/db/client.ts: drizzle(mysql2 pool) singleton export `db`
- drizzle.config.ts: dialect mysql, schema → migrations, dbCredentials from env
- src/routes/health.ts: GET / with real SELECT 1 DB round-trip, 200 or 503
- src/index.ts: Hono app with /health mounted before auth, serveStatic for PWA
- tests/health.test.ts: 2 tests pass (mocked DB); TDD GREEN gate
- apps/pwa/src/App.tsx: React shell fetching /health via TanStack Query
2026-06-04 09:52:36 -04:00
Lucas Berger f31711af27 test(01-01): add failing health test (RED gate)
- Tests GET /health returns 200 { ok, db } on success and 503 on DB error
- Fails because src/index.ts and src/routes/health.ts don't exist yet
2026-06-04 09:50:43 -04:00
Lucas Berger 3f591566d1 chore(01-01): scaffold monorepo, Docker Compose stack, and Vitest harness
- pnpm workspace with apps/api (Hono/Drizzle) and apps/pwa (Vite/React 19)
- Pinned versions per RESEARCH: hono@4.12.23, drizzle-orm@0.45.2, mysql2@3.22.4, tsdav@2.2.2, ical.js@2.2.1, zod@^3.25.0, node-cron@^4.2.1
- docker-compose.yml with mariadb:11 healthcheck, api depends_on service_healthy, redis stub
- docker-compose.dev.yml overrides for local dev (bind mounts, exposed ports)
- .env.example lists all env vars (DB_*, OIDC_*, APP_PASSWORD_ENCRYPTION_KEY)
- .gitignore excludes .env (never commit secrets)
- apps/api/vitest.config.ts with environment: node
- Wave 0 test stubs: health, auth/user, broker/crypto, broker/sync, broker/poller
2026-06-04 09:50:16 -04:00