Commit Graph
100 Commits
Author SHA1 Message Date
Lucas Berger 8b5ec797cf chore: merge executor worktree (worktree-agent-a62cd2d02a9defd0a) 2026-06-07 15:31:31 -04:00
Lucas Berger 00a0454514 fix(260607-l6l): scope GET /api/events to current user + shared calendars
BUG 3: GET / had no ownership predicate — it returned all users' events.
Second household member would see other member's private events.

- Resolve currentUserId at top of GET handler (same resolveUserId helper
  as write endpoints); return 401 if unauthenticated.
- Add ownership predicate to WHERE: AND (calendars.userId = currentUserId
  OR calendars.isShared = true). Combined with and() around the existing
  date-window or() block. Mirrors the /writable-calendars idiom (D-03).
2026-06-07 15:28:54 -04:00
Lucas Berger 23c8bb3402 fix(260607-l6l): derive displayName from OIDC claims in me.ts + resolveUserId
BUG 2: Both me.ts and events.ts resolveUserId were passing email (often
absent) as displayName to upsertUser, resulting in blank legend names.
Also, upsertUser returned existing rows unchanged even when displayName
was null and a better value was now available.

- me.ts: derive displayName via name → preferred_username → email →
  "Member <sub-prefix>" fallback, checked defensively. Updated JSDoc.
- events.ts resolveUserId: same derivation so write-path upserts don't
  re-blank a correctly-set displayName.
- user.ts: when existing row has null displayName and caller supplies one,
  issue an UPDATE so already-existing blank rows are corrected on next login.

Authelia-side emission of name/preferred_username is an operator concern
(claim mappings / userinfo scope in authelia config) — out of scope here.
The code now reads whatever claims are present and falls back sensibly.
2026-06-07 15:27:40 -04:00
Lucas Berger 28704132d0 fix(260607-l6l): add missing innerJoin to PATCH+DELETE event lookups
BUG 1: PATCH /:uid/edit and DELETE /:uid selected calendars.url/userId
from .from(calendarEvents) with no join, causing Drizzle to throw at
toSQL() time → 503. Added .innerJoin(calendars, ...) to both lookups,
mirroring the working GET / join idiom.

- Updated PATCH + DELETE beforeEach mocks to route through innerJoin→where
- Updated CR-01 PATCH test mock similarly
- Added regression: edit/delete lookups join calendars describe block with
  toSQL() assertions using vi.importActual (real drizzle, no DB needed)
- All 21 tests pass; typecheck clean
2026-06-07 15:25:31 -04:00
Lucas Berger 4e174e5b44 docs(260607-l6l): pre-dispatch plan for write-path correctness bugs 2026-06-07 15:18:38 -04:00
Lucas Berger 3ae7724df1 wip: phase 03 Gate 2 paused — live bring-up working; edit/delete join fix is the next blocker 2026-06-06 22:42:06 -04:00
Lucas Berger bdbb9b8324 docs(debug): resolve write-path-event-bugs (timezone + calendar identity) 2026-06-06 22:32:10 -04:00
Lucas Berger a9d3de658e fix(03): correct event-write timezone + per-user calendar identity (Gate 2 Part D)
BUG A — timed events written 4h off: EventForm sent a naive local wall-clock
string with no offset; the UTC API container parsed it via new Date() as UTC, so
09:00 America/Toronto serialized to DTSTART:...090000Z. Fix: new
apps/pwa/src/lib/eventDateTime.ts serializes timed events to an unambiguous UTC
instant in the browser (where the operator's zone is known); all-day stays a DATE
string. No backend change.

BUG B — created events attached to the wrong user's calendar + duplicate calendar
rows per poll: calendars had no unique key on url, and poller/sync matched
calendars by url alone — so under the shared single Fastmail account (D-16) one
member's collection resolved to the other member's row. Fix: composite
unique(user_id, url); scope poller lookup + sync select to (userId, url); hand
migration 0001 (dedup + add key), applied to the live DB.

Regression tests fail against the buggy url-only predicate. API 98/98, PWA 140/140,
tsc clean both packages.
2026-06-06 22:32:10 -04:00
Lucas Berger 505f64ed93 docs: add backlog item 999.2 — slick unauthenticated-entry (no login flash) 2026-06-06 22:11:33 -04:00
Lucas Berger 431ab31fb8 fix(api): serve the full ./public tree, not just /assets/*
Root-level PWA files (manifest.webmanifest, sw.js, registerSW.js, workbox-*.js,
icon-*.png, apple-touch-icon.png) were falling through to the index.html
catch-all and returning HTML — breaking the manifest (syntax error) and
preventing the service worker from ever registering. serveStatic('/*') serves
any existing file and calls next() for SPA routes, so index.html stays the
fallback. Registered after /health, /api/*, /callback so those still win.
2026-06-06 21:57:42 -04:00
Lucas Berger 1adb460412 fix(pwa): fetchMe uses redirect:manual so unauthenticated /api/me can't hang
With the default redirect:follow, the browser follows the OIDC guard's 302 to
Authelia (cross-origin, credentialed) and the fetch HANGS — meQuery stays
'loading' so the SPA spins forever and the isError-driven login redirect never
fires. redirect:manual surfaces the 302 as an opaqueredirect (status 0) that we
detect as auth-required and throw, letting CalendarShell navigate to /api/login.
+4 fetchMe tests.
2026-06-06 21:57:42 -04:00
Lucas Berger 874f23de2f docs(quick-260606-tv8): sign-in redirect fix — summary + state 2026-06-06 21:42:58 -04:00
Lucas Berger 76db63458e chore: remove operator seed script + gitignore local verification artifacts
- git rm apps/api/scripts/seed-credential.mjs (operator-only, run out-of-band;
  the credential is already seeded in the running DB)
- gitignore .playwright-cli/, gate2-*.png, and the seed script path
2026-06-06 21:42:29 -04:00
Lucas Berger 7c6531f4d0 chore: merge executor worktree (worktree-agent-abd829a63c8f5dade) 2026-06-06 21:40:46 -04:00
Lucas Berger c2e0ab1b1f feat(260606-tv8-01): wire login redirect into CalendarShell meQuery handling
- Import maybeRedirectToLogin + clearLoginRedirect from loginRedirect.ts
- useEffect on meQuery.isError calls maybeRedirectToLogin() (one-shot, loop-guarded)
- useEffect on meQuery.isSuccess calls clearLoginRedirect() for future re-auth
- Existing 'Sign-in required' branch retained as fall-through for already-attempted case
2026-06-06 21:38:38 -04:00
Lucas Berger 6dc9ccd2e9 feat(260606-tv8-01): add one-shot login-redirect helper + tests; fix client.ts comment
- Add loginRedirect.ts: maybeRedirectToLogin (sessionStorage one-shot guard) and
  clearLoginRedirect; guards window/sessionStorage for SSR/test safety
- Add loginRedirect.test.ts: covers first-call redirect, one-shot no-op, clear+retry
- Update client.ts: remove false claim that fetch follows Authelia 302 automatically;
  note that XHR/fetch CORS-blocks cross-origin redirects, top-level nav required
2026-06-06 21:37:14 -04:00
Lucas Berger 237ec493aa feat(260606-tv8-01): add guarded GET /api/login route + tests
- Register app.get('/api/login', redirect to '/') in protected-routes block
- Route placed after OIDC guard so unauthenticated nav triggers auth flow
- Add login.test.ts covering bypass and OIDC-passthrough redirect paths
2026-06-06 21:35:52 -04:00
Lucas Berger b788ffc93e docs(260606-tv8): pre-dispatch plan for sign-in redirect fix 2026-06-06 21:33:35 -04:00
Lucas Berger b46b25b26b chore(03): Gate 2 stack bring-up — serve PWA from API image, prod env, credential seed
- Dockerfile: build apps/pwa into the production image's ./public so the API
  serves the PWA on a single port (:3000) for the Pangolin/newt tunnel
- docker-compose.yml: set NODE_ENV=production (mount OIDC unconditionally) and
  constrain OIDC_SCOPES=openid profile email offline_access (Authelia rejected
  the empty-default's full scopes_supported with invalid_scope)
- apps/api/scripts/seed-credential.mjs: operator tool to seed member_credentials
  (encrypted Fastmail app password) out-of-band — fills the documented gap
2026-06-06 21:30:58 -04:00
Lucas Berger ca87c023ef wip: phase 03 paused — gap-closure done, docker bring-up awaiting auth+PWA decisions 2026-06-05 21:48:17 -04:00
Lucas Berger b3eff3c677 docs(03): record gap-closure completion (03-09..03-12) in STATE + ROADMAP 2026-06-05 21:10:26 -04:00
Lucas Berger 0e4a263ce6 merge(03-11): outbox durability + concurrency + etag re-read (CR-04/05, WR-02) 2026-06-05 21:08:27 -04:00
Lucas Berger ad181e7b2a docs(03-11): complete outbox durability + etag fix plan summary (CR-04, CR-05, WR-02) 2026-06-05 21:08:07 -04:00
Lucas Berger 09fd1f2e92 feat(03-11): GREEN — re-read freshest calendarEvents etag before update PUT (WR-02)
- In update dispatch, SELECT etag FROM calendar_events WHERE uid = row.uid before PUT
- Use fresh etag as If-Match instead of stale enqueue-time row.etag when available
- Fall back to row.etag when calendarEvents has no matching row
- D-08 conflict detection intact: genuine external changes update calendarEvents.etag
  differently from any pending row, so they still 412 correctly
2026-06-05 21:06:32 -04:00
Lucas Berger 5eb26c0e6b test(03-11): RED — fresh etag re-read before PUT to avoid spurious 412 (WR-02)
- WR-02 fresh: update PUT must use calendarEvents.etag not stale enqueue-time etag
  (fails RED: capturedEtag === 'old-etag', not 'new-etag')
- WR-02 fallback: when calendarEvents has no row, fall back to row.etag (passes in RED)
- Add mockWhereCalEvents to mock infrastructure to isolate calendarEvents selects
- Switch all beforeEach to vi.resetAllMocks() to prevent mockImplementationOnce bleed
2026-06-05 21:05:40 -04:00
Lucas Berger b409c09e25 feat(03-11): GREEN — durable create-before-delete gating + drain concurrency guard (CR-04, CR-05)
- CR-04: delete rows with groupId query DB for sibling create status before dispatch
  - sibling 'pending': defer delete to later cycle (leave row pending)
  - sibling 'failed'/'dead': mark delete failed permanently (original event preserved, D-04)
  - sibling 'done': dispatch delete normally
- CR-05: module-level isDraining guard; overlapping 15s cycles are no-ops
  - SINGLE-PROCESS ONLY — documented limitation for multi-replica deployments
- Fix mockFromFn to use Symbol.for('drizzle:Name') instead of JSON.stringify (circular)
- Update D-04 ordering test to queue sibling-status mock response
2026-06-05 21:01:57 -04:00
Lucas Berger 6b2cdf3683 test(03-11): RED — durable create-before-delete gating + concurrency guard (CR-04, CR-05)
- CR-04 cross-batch drain 1: sibling create 'pending' must block delete dispatch
- CR-04 cross-batch drain 2: sibling create 'done' must allow delete dispatch
- CR-04 paired-create-failed: sibling create 'failed'/'dead' marks delete failed, preserves original
- CR-05: two overlapping drain calls must invoke createCalendarEvent exactly once
2026-06-05 20:56:07 -04:00
Lucas Berger 416e813e40 merge(03-10): outbox real VEVENT dispatch + fail-closed creds (CR-02/03, WR-01/04/08) 2026-06-05 20:53:25 -04:00
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