Plan 06-05 added hasRrule as a required field on the PWA CalendarOccurrence
type (mirroring 06-03's server-side field), but pre-existing fixtures in
EventDetailPopover.test.tsx did not set it — breaking tsc --noEmit / vite build
(vitest passed because esbuild strips types). Both fixtures are non-recurring
single events, so hasRrule: false is correct. Post-merge integration fix.
- Import computeNewTimedEnd/computeNewAllDayEnd from eventDateTime.ts (D-04)
- Start date onChange: calls computeNewAllDayEnd (all-day) or computeNewTimedEnd (timed)
- Start time onChange: calls computeNewTimedEnd preserving duration (timed only)
- Add recurrenceBound/recurrenceUntil/recurrenceCount state (D-06)
- Reset useEffect extended to reset bound state on form open
- Add 'Ends' control (Never/On date/After N times) shown when recurrence != none
- Inline validation: count < 1 and until < start
- Payload conditionally includes recurrenceUntil/recurrenceCount (create mode only)
- Error state type extended for recurrenceBound validation
- Add hasRrule to EDIT_OCCURRENCE, RECURRING_OCCURRENCE, LATE_OCCURRENCE fixtures
- Add ALL_DAY_OCCURRENCE fixture for D-05 round-trip test
- Add test cases: D-04 timed/all-day end-tracking on start change
- Add test cases: D-05 all-day edit pre-fills inclusive end (no drift)
- Add test cases: D-06 Ends control visibility, On date/After N times reveals
- Add test cases: D-06 validation (count < 1), payload emission (count, never)
- Add @keyframes pulse (0%,100% opacity:1; 50% opacity:0.4) to tokens.css after @keyframes spin
- Remove redundant local <style> block redefining @keyframes spin from PushPermissionPrompt.tsx
- LiveSyncIndicator reconnecting dot now resolves its pulse animation from the global stylesheet
- PushPermissionPrompt spinner continues to work via the existing global spin keyframe
Pre-fetch ServiceWorkerRegistration into component state via useEffect in both
PushPermissionPrompt and SettingsSheet. Gate the subscribe tap target as disabled
until both vapidKey AND swRegistration are ready. The tap handler now has zero
awaits between the user gesture and registration.pushManager.subscribe(), fully
satisfying the iOS user-gesture requirement.
- 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
- 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)
- 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)
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.
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.
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.
- 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
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)
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.
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.
- 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
- 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)
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.
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).
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.
- 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.
- 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
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
- 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
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.