Commit Graph
100 Commits
Author SHA1 Message Date
Lucas Berger aefdde13bd docs(03-10): complete outbox ICS builder wiring plan summary
CR-02, CR-03, WR-01, WR-04, WR-08, IN-01 closed.
2026-06-05 20:53:04 -04:00
Lucas Berger c21b040b36 feat(03-10): fail closed on bad credentials + fix backoff index + explicit randomUUID (CR-03, WR-01, WR-08)
- outboxWorker: remove empty-credential fallback; let loadClientForUser throw on error (CR-03)
- outboxWorker: fix backoff index from nextAttemptCount to row.attemptCount so first retry waits 15s not 60s (WR-01)
- events.ts: replace bare crypto.randomUUID() with import { randomUUID } from 'node:crypto' on all three handlers (WR-08)
2026-06-05 20:51:48 -04:00
Lucas Berger c178dcee0c test(03-10): add RED tests for CR-03 fail-closed creds + WR-01 backoff index
- Add mockDecryptPassword to vi.hoisted() so tests can control loadClientForUser behavior
- Add vi.mock for broker/crypto.js to enable CR-03 scenario
- Introduce wireMockChain() helper that differentiates credential vs outbox db selects
- CR-03 RED: credential-load failure must leave row pending, not call createFastmailClient('')
- WR-01 RED: first transient retry must use BACKOFF_SECONDS[0]=15s not BACKOFF_SECONDS[1]=60s
- Update FAKE_CRED_ROW so loadClientForUser can return a real credential-shaped row
2026-06-05 20:50:11 -04:00
Lucas Berger c03b47938e feat(03-10): wire buildVeventString into dispatch path + fix all-day DTEND+1 (CR-02, WR-04)
- outboxWorker: parse stored form JSON, build VCALENDAR via buildVeventString for create/update
- outboxWorker: return hardFail on payload parse error (corrupt payload never self-resolves)
- outboxWorker: import buildVeventString and RRULE_PRESETS from vevent.js
- vevent.ts: advance all-day DTEND by +1 calendar day (RFC-5545 exclusive end, WR-04 owning boundary)
2026-06-05 20:47:50 -04:00
Lucas Berger 813a7ba697 test(03-10): add RED tests for ICS builder wiring + WR-04 + CR-02
- vevent.test.ts: D-13 form-parsed contract block — timed and all-day cases
  (all-day DTEND+1 fails: emits 20260610 not 20260611)
- outboxWorker.test.ts: worker integration — create/update must pass BEGIN:VCALENDAR
  to CalDAV write functions (fails: raw JSON passes through today)
- worker: unparseable payload must mark row failed (fails: marks done today)
- Update makeRow default payload to form JSON shape the worker should parse
2026-06-05 20:46:50 -04:00
Lucas Berger 1fc56f42d0 merge(03-12): EventForm edit/a11y gap closure (WR-03/05/07, IN-03) 2026-06-05 20:43:45 -04:00
Lucas Berger 578315c8dd merge(03-09): route-layer reachability gap closure (CR-01, CR-06) 2026-06-05 20:43:45 -04:00
Lucas Berger a30a16b18a docs(03-12): complete EventForm gap-closure plan — WR-03/05/07, IN-03/04, PWA assets 2026-06-05 20:43:12 -04:00
Lucas Berger 0d3883ee02 docs(03-09): complete plan 09 summary — schema rename + OIDC resolution
CR-01: eventFieldsSchema aligned to canonical title/start/end (PWA contract)
CR-06: resolveUserId async with upsertUser on all 5 write handlers
19 tests pass; tsc clean
2026-06-05 20:42:13 -04:00
Lucas Berger e971e16cc6 feat(03-12): GREEN — WR-07 real focus trap on EventForm dialog
Add Tab/Shift+Tab focus trap to the dialog element:
- onKeyDown handler queries all focusable elements inside dialogRef
- Tab from last element wraps to first (preventDefault)
- Shift+Tab from first element wraps to last (preventDefault)
- No new dependency — implemented inline with dialogRef
- Existing focus-on-open (titleRef) and Escape-to-close unchanged
- Update docblock: focus trap claim is now accurate (WR-07)
2026-06-05 20:41:28 -04:00
Lucas Berger fac3a21332 feat(03-09): convert resolveUserId to async — real OIDC iss/sub→users.id via upsertUser (CR-06)
- Import upsertUser from auth/user.js
- resolveUserId now async: dev-bypass path unchanged; OIDC path calls getAuth
  then upsertUser(iss, sub, email) to resolve DB user id
- All 5 handlers (create, edit, delete, sync-status, writable-calendars) updated
  to await resolveUserId and 401 only when it returns null
- Remove all inline 'For now return 401' stubs and redundant getAuth calls
- grep confirms 0 'For now return 401' stubs remain; upsertUser imported+called
2026-06-05 20:41:20 -04:00
Lucas Berger 4244e8cd29 test(03-12): RED — WR-07 focus trap Tab/Shift+Tab cycle tests
Add two failing tests for the focus trap:
- Tab from last focusable element must wrap to first inside dialog
- Shift+Tab from first focusable element must wrap to last inside dialog

Both fail today because EventForm only calls .focus() once on open;
Tab escapes the modal to background content.
2026-06-05 20:40:40 -04:00
Lucas Berger f0f1361fba feat(03-12): GREEN — WR-03 blank edit, WR-03 recurrence, WR-05 zone-consistent, IN-03
WR-03 blank: add occurrence?.uid to reset effect deps so form re-populates
when occurrence resolves in TanStack cache after form opens.

WR-03 recurrence: derive initial recurrence from occurrence?.recurrence
instead of hard-coding 'none'; defaults to 'none' when absent (v1 comment).

WR-05: rewrite parseDateTime to use getFullYear/getMonth/getDate/getHours/
getMinutes (all local accessors) — never mix toISOString() UTC date with
getHours() local time.

IN-03: export todayIso from calendarStore (was private); import into EventForm
and collapse getDefaultStartDate/getDefaultEndDate to todayIso() calls.
2026-06-05 20:40:11 -04:00
Lucas Berger 6d1d338a45 test(03-09): add RED OIDC path tests — resolveUserId must call upsertUser (CR-06)
- POST /create with valid OIDC session (devBypassInjectUser.active=false, getAuth
  returns valid iss/sub) must return 202 not 401
- POST /create with no session (getAuth=null) must return 401
- Refactor getAuth/devBypass mocks to use vi.hoisted configurable flags for
  per-test OIDC path isolation
- Mock upsertUser from auth/user.js so OIDC resolution can be verified
2026-06-05 20:40:09 -04:00
Lucas Berger 99cb1698a8 feat(03-09): rename eventFieldsSchema to canonical title/start/end contract (CR-01)
- Replace summary→title, dtstart→start, dtend→end in eventFieldsSchema
- Server now accepts exact CreateEventPayload shape the PWA sends
- Update existing write tests to use new canonical field names
- No internal rename map; one canonical name set end-to-end
- grep confirms no summary/dtstart/dtend in eventFieldsSchema
2026-06-05 20:38:27 -04:00
Lucas Berger 02e312acdc test(03-12): RED — WR-03 blank edit, WR-03 recurrence, WR-05 zone, IN-03 export
- WR-03 blank: assert title re-populates when occurrence arrives in TanStack cache after form opens (fails: reset effect ignores occurrence in deps)
- WR-03 recurrence: assert weekly recurring event preselects 'weekly' not 'none' (fails: reset effect hard-codes 'none')
- IN-03: assert todayIso is exported from calendarStore (fails: currently private)
- WR-05: zone-consistent parseDateTime test with TZ=UTC pinned in vitest.config.ts env block
- Pin TZ=UTC in vitest.config.ts for deterministic date-extraction assertions
2026-06-05 20:38:18 -04:00
Lucas Berger 944693fed0 test(03-09): add RED contract tests for canonical title/start/end client payload
- POST /create with {title,start,end,allDay,recurrence} asserts 202 (fails: server requires summary/dtstart/dtend)
- PATCH /:uid/edit with same shape asserts 202 (fails: same schema mismatch CR-01)
2026-06-05 20:37:20 -04:00
Lucas Berger 941c4d621f docs(03): revise gap plans 03-10/11/12 per checker feedback 2026-06-05 19:27:19 -04:00
Lucas Berger d1658bd1db docs(03): gap-closure plans 03-09..03-12 for write-path review findings 2026-06-05 19:20:25 -04:00
Lucas Berger 628894c8c2 docs(03): add code review report 2026-06-05 19:11:12 -04:00
Lucas Berger 1f8775e7b6 docs(03-08): scaffold Gate 2 results + record production build status
- PWA build: CLEAN — 1818 modules, sw.js + workbox generated (build SHA 40dfbb4)
- API build: CLEAN — tsc passed, no errors
- 03-GATE2-RESULTS.md created with deploy header (URL TBD), full Gate 2 checklist
  (all rows marked PENDING — operator/device), and operator-setup section covering:
  Authelia OIDC client registration, OIDC_AUTH_EXTERNAL_URL, Pangolin/Newt Mode A rig,
  DB schema push, and /health tunnel verification
2026-06-05 18:51:37 -04:00
Lucas Berger 40dfbb48d5 docs(03-06): complete edit/delete + sync toast plan 2026-06-05 18:48:17 -04:00
Lucas Berger 40322e11bf feat(03-06): wire EventDetailPopover Edit/Delete footer and implement DeleteConfirmationDialog
- EventDetailPopover: replace aria-hidden placeholder with Edit2/Trash2 footer buttons
  - Edit opens EventForm in edit mode and closes popover
  - Delete opens DeleteConfirmationDialog via setDeleteDialog (T-03-17 two-tap)
- DeleteConfirmationDialog: centered modal, max-width 320px, backdrop + focus trap
  - heading 'Delete event?', Fastmail body copy per UI-SPEC
  - Cancel/Escape close without deleting; Delete fires mutation
  - On success: setLastSyncedUid (feeds SyncStateToast), close dialog + popover
  - TanStack mutation; 48px Delete button (--color-destructive)
- CalendarShell: mount DeleteConfirmationDialog in both phone and tablet/desktop layouts
2026-06-05 18:46:24 -04:00
Lucas Berger 2fbeffee9a test(03-06): add failing tests for EventDetailPopover footer and DeleteConfirmationDialog 2026-06-05 18:44:40 -04:00
Lucas Berger aa7c4c37d4 feat(03-06): implement SyncStateToast with polled sync-status feedback (D-05/D-06/D-08/D-09)
- SyncStateToast: pending/done/failed/dead states per UI-SPEC
- refetchInterval 3000ms while pending; disabled on terminal status
- done + conflict (412) invalidate ['events'] cache (D-06/D-08)
- done auto-dismisses after 2s; failed/dead persist with dismiss button
- role=status (pending/done) and role=alert (failed/dead) for a11y
- Mounted in CalendarShell (both phone + tablet/desktop layouts)
- EventForm.onSuccess: setLastSyncedUid(uid) instead of invalidateQueries
2026-06-05 18:43:36 -04:00
Lucas Berger 6874e1a074 test(03-06): add failing tests for SyncStateToast all states and polling 2026-06-05 18:37:07 -04:00
Lucas Berger 8aeacc8607 feat(03-06): add deleteEvent, fetchSyncStatus client calls and delete/sync Zustand keys
- deleteEvent(uid): DELETE /api/events/:uid with credentials:include, throws on !ok
- fetchSyncStatus(uid): GET /api/events/sync-status?uid= returning SyncStatus
- Export SyncStatus and SyncStatusValue types
- Zustand: deleteDialogOpen/deleteDialogUid/lastSyncedUid keys + setDeleteDialog/setLastSyncedUid setters
2026-06-05 18:35:51 -04:00
Lucas Berger 8357cf998e test(03-06): add failing tests for deleteEvent, fetchSyncStatus, and delete/sync Zustand keys 2026-06-05 18:34:55 -04:00
Lucas Berger dbd09bc2bb docs(03-05): complete event write UI plan — EventForm + client calls + FAB 2026-06-05 18:32:37 -04:00
Lucas Berger 69eac90bab feat(03-05): mount EventForm + add New Event FAB/toolbar trigger in CalendarShell
- Import EventForm and Plus icon from lucide-react
- Phone: fixed FAB bottom-right (56px, dark neutral fill per UI-SPEC)
- Tablet/desktop: toolbar button above calendar content area
- Both trigger setEventForm(true, 'create') via Zustand
- EventForm conditionally rendered while eventFormOpen
- Selectors pattern preserved to avoid unnecessary re-renders (Bug B guard)
2026-06-05 18:30:25 -04:00
Lucas Berger 86cefffe2f feat(03-05): implement EventForm modal (create/edit)
- Bottom sheet on phone, centered 480px dialog on desktop (EventDetailPopover pattern)
- Fields: title, all-day toggle, start/end date/time, recurrence select, location, description
- D-02: calendar picker hidden when 1 writable calendar, shown when >1 (from writable-calendars endpoint)
- D-11: recurrence presets None/Daily/Weekly/Monthly/Yearly only (whole-series)
- Validation: empty title + end-before-start with UI-SPEC error copy
- create mode: POST /api/events/create; edit mode: PATCH /api/events/:uid/edit
- role=dialog aria-modal=true; focus Title on open; Escape/backdrop close
- T-03-15: all values as plain-text JSX children; no dangerouslySetInnerHTML
- D-01: last-used calendar URL persisted in localStorage
- Auto-fix: vi.hoisted() for mock factory variables (D-03-04-hoisting)
2026-06-05 18:29:23 -04:00
Lucas Berger df416a45f1 test(03-05): add failing tests for EventForm modal component
- Fields: title, all-day toggle, start/end date/time, recurrence, location, description
- D-02: calendar picker absent with 1 calendar, present with 2 calendars
- Validation: empty title shows error, end-before-start shows error
- Create mode calls createEvent mutation; edit mode calls updateEvent mutation
- Escape and backdrop close the form; Cancel button closes
- role=dialog aria-modal=true; edit mode pre-populates title from TanStack cache
2026-06-05 18:27:09 -04:00
Lucas Berger 6ffcdcbd6b feat(03-05): add write client calls and eventForm Zustand keys
- createEvent(payload): POST /api/events/create, credentials:include, returns {uid}
- updateEvent(uid, payload): PATCH /api/events/:uid/edit
- fetchWritableCalendars(): GET /api/events/writable-calendars, returns calendars array (D-03 server-authoritative)
- Exported interfaces: CreateEventPayload, CreateEventResponse, WritableCalendar, RecurrencePreset
- calendarStore: eventFormOpen (bool), eventFormMode ('create'|'edit'), eventFormUid (string|null)
- setEventForm(open, mode?, uid?) setter with correct defaults
2026-06-05 18:26:00 -04:00
Lucas Berger 6400ce693c test(03-05): add failing tests for write client calls and eventForm store keys
- createEvent: POST /api/events/create with credentials:include, returns uid
- updateEvent: PATCH /api/events/:uid/edit
- fetchWritableCalendars: GET /api/events/writable-calendars, returns calendars array
- calendarStore: eventFormOpen, eventFormMode, eventFormUid defaults and setEventForm setter
2026-06-05 18:25:00 -04:00
Lucas Berger 82ab87a633 docs(03-04): complete outbox worker plan — state machine GREEN, wired at boot 2026-06-05 18:23:01 -04:00
Lucas Berger 026aebccdf feat(03-04): wire startOutboxWorker into index.ts at boot
- Import startOutboxWorker beside startBrokerPoller import
- Call startOutboxWorker() immediately after startBrokerPoller()
- Worker drains D-05 outbox every 15s alongside the 5-min ctag poller
2026-06-05 18:20:50 -04:00
Lucas Berger cd4a8931e5 feat(03-04): implement outbox drain state machine (GREEN)
- runOutboxDrain: drains pending outbox rows, dispatches CalDAV writes
  via broker/write.ts, classifies HTTP responses per D-07/D-08
- CONFLICT_STATUS=412 routes to conflict flow: mark failed, re-sync (D-08)
- TRANSIENT_STATUSES: exponential backoff with MAX_ATTEMPTS=5 dead-letter (D-07)
- HARD_FAIL_STATUSES 400/401/403: fail immediately, no retry (D-07)
- Edit-as-move D-04: create row sorted before delete for same groupId;
  create-fail aborts the paired delete (T-03-14)
- triggerTargetedResync: fetches fresh DAVCalendars, calls syncCalendar (D-06)
- startOutboxWorker: node-cron */15 * * * * * schedule (15s interval)
- Fix test scaffold: vi.hoisted() for mock variables to resolve vitest
  hoisting TDZ issue; simplified mock chain to match and() single .where()
2026-06-05 18:20:18 -04:00
Lucas Berger 95864e5dc8 docs(03-07): complete PWA install plan — manifest, InstallPrompt, icons 2026-06-05 18:07:59 -04:00
Lucas Berger e0fb34b252 feat(03-07): InstallPrompt — iOS walkthrough banner + Android beforeinstallprompt
- Implement isIOSSafariNonStandalone(): iOS UA + navigator.standalone detection
- Implement useAndroidInstallPrompt(): captures beforeinstallprompt, exposes canInstall/triggerInstall
- InstallPrompt renders nothing when display-mode:standalone or navigator.standalone (already installed)
- iOS branch: dismissible banner with 'Install FamilySync' heading, 'How to install' link
  opens 5-step walkthrough sheet (exact UI-SPEC copy, orange #F5A623 step number annotation)
- Android branch: banner with 'Install' button shown only when canInstall=true
- localStorage.installPromptDismissed persists banner dismissal
- role="banner", dismiss aria-label="Dismiss install prompt", 44px touch targets
- Mount <InstallPrompt /> in CalendarShell (phone: below AppNav; desktop: top of content area)
- InstallPrompt.test.tsx GREEN (5 behavior tests); full PWA suite 44 tests green; tsc clean
2026-06-05 18:05:30 -04:00
Lucas Berger bd8283774d feat(03-07): VitePWA manifest + auth-safe SW denylist + iOS head/icons
- Add VitePWA plugin to vite.config.ts with registerType:autoUpdate
- navigateFallbackDenylist excludes /callback, /api/, /health (T-03-20 Gate 2)
- runtimeCaching: [] — no API response caching (T-03-21)
- Manifest: name/short_name FamilySync, display:standalone, scope:/, theme_color #4A90D9
- Icons: 192x192, 512x512, 512x512 maskable in manifest
- Generate icon-192.png (192x192), icon-512.png (512x512), apple-touch-icon.png (180x180)
- Add five iOS head entries: apple-touch-icon link, apple-mobile-web-app-capable/status-bar-style/title
- Build verified: dist/manifest.webmanifest emitted with correct fields; SW + workbox emitted
2026-06-05 18:02:56 -04:00
Lucas Berger 7398946aa8 docs(03-03): complete write API surface plan — enqueue-only endpoints GREEN 2026-06-05 17:59:53 -04:00
Lucas Berger 0a8222329e feat(03-03): implement write API surface — create/edit/delete + sync-status + writable-calendars
- POST /create: validates with zod, checks calendar ownership (D-03/T-03-06), enqueues pending outbox row, returns 202 with uid
- PATCH /:uid/edit: looks up event, checks ownership, enqueues update row; uses db.transaction for edit-as-move calendar pair (D-04)
- DELETE /:uid: looks up event, checks ownership, enqueues delete row with server-side etag (T-03-10)
- GET /sync-status: returns outbox status scoped to currentUser only (T-03-07/D-09)
- GET /writable-calendars: returns own personal + shared calendars, never other member's personal (D-03/T-03-11)
- Auth via dev-bypass (c.get('user')) + getAuth(c) fallback; 401 if neither
- No tsdav import — broker boundary enforced (D-12)
- All 69 events tests GREEN; tsc --noEmit clean
2026-06-05 17:58:01 -04:00
Lucas Berger e14c5dab69 test(03-03): extend events tests RED — write/sync-status/writable-calendars endpoints
- Add write endpoint tests: POST /create, PATCH /:uid/edit, DELETE /:uid
- Add GET /sync-status tests (D-09 outbox polling)
- Add GET /writable-calendars tests (D-03 writable set, access control)
- Wire db.insert and db.transaction into the vi.mock for db/client.js
- Mock devAuthBypass to inject dev user in write-endpoint tests
- All 9 new tests are RED (routes not yet registered)
2026-06-05 17:54:08 -04:00
Lucas Berger 4eb7c28797 docs(03-02): complete broker primitives plan — vevent.ts + write.ts GREEN 2026-06-05 17:50:23 -04:00
Lucas Berger a1243c1b83 feat(03-02): implement tsdav write wrappers (Task 2 GREEN) + fix vevent.ts TS type
write.ts:
- createCalendarEvent: wraps client.createCalendarObject with ${uid}.ics filename
- updateCalendarEvent: wraps client.updateCalendarObject with etag → If-Match (D-08)
- deleteCalendarEvent: wraps client.deleteCalendarObject with etag → If-Match (D-08)
- null etag passed as '' (safe; no crash, no spurious If-Match header)
- Returns raw Response; status code interpretation deferred to outboxWorker (D-07)
- All 6 write.test.ts assertions GREEN

vevent.ts fix:
- ICAL.Time constructor requires 2 args per TS types; pass ICAL.Timezone.localTimezone
  as zone param for all-day DATE values (isDate:true suppresses TZID regardless)
- tsc --noEmit passes clean
2026-06-05 17:48:12 -04:00
Lucas Berger b23b9597df feat(03-02): implement buildVeventString VEVENT builder (Task 1 GREEN)
- buildVeventString(NewEventParams) → { uid, icsString } using ical.js ICAL.Component
- All-day events use ICAL.Time({ isDate: true }) → VALUE=DATE, no TZID, no time (D-13)
- Timed events use ICAL.Time.fromJSDate(date, true) → DTSTART:...Z, no TZID (D-13)
- RRULE serialized via ICAL.Recur.fromString + ICAL.Property (prevents char-split bug)
- Exports: buildVeventString, NewEventParams, RRULE_PRESETS (daily/weekly/monthly/yearly)
- Uses crypto.randomUUID() for UID generation; appends @familysync suffix
- All 7 vevent.test.ts assertions GREEN
2026-06-05 17:46:19 -04:00
Lucas Berger 0c10f9a6d3 docs(03): track follow-up to adopt drizzle generate+migrate workflow 2026-06-05 17:42:58 -04:00
Lucas Berger a487569c7e docs(03-01): complete foundation-scaffold plan 2026-06-05 17:41:41 -04:00
Lucas Berger bbfccda756 test(03-01): add Wave 0 RED test scaffold for all Phase 3 behaviors
- vevent.test.ts: DTSTART UTC 'Z' for timed, DATE for all-day (D-13), RRULE (CAL-04/07)
- write.test.ts: createCalendarEvent uid.ics filename, updateCalendarEvent/deleteCalendarEvent
  etag/If-Match shapes (CAL-04/05/06, D-08)
- outboxWorker.test.ts: pending→done on 204, pending→failed on 412 (no retry), pending→backoff
  on 500, pending→dead at MAX_ATTEMPTS, edit-as-move create-before-delete ordering (D-04/D-07/D-08)
- events.test.ts (extended): POST /create 202+outbox row, PATCH /edit 202+etag, DELETE /:uid 202,
  GET /sync-status, GET /writable-calendars D-03 access control, 403 unauthorized calendar (V4)
- InstallPrompt.test.tsx: isIOSSafariNonStandalone UA detection, useAndroidInstallPrompt
  canInstall lifecycle (PWA-01/PWA-02)
All tests fail RED — implementation modules do not exist yet
2026-06-05 17:26:02 -04:00
Lucas Berger 0c0bcefeef feat(03-01): populate calendarEvents.objectUrl in sync.ts from obj.url
- Set objectUrl: obj.url ?? null in both .values() and .onDuplicateKeyUpdate({ set: {} })
  alongside existing etag assignment — stores CalDAV object URL for If-Match on
  update/delete (D-08)
- All existing broker/sync tests pass (47 total)
2026-06-05 17:23:06 -04:00
Lucas Berger 78f0deefac feat(03-01): extend schema with calendarOutbox table + calendarEvents.objectUrl; install vite-plugin-pwa
- Add mysqlEnum import to drizzle-orm/mysql-core import block
- Add objectUrl varchar(1024) to calendarEvents after etag column (D-08)
- Add calendarOutbox table with status machine columns, groupId for edit-as-move (D-04)
- Add indexes: idx_outbox_user_status, idx_outbox_next_attempt, idx_outbox_uid
- Install vite-plugin-pwa@1.3.0 (supply-chain gate T-03-SC cleared by Task 1)
2026-06-05 17:22:37 -04:00
Lucas Berger 93302cf942 docs(03): finalize phase plan (8 plans, verified) 2026-06-05 17:08:16 -04:00
Lucas Berger 9dd08d28d1 docs(03): create phase plan (8 plans, 5 waves) 2026-06-05 16:57:01 -04:00
Lucas Berger 6f5110266d docs(03): UI design contract
Establishes Phase 3 visual and interaction contract for event write-back
(EventForm, SyncStateToast, DeleteConfirmationDialog, calendar picker) and
PWA install surfaces (iOS A2HS walkthrough, Android beforeinstallprompt).
Inherits Phase 2 token layer in full; activates --color-destructive for
delete actions.
2026-06-05 16:39:15 -04:00
Lucas Berger 7b0463d25e docs(03): add research + validation strategy 2026-06-05 16:33:30 -04:00
Lucas Berger 364b6e68b0 docs(03): research phase domain 2026-06-05 16:32:38 -04:00
Lucas Berger 46cd35e4de docs(state): record phase 3 context session 2026-06-05 16:17:26 -04:00
Lucas Berger e579a6870a docs(03): capture phase context 2026-06-05 16:17:26 -04:00
Lucas Berger cd71b2bbe2 docs(phase-02): record follow-up todos (dev-run docs, traceability, DST spot-check) 2026-06-05 16:01:46 -04:00
Lucas Berger edf9649a21 docs(phase-02): complete phase — mark roadmap + requirements traceability 2026-06-05 16:01:15 -04:00
Lucas Berger 45b4c40c12 docs(phase-02): evolve PROJECT.md + STATE after phase completion 2026-06-05 16:01:04 -04:00
Lucas Berger b794785e09 test(02): human UAT approved — all 4 success criteria; verification passed 2026-06-05 16:00:27 -04:00
Lucas Berger 504ce369b5 fix(02): display events in viewer's local timezone, not UTC
Schedule-X defaults its calendar timezone to 'UTC', so a 17:45-04:00 event rendered at
21:45 (9:45 PM). Set timezone to the viewer's resolved IANA zone so events convert to
local wall-clock; the popover already showed local time, so the two now agree.
2026-06-05 15:31:28 -04:00
Lucas Berger 05d9f70b45 fix(02): window occurrences in UTC, not server-local timezone
ICAL.Time.fromJSDate(window, false) interpreted the UTC-midnight window bounds in the
server's local TZ (America/New_York in dev), shifting the window by the server offset and
dropping evening occurrences near a day window's end (e.g. June 11 17:45-04:00 = 21:45Z was
excluded from the June-11 day view). Use UTC so the window is deterministic and correct.
2026-06-05 15:28:26 -04:00
Lucas Berger d07e8af88d fix(02): keep calendar mounted on empty windows so navigation survives
Navigation now lives in Schedule-X's built-in header; replacing the calendar with
EmptyState on a zero-event day removed the nav and stranded the user. Always render
the calendar (empty grid is self-explanatory).
2026-06-05 15:21:58 -04:00
Lucas Berger 92dbbfe110 fix(02): give React adapter wrapper height so week/day grid scrolls
.sx-react-calendar-wrapper (emitted by @schedule-x/react) had no height, collapsing the
height chain so .sx__view-container could not scroll. Set it to height:100%.
2026-06-05 15:19:05 -04:00
Lucas Berger 194f6a82a8 fix(02): show owner name / Family in event popover footer
Backend:
- expand.ts: add ownerName: string | null to CalendarOccurrence
  interface and expandOccurrences() signature; thread it onto every
  emitted occurrence.
- events.ts: SELECT users.displayName as ownerName in the join; pass
  it to expandOccurrences().

Frontend:
- client.ts: add ownerName: string | null to CalendarOccurrence.
- EventDetailPopover.tsx: render isShared ? 'Family' :
  (ownerName ?? calendarName) in the footer instead of calendarName.

Tests:
- expand.test.ts: pass ownerName to all expandOccurrences() calls;
  assert ownerName is carried onto occurrences in the DST test.
- events.test.ts: add ownerName to mock rows; assert ownerName present
  on occurrences; add ownerName assertion to timed-recurring test.
- EventDetailPopover.test.tsx: add ownerName to fixtures; split
  "calendar name in footer" into three targeted tests covering
  personal-with-owner, shared→Family, and null-owner fallback.
2026-06-05 15:14:43 -04:00
Lucas Berger fc758e8ea6 fix(02): fix week/day time-grid clip and hour-label contrast
- CalendarShell: remove overflow:hidden from calendar container; add
  height:100% so Schedule-X .sx__calendar-wrapper can fill the flex
  parent and .sx__view-container can scroll.
- index.css: add explicit .sx__calendar-wrapper { height: 100% } rule
  to propagate height through the React adapter's wrapper element.
- tokens.css: fix --sx-color-neutral override from near-white
  var(--color-surface-dim) to readable var(--color-text-secondary);
  fix --sx-color-neutral-variant to var(--color-border); add
  --sx-internal-color-text override for chevrons and UI borders.
  Both hour-axis labels (.sx__week-grid__hour-text) and weekday/day
  name headers (.sx__week-grid__day-name, .sx__week-grid__date-number)
  use --sx-color-neutral — all now readable.

Class and variable names confirmed from @schedule-x/theme-default@4.6.0
dist/index.css inspection.
2026-06-05 15:14:33 -04:00
Lucas Berger d240657059 fix(02): remove custom ViewToolbar; use Schedule-X built-in header
- Remove <ViewToolbar> render and its import from CalendarShell
- Remove createCalendarControlsPlugin import, useState instance, and plugin
  array entry (calendar-controls only served the custom toolbar)
- Delete ViewToolbar.tsx (no longer referenced anywhere)
- Remove calendar-controls mock from CalendarShell.test.tsx
- CSS audit confirmed no rules hide Schedule-X weekday-name row; no CSS changes needed
- All four views (day/week/month-grid/month-agenda) remain; Schedule-X's native
  header exposes them in its own view switcher
2026-06-05 14:54:40 -04:00
Lucas Berger 1f0b9546a8 fix(02): include all-day recurring masters in events route pre-filter
- Old filter: hasRrule=1 AND dtstartUtc < windowEnd
  All-day recurring masters have dtstartUtc=NULL so the comparison evaluates
  to NULL/false — 11 such rows in live cache were never returned
- New filter: hasRrule=1 AND (dtstartUtc < windowEnd OR dtstartDate < end)
  The OR covers all-day masters whose only date column is dtstartDate (DATE)
- expandOccurrences already does precise per-occurrence window checks, so
  over-selecting a master on the DATE path is safe
- Extend events.test.ts: assert timed recurring master (dtstart 2024) returns
  occurrences in 2026 window; assert all-day recurring master (dtstartDate 2024,
  dtstartUtc NULL) returns its 2026-06-15 occurrence
2026-06-05 14:50:27 -04:00
Lucas Berger f70496871a fix(02): populate hasRrule on every sync upsert so recurring masters are flagged
- Use ICAL.Event.isRecurring() (parity with expand.ts) to detect RRULE/RDATE
- Add hasRrule to .values() INSERT and .onDuplicateKeyUpdate() SET so the flag
  is set on first sync and self-heals on every subsequent re-sync
- Without this fix every event had has_rrule=0 (column default), causing the
  events route recurring-master pre-filter to return zero recurring occurrences
- Add sync.test.ts cases: hasRrule=true for timed+all-day recurring VEVENTs,
  hasRrule=false for non-recurring, and hasRrule in onDuplicateKeyUpdate.set
2026-06-05 14:50:18 -04:00
Lucas Berger 5d82f859fd fix(02): wire calendar-controls plugin and Zustand selectors
Bug A — navigation no-op: replace $app.calendarState private-API poking
with the official @schedule-x/calendar-controls plugin. CalendarShell
creates the plugin once via useState stable initialiser and passes it to
ViewToolbar as `controls`. ViewToolbar calls controls.setDate(PlainDate)
and controls.setView(id) for all navigation and view-switching. Step size
matches the active view: day→±1 day, week→±1 week, month-*→±1 month.

Bug B — popover-open calendar flash: replace the unselected
useCalendarStore() destructuring in CalendarShell and ViewToolbar with
per-field selectors. Neither component now subscribes to openEventId, so
popover open/close no longer triggers a re-render that rebuilds the
Schedule-X config.

- Add @schedule-x/calendar-controls@4.6.0 dependency
- Update CalendarShell.test.tsx: add vi.mock for calendar-controls
- typecheck, vitest (37/37), build all pass
2026-06-05 14:45:27 -04:00
Lucas Berger 1a24b00de9 fix(02): day-view 400, invalid date display, duplicate popover (BUG 2-4)
- BUG 2: onRangeUpdate sets exclusive end = range.end + 1 day so day view
  sends a 1-day window (start < end, no 400) and week/month include the last day
- BUG 3: formatDateTime strips IANA bracket '[Zone]' before new Date() to prevent
  'Invalid Date, Invalid Date – Invalid Date' in event popover; regression test added
- BUG 4: remove createEventModalPlugin + customComponents.eventModal — keep only
  the Zustand-driven standalone EventDetailPopover to prevent double-open fight
2026-06-05 14:34:36 -04:00
Lucas Berger ee2281fe81 fix(02): correct DURATION-only event end in expand.ts (BUG 1)
- Replace dtend ?? dtstart with event.endDate which handles DURATION-only VEVENTs
- Add positive-duration guard (PT30M / P1D) to both non-recurring and recurring branches
- Add single-duration.ics fixture and regression test asserting end > start for DURATION-only events
2026-06-05 14:33:00 -04:00
Lucas Berger bef4a83fe0 fix(02): CSS-safe occurrence ids + error boundary to surface render errors
Schedule-X rejects ids containing ':' '[' ']' (the old ${uid}::${iso} form) — mint ev-<uid>-<epochMs> instead. Add an ErrorBoundary so a render throw shows the error instead of a blank page.
2026-06-05 14:30:32 -04:00
Lucas Berger 93c368402c fix(02): update expand tests to assert IANA-annotated format + add cross-contract test
- Assert timed start/end strings include '[America/New_York]' bracket (not offset-only)
- Assert DST boundary offsets: -05:00[America/New_York] pre-transition, -04:00[America/New_York] post
- Add cross-contract regression test: feeds expandOccurrences output directly into
  Temporal.ZonedDateTime.from() to prove the expand→hydrate contract holds end-to-end
- Import 'temporal-polyfill/global' at top of test file for the Temporal global
- Rename describe block from 'RED stubs (Wave 0)' to reflect GREEN state
2026-06-05 14:04:00 -04:00
Lucas Berger 35f725d450 fix(02): emit IANA-annotated timed strings from serializeTime
Temporal.ZonedDateTime.from() rejects offset-only ISO strings such as
'2026-06-18T08:00:00-04:00'; it requires an IANA bracket, e.g.
'2026-06-18T08:00:00-04:00[America/New_York]'. serializeTime() was
emitting offset-only for named zones and bare 'Z' for UTC — both
unparseable by the frontend, blanking the calendar view.

Changes:
- Named IANA zone: emit '...±HH:MM[tzid]' using t.zone.tzid
- UTC zone: strip trailing 'Z' from toString(), emit '+00:00[UTC]'
- Floating zone (no registered VTIMEZONE): fall back to '+00:00[UTC]'
- Update CalendarOccurrence docstrings to reflect the IANA-annotated contract
- Add temporal-polyfill@0.3.2 as dev dep in api for cross-contract test
2026-06-05 14:02:36 -04:00
Lucas Berger df5d36308a fix(02): add regression tests for /api/me under dev-auth bypass
- Asserts GET /api/me returns 200 with DEV_USER (id=1, color=#4A90D9)
  when DEV_AUTH_BYPASS=true and NODE_ENV!=production
- Asserts oidcAuthMiddleware is NOT wired when bypass is active
- Asserts oidcAuthMiddleware IS wired when bypass is absent
- Asserts 401 from getAuth(null) fallback path with no OIDC session
2026-06-05 13:48:05 -04:00
Lucas Berger 4b34b16f02 fix(02): dev-auth bypass no longer blocked by oidcAuthMiddleware
- index.ts: compute devBypassActive at startup; skip app.use(oidcAuthMiddleware)
  entirely when active so the OIDC guard never runs in local dev
- routes/me.ts: read c.get('user') first; return dev identity directly when
  devAuthBypass injected it, bypassing getAuth() and the DB upsert
- auth/devBypass.ts: add ContextVariableMap augmentation for 'user' key;
  correct stale comment that claimed getAuth/401 path was still active
2026-06-05 13:48:00 -04:00
Lucas Berger 11595e7924 docs(02-05): complete plan 05 autonomous tasks — SUMMARY.md 2026-06-05 10:59:31 -04:00
Lucas Berger 216ddcedf4 feat(02-05): ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState; retire EventProof
- ColorLegend: per-member color swatches (12px circle, label) + always-visible Family row
- AppNav: phone 48px top bar (avatar with aria-label/title) + tablet/desktop 240px sidebar with ColorLegend
- ViewToolbar: Today/prev/next + Day/Week/Month/Agenda view switcher; 44px min-height; active state uses surface tint not accent
- SkeletonCalendar: shimmer month (6x7 grid) and agenda (4 date-group blocks) variants; aria-busy=true
- EmptyState: CalendarDays icon + 'Nothing here' heading + body copy per UI-SPEC
- CalendarShell: full phone/desktop layout with AppNav + ViewToolbar + ColorLegend chrome
- CalendarShell: state branches — loading→SkeletonCalendar, empty→EmptyState, error→'Couldn't load events' + Retry button (refetchQueries)
- EventProof.tsx deleted; legacy types removed from client.ts
- CalendarShell.test.tsx: updated to waitFor ScheduleXCalendar after data loads
- All 36 tests pass, tsc clean, vite build clean (490kB)
2026-06-05 10:58:06 -04:00
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 00a4e66a8d docs(phase-02): update tracking after wave 3 2026-06-05 10:44:57 -04:00
Lucas Berger 7b32bc1456 docs(02-04): complete CalendarShell plan — SUMMARY.md 2026-06-05 10:44:04 -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 dabe2ccd57 docs(phase-02): record D-16 (shared calendar on primary FM account) + defer is_shared marking 2026-06-05 10:34:22 -04:00
Lucas Berger bfddcb1da2 docs(phase-02): update tracking after wave 2 2026-06-05 10:33:53 -04:00
Lucas Berger 010ef78230 docs(02-02): complete windowed events + expansion plan
- SUMMARY.md created with task outcomes, deviations, self-check
- Task 3 deferral documented (shared calendar marking pending operator setup)
2026-06-05 10:32:30 -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 5435df6bbe docs(02-03): complete plan 03 summary — token layer, calendar config, hydration, store 2026-06-05 09:49:39 -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 6dc5d77f60 docs(phase-02): update tracking after wave 1 2026-06-05 09:38:23 -04:00
Lucas Berger 2d75b2b73b docs(02-01): complete plan 01 — schema columns, test harness, dev-auth bypass summary 2026-06-05 09:35:53 -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