Commit Graph
120 Commits
Author SHA1 Message Date
Lucas Berger e5f7b1ab7c fix(05-review): CR-03 notificationclick uses matchAll+focus+navigate for deep-link URLs 2026-06-09 22:22:27 -04:00
Lucas Berger 814d29dbdd fix(05-review): WR-05 re-confirm push subscription server-side on health-check mount 2026-06-09 22:21:16 -04:00
Lucas Berger 76e0fb9588 fix(05-review): IN-03 use useId() instead of Math.random() for headingId in PushPermissionPrompt 2026-06-09 22:21:05 -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 1de4aa5a3e feat(05-08): SettingsSheet (master toggle D-09) + AppNav avatar promoted to button
- Create SettingsSheet.tsx: bottom sheet (role=dialog, z:301, Escape+backdrop-close)
  with FamilySync Notifications toggle (role=switch, aria-checked, 44px target)
  wired to usePushSubscription setEnabled + permission state
  and inline permission-denied hint (AlertCircle + 'How to enable') when denied
- Promote PhoneNav avatar div to button with onOpenSettings onClick + aria-label
- Add onOpenSettings prop to DesktopNav; add avatar button at sidebar bottom
- Thread onOpenSettings through AppNavProps
- Add @keyframes spin to tokens.css (fixes missing keyframe used by SettingsSheet + SyncStateToast)
2026-06-09 21:45:59 -04:00
Lucas Berger 458d6e4fef feat(05-08): extend usePushSubscription with isSubscribed, setEnabled, permission state (D-10)
- Add isSubscribed state (true when pushManager has active subscription)
- Add setEnabled(on) master toggle: off=unsubscribe, on+granted=silent subscribe, on+default/denied=no-op
- Health-check now respects readNotificationsDisabled() — skip re-subscribe if user explicitly disabled
- Export readNotificationsEnabled for PermissionDeniedBanner/SettingsSheet initial state
- Remove dead readNotificationsEnabled local-only usage (was unused in returned interface)
- persistNotificationsEnabled(false) now writes '0' instead of removing key for explicit off state
2026-06-09 21:43:31 -04:00
Lucas Berger bf8f63b47c feat(05-04): usePushSubscription hook + PushPermissionPrompt + App mount
- Create apps/pwa/src/hooks/usePushSubscription.ts: subscribe (in tap handler, VAPID key cached), unsubscribe, permission; health-check on mount (D-10); urlBase64ToUint8Array helper; prefetchVapidKey export
- Create apps/pwa/src/components/PushPermissionPrompt.tsx: WalkthroughSheet-style bottom sheet, Bell icon, 'Stay in the loop' heading, 48px Enable CTA (var(--color-member-0)), 44px Not-now ghost, no backdrop-dismiss, pushPermissionDismissed key, Loader2 spinner while awaiting
- Mount PushPermissionPrompt in App.tsx (for installed-PWA path) and InstallPrompt.tsx (for post-Android-install justInstalled path)
- Build green; tsc clean
2026-06-09 21:09:41 -04:00
Lucas Berger e5953ebb31 feat(05-04): SW migration to injectManifest with push + notificationclick + denylist
- Migrate vite.config.ts from generateSW to injectManifest (strategies, srcDir, filename)
- Add rolldownOptions.output.format=iife to produce sw.js (not sw.mjs) matching registerSW.js
- Create apps/pwa/src/sw.ts: skipWaiting+clientsClaim (autoUpdate), precacheAndRoute, NavigationRoute denylist (/callback,/api/,/health)
- Push handler: dual-format payload parsing (iOS 18.4+ declarative + legacy), ALWAYS event.waitUntil(showNotification) — never silent (D-11)
- notificationclick: close notification, focus existing window or openWindow to deep-link URL (D-14)
- Build verified: dist/sw.js contains showNotification, waitUntil, callback denylist, notificationclick
2026-06-09 21:06:32 -04:00
Lucas Berger 80bbdc1735 chore(05-01): install web-push and workbox push dependencies
- apps/api: add web-push@^3.6.7 (prod) + @types/web-push@^3.6.4 (dev)
- apps/pwa: add workbox-core, workbox-precaching, workbox-routing@^7.4.1 (dev)
- pnpm-lock.yaml updated for all three workspace packages
2026-06-09 20:47:35 -04:00
Lucas Berger 1652a68c51 feat(04-06): implement live-sync SSE vertical slice (LIST-04, D-04/D-10/D-11/D-12)
- Wire publishListEvent fan-out in lists.ts after every write mutation (item:added/updated/deleted, list:updated/deleted)
- Add GET /api/sse/lists scoped endpoint in sse.ts: resolveUserId → 401 on null; getAccessibleListIds → subscribe only to accessible channels; 30s heartbeat; cleanup on disconnect (D-04/T-04-01/T-04-02)
- Create useListSSE.ts: bounded-backoff EventSource wrapper (250ms→500ms→1s→2s→4s→cap 8s); MAX_ATTEMPTS=6; withCredentials:true; close-before-retry prevents reconnect storm (Pitfall 3); invalidates ['list', listId] on open (D-10) and on each event; onStateChange('disconnected') after exhaustion (D-11)
- Create LiveSyncIndicator.tsx: connected=green dot; reconnecting=pulsing muted dot + label; disconnected=red dot + 'Updates paused' (role=alert); correct ARIA per UI-SPEC
- Wire useListSSE + LiveSyncIndicator into ListDetail header; retain refetchInterval:30000 polling fallback (D-12)
- All 8 useListSSE tests pass; all 54 API tests pass; both typechecks pass
- playwright-cli: live update confirmed (eggs item added via API appeared in browser without manual refresh)
2026-06-09 13:33:04 -04:00
Lucas Berger 5a8d1efe1c test(04-06): add failing RED tests for LIST-04 SSE fan-out + bounded backoff
- API: 5 failing fan-out spy tests (subscribeListEvents receives 0 events since publishListEvent seams commented out in lists.ts)
- API: 4 D-04 scoped subscription tests (green — listAccess primitives from 04-02 already proven)
- PWA: useListSSE.test.ts — all 7 tests fail (module-not-found; hook not yet created)
- Covers: item:added/updated/deleted, list:updated/deleted fan-out + D-11 bounded backoff exhaustion + D-10 reconnect invalidation
2026-06-09 13:25:39 -04:00
Lucas Berger d49c5f1c9c feat(04-05): dnd-kit drag-to-reorder active items (LIST-03, D-13/D-14/D-15)
- ItemRow: useSortable with drag listeners scoped to GripVertical handle only;
  CSS transform animation for remote reorders (D-14); grabbing cursor when dragging
- ListDetail: DndContext/SortableContext over active items; PointerSensor (immediate),
  TouchSensor (200ms delay + 5px tolerance — no accidental scroll drags),
  KeyboardSensor (accessibility fallback)
- onDragEnd: computes generateKeyBetween(prevRank, nextRank) at destination, fires
  optimistic setQueryData then PATCHes { position: newRank } — one-row write (D-13)
- Rollback on PATCH error restores previous order via onError (D-15 LWW convergence)
- Completed items receive no drag handle (not reorderable per UI-SPEC)
2026-06-09 13:13:11 -04:00
Lucas Berger 6da9c2ae7b feat(04-04): add ListDetail with active/completed split + ItemRow + AddItemInput (LIST-02)
- listsClient.ts: add fetchListItems, addItem, patchListItem, deleteItem + ListItemsResponse type
- ListDetail.tsx: replace placeholder with real implementation — useQuery(['list', listId])
  with 30s polling fallback (D-12); active/completed split (D-05); optimistic mutations (D-07);
  delete-wins no-rollback (D-09); per-field check PATCH (D-08)
- ItemRow.tsx: 44px touch target, checkbox (20px visual/44px touch, accent fill when checked),
  plain-text item text (T-04-06 XSS guard), GripVertical handle slot for Plan 05,
  hover Trash2 delete + swipe-left zone, transform 150ms ease-out animation slot (D-14)
- AddItemInput.tsx: sticky bottom input + Add button, disabled when empty, Enter key support
- ListDetail.test.tsx: 7 real tests replacing todo stubs — optimistic add/check/uncheck/delete,
  rollback on error, D-05 completed-sink split, D-09 delete-wins no-rollback
- Playwright browser check: add milk → sinks to Completed on check → vanishes on delete PASS
2026-06-09 13:01:49 -04:00
Lucas Berger 95dbc663c1 feat(04-03): wire ListsIndex + ListCard + CreateListSheet + ListDeleteDialog (LIST-01)
- listsClient.ts: add createList/patchList/deleteList + List/ListItem types with activeCount/doneCount
- ListsEmptyState.tsx: extracted standalone component (ClipboardList icon, UI-SPEC copy)
- ListCard.tsx: name/count badge/Shared pill/ChevronRight; hover-reveal delete button; navigates /lists/:id
- CreateListSheet.tsx: bottom-sheet/modal; Shared default (D-01); optimistic useMutation; auto-focus; Escape to close
- ListDeleteDialog.tsx: mirrors DeleteConfirmationDialog pattern; props-driven (no calendarStore); XSS guard on name
- ListsIndex.tsx: replaced placeholder with real data via useQuery+useMutation; mounts CreateListSheet+ListDeleteDialog
- DeleteConfirmationDialog.tsx NOT modified (stable, D-06 pattern preserved)
- PWA typecheck passes; DeleteConfirmationDialog.test.tsx 10 passed
- Playwright E2E: create Groceries+Gift Ideas (Shared pills); delete dialog → confirm → card disappears
2026-06-09 12:45:19 -04:00
Lucas Berger c0088edf44 feat(04-01): wire BrowserRouter + BottomTabBar + empty Lists surface
- App.tsx: BrowserRouter with /calendar, /lists, /lists/:listId routes; / redirects to /calendar
- BottomTabBar.tsx: fixed-bottom 56px tab bar with Calendar + Lists NavLinks, active accent
- AppNav.tsx: add Calendar/Lists NavLinks to desktop sidebar (≥768px)
- ListsIndex.tsx: full-height surface with isLoading/isError/empty state branches; "+ New List" FAB placeholder
- ListDetail.tsx: placeholder stub for /lists/:listId (Plan 04-04 fills in)
- listsStore.ts: Zustand UI-only store (activeTab, createListSheetOpen)
- listsClient.ts: fetchLists + List/ListItem types (initial; Plans 04-02/03 expand)
- Fix Wave-0 RED stubs: add vitest imports so stubs execute (todo) not error on import
- Fix CalendarShell.test.tsx: wrap renderWithClient in MemoryRouter (AppNav uses NavLink)
2026-06-09 12:03:52 -04:00
Lucas Berger 39d4ec84c0 feat(04-01): install new deps + scaffold API test harness with Wave-0 RED stubs
- Add react-router@7, @dnd-kit/core, @dnd-kit/sortable, fractional-indexing to PWA
- Add fractional-indexing to API (rank generation server-side)
- ioredis NOT added (in-memory EventEmitter per RESEARCH Plan 02 justification)
- Create apps/api/test/setup.ts with afterEach DB cleanup for list tables
- Wire test.setupFiles in apps/api/vitest.config.ts
- Add 4 Wave-0 RED stub test files (LIST-01/02/03/04, D-04, D-11, D-07)
- All stubs run as todo, not import-error
2026-06-09 11:49:10 -04:00
Lucas Berger eed178fb39 fix(03): WR-01/WR-02/IN-02 recurrence-edit helper text, all-day toggle clamp, edit-mode parse-failure guard 2026-06-09 11:02:09 -04:00
Lucas Berger 7e4ea710d0 fix(03): IN-03 guard localStorage access in InstallPrompt 2026-06-09 10:43:37 -04:00
Lucas Berger 1c71f8c980 fix(03): WR-06 surface move-failed distinctly with re-save guidance 2026-06-09 10:41:55 -04:00
Lucas Berger d34edece96 fix(03): WR-03 use cleaned string for all-day check in parseDateTime 2026-06-09 10:39:54 -04:00
Lucas Berger 02aa407764 fix(03): WR-01 preserve existing RRULE on edit instead of resetting to none 2026-06-09 10:39:06 -04:00
Lucas Berger f645644853 fix(03): CR-03 convert exclusive all-day end to inclusive on edit pre-fill 2026-06-09 10:37:13 -04:00
Lucas Berger d4d5327fc4 fix(calendar): all-day off-by-one (exclusive DTEND) + member color too close to shared rose
All-day: a single-day all-day event displayed across two days. iCal all-day
DTEND is EXCLUSIVE (1-day event = DTSTART:24/DTEND:25) and the server occurrence
carries that exclusive end, but Schedule-X treats all-day end as INCLUSIVE.
hydrateEvents now subtracts one day (clamped to >= start) so a 1-day event shows
on one day and an N-day event spans N days. Write path was already correct
(verified against stored VEVENTs). +regression test.

Color: a member's coral (#E8734A) was mistaken for the shared-family rose
(#F25C7A). Reorder COLOR_PALETTE so warm near-rose hues (amber, coral) are
assigned LAST; early members get cool, clearly-distinct colors (blue/green/teal).
2026-06-07 18:37:56 -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 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 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 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 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 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 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 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 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 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 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 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 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