Commit Graph
24 Commits
Author SHA1 Message Date
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00
Lucas Berger 03e953158a fix(13-02): eliminate all ESLint violations — pnpm lint exits 0
- eslint.config.js: disable React Compiler rules (v7 flat.recommended enables
  them; codebase does not use the Compiler); add e2e/ to disableTypeChecked
  block; promote exhaustive-deps to error
- API broker: remove redundant as-casts (outboxWorker, poller, reminderScheduler,
  expand, sync, vevent, spike); add targeted ical.js no-unsafe-assignment/argument
  disables with justifying comments inside try blocks
- API routes/sse.ts: fix no-misused-promises on async writeSSE callback with
  void+IIFE+catch pattern
- API routes/lists.ts: let → const for updateValues
- API tests: remove unused imports (beforeEach, eq, vi); rename unused vars
  with _ prefix; remove unused lastActiveId assignment
- PWA components: void navigate() and void queryClient.invalidateQueries() on
  all fire-and-forget call sites; fix CalendarShell explicit-type-casts;
  Couldn't → HTML entity
- PWA test files: as unknown as Response for partial mock objects; string | null
  type annotation on mockLastSyncedUid; remove async from test callbacks without
  await; act(() => {}) not await act(async () => {}) for sync ops
- sw.ts: restructure Notification.data?.url access as let+if so disable
  comments land on the exact violation lines; void self.skipWaiting()
2026-06-11 20:23:38 -04:00
Lucas Berger a570135a8d fix(06): IN-02 document resolveDefaultView is an SSR guard, not breakpoint logic 2026-06-10 16:54:12 -04:00
Lucas Berger 051874ba12 fix(06): make AppNav persistent across routes so Lists keeps the nav
- Lift AppNav from CalendarShell to App.tsx as a sibling of <Routes>
- App.tsx fetches /api/me (same query key as CalendarShell — deduplicated by TanStack Query)
- App.tsx provides the outer layout (phone: column, desktop: row) with AppNav always rendered
- CalendarShell simplified: no longer manages AppNav, outer flex layout stays in App.tsx
- AuthSplash gains overlay prop (position:fixed inset:0 z-index:999) so it covers AppNav when needed
- CalendarShell uses AuthSplash with overlay=true so auth splashes cover full viewport
- Remove onOpenSettings prop from CalendarShell (wired directly in App.tsx to SettingsSheet)
- Desktop sidebar nav (FamilySync brand, Calendar/Lists links) now persists on /lists route
2026-06-10 16:02:02 -04:00
Lucas Berger e392c69196 fix(06-05): make AuthSplash dead-end state reachable + persist redirect guard
- CalendarShell now captures maybeRedirectToLogin() return value in meQuery.isError effect
- When the one-shot guard is exhausted (returns false), arm loginRedirectExhausted state
- Render AuthSplash state=dead-end (tap-to-retry) when guard is exhausted, not indefinite redirecting spinner
- Reset loginRedirectExhausted on successful auth (meQuery.isSuccess) for session recovery
- Add sessionStorage.clear() to beforeEach so CalendarShell tests are isolated
- RED test committed in prior commit (36ef7a0)
2026-06-10 15:55:06 -04:00
Lucas Berger 139ef00ed4 feat(06-05): global session-expiry interstitial via QueryCache/MutationCache onError
- Add sessionExpired boolean + setSessionExpired action to Zustand calendarStore
- main.tsx: construct QueryClient with QueryCache+MutationCache onError (v5 pattern, not defaultOptions.onError)
- onGlobalError: checks instanceof SessionExpiredError, calls setSessionExpired(true) via store.getState()
- CalendarShell: read sessionExpired from store; render AuthSplash(state=redirecting, 'Session expired', 'Signing you back in…')
- CalendarShell: useEffect fires clearLoginRedirect + maybeRedirectToLogin after 1.5s when sessionExpired (one-shot guard re-armed)
- Context7 /tanstack/query confirmed v5 QueryCache/MutationCache constructor + onError signature
2026-06-10 11:32:45 -04:00
Lucas Berger e7b34a5ce2 feat(06-05): gate app render behind AuthSplash (no pre-auth flash)
- Create AuthSplash component: full-screen centered column, loading/redirecting/dead-end states
- loading state: Loader2 spinner + 'Signing you in' heading + 'Taking you to the sign-in page…' body
- redirecting state: spinner + customisable heading/body (defaults to cold-load copy)
- dead-end state: tap-to-retry button (clearLoginRedirect + maybeRedirectToLogin)
- role=status, aria-label, inline styles matching SkeletonCalendar layout approach
- CalendarShell: early-return AuthSplash on meQuery.isLoading (no skeleton before auth)
- CalendarShell: replace 'Sign-in required' role=alert block with AuthSplash state=redirecting
- CalendarShell: isInitialLoading no longer includes meQuery.isLoading (handled by early return)
2026-06-10 11:31:17 -04:00
Lucas Berger 010a69c047 feat(05-08): PermissionDeniedBanner + App mount + CalendarShell onOpenSettings wiring
- Create PermissionDeniedBanner.tsx: role=alert banner shown only when
  permission=denied AND notificationsEnabled=1 (OS-revoked case, D-10)
  with AlertCircle icon, 'Notifications blocked' heading, inline 'How to enable'
  button that opens OS-specific instruction sheet (iOS 4-step / Android 4-step)
- Mount PermissionDeniedBanner and SettingsSheet in App.tsx; wire onOpenSettings
  state from avatar tap through CalendarShell → AppNav → PhoneNav/DesktopNav
- CalendarShell accepts optional onOpenSettings prop, threads to both AppNav usages
- playwright-cli verified: banner renders with exact UI-SPEC copy when
  permission=denied+was-enabled; banner absent when permission=granted;
  'How to enable' opens instruction sheet with correct Android steps;
  SettingsSheet opens from avatar click with toggle + permission-denied hint
2026-06-09 21:50:48 -04:00
Lucas Berger 2e10752a59 fix(pwa): stop calendar remount/flash on re-render (Bug B root cause)
The calendar flashed whenever the event popup/form closed or a post-write events
refetch landed. Root cause: CalendarContent was a function component DEFINED
INSIDE CalendarShell's render and used as <CalendarContent />. A nested component
has a new identity every render, so React unmounted+remounted its whole subtree
— including <ScheduleXCalendar> — on ANY CalendarShell re-render. The earlier
Bug B work only minimized re-renders (Zustand selectors) to dodge this; the
resync-before-done fix made the post-write ['events'] refetch deliver changed
data again, so the remount/flash returned.

Fix: render the content as a plain JSX element value (const calendarContent)
referenced at both layout sites instead of a nested component type. Element
values reconcile in place across re-renders — no remount, no flash.
2026-06-07 17:55:56 -04:00
Lucas Berger bb61d21c83 fix(pwa): gate events query on auth to stop OIDC state-cookie churn
Internal Server Error after returning from Authelia: processOAuthCallback threw
OAUTH_INVALID_RESPONSE ("unexpected state parameter") because the OIDC state
cookie no longer matched the state returned to /callback.

Root cause: eventsQuery (fetchEvents, redirect:'follow', retry:2) ran
concurrently with fetchMe on load. While unauthenticated, every /api/* request
hits the OIDC guard, which 302-redirects to Authelia AND sets a fresh state
cookie. fetchEvents could not follow the cross-origin redirect, so React Query
retried it up to 3x over ~3s — each retry overwriting the state cookie mid-login,
racing the single /api/login navigation that owns the real flow.

Fix: enabled: meQuery.isSuccess. Only fetchMe (redirect:'manual', retry:false)
touches a guarded endpoint while unauthenticated, so the top-level /api/login
navigation owns the state cookie uncontested. Realizes the documented design
intent that only fetchMe drives the login redirect.
2026-06-07 15:57:17 -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 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 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 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 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 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 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 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 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 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 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