10 KiB
phase: 02-calendar-display plan: "05" subsystem: pwa-calendar-ux tags: [event-popover, color-legend, app-nav, view-toolbar, skeleton, empty-state, xss-guard, tdd] dependency_graph: requires: ["02-04"] provides: [EventDetailPopover, ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState, CalendarShell-chrome] affects: ["phase-03"] tech_stack: added: [] patterns: - EventDetailPopover dual-mode — Zustand openEventId (standalone) + customComponents.eventModal (Schedule-X) - queryClient.getQueriesData for cross-query cache lookup by event id - ViewToolbar accesses Schedule-X internal calendarApp.$app.calendarState for navigation - SkeletonCalendar shimmer via CSS animation from tokens.css @keyframes shimmer - TDD RED commit (test only) → GREEN commit (feat + test) per plan task 1 gate - "@testing-library/jest-dom" imported in test-setup.ts for toHaveTextContent matcher key_files: created: - apps/pwa/src/components/EventDetailPopover.tsx - apps/pwa/src/components/EventDetailPopover.test.tsx - apps/pwa/src/components/ColorLegend.tsx - apps/pwa/src/components/AppNav.tsx - apps/pwa/src/components/ViewToolbar.tsx - apps/pwa/src/components/SkeletonCalendar.tsx - apps/pwa/src/components/EmptyState.tsx modified: - apps/pwa/src/components/CalendarShell.tsx - apps/pwa/src/components/CalendarShell.test.tsx - apps/pwa/src/test-setup.ts - apps/pwa/src/api/client.ts deleted: - apps/pwa/src/components/EventProof.tsx decisions:
- "EventDetailPopover dual-mode: standalone (Zustand openEventId + TanStack Query cache) AND Schedule-X customComponents.eventModal"
- "ViewToolbar navigation via calendarApp.$app.calendarState — Schedule-X internal API; typed as any, runtime-guarded"
- "Phase 3 footer action area reserved in EventDetailPopover with code comment (D-08)"
- "Legacy fetchEventsLegacy / CalendarEvent types removed from client.ts along with EventProof deletion" metrics: duration: "~30m" completed: "2026-06-05" tasks_completed: 2 tasks_pending: 1 files_created: 7 files_modified: 4 files_deleted: 1
Phase 02 Plan 05: Calendar UX — Popover, Chrome, States Summary
Read-only EventDetailPopover (XSS-safe, accessible, Phase-3-reusable), always-visible ColorLegend, AppNav/ViewToolbar chrome, and skeleton/empty/error states wired into CalendarShell; EventProof retired.
What Was Built
Task 1: EventDetailPopover + CalendarShell wiring (TDD)
apps/pwa/src/components/EventDetailPopover.tsx (259 lines):
- Resolves open event by
openEventId(Zustand) from TanStack Query['events']cache usingqueryClient.getQueriesData - Dual-mode: standalone (primary, driven by Zustand) +
customComponents.eventModalprop from Schedule-X - Renders title (heading), date/time, location (with MapPin icon when present), description (max-4-lines scroll), calendar name + color swatch footer
- T-02e-01 XSS guard: all event fields as plain-text JSX children — no raw HTML injection
- Focus trap on open, Escape to close (document keydown listener), backdrop-click to close
- Close "×" button with
aria-label="Close"and 44px minimum touch target - Phone: bottom-sheet layout (fixed bottom, rounded top corners); tablet/desktop: centered popover (max-width 360px)
- Phase 3 footer action area reserved with comment — Phase 3 adds edit/delete actions there (D-08)
- Wired in CalendarShell:
customComponents={{ eventModal: EventDetailPopover }}AND rendered standalone after the layout
apps/pwa/src/components/EventDetailPopover.test.tsx (192 lines, TDD RED → GREEN):
- TDD RED commit: tests written first, failing because file missing
- GREEN commit: implementation makes all 12 tests pass
- Tests: title/location/description/calendarName render as text; heading role; Escape/close-button/backdrop all call
setOpenEventId(null); null openEventId renders nothing - XSS guard:
<script>alert("xss")</script>in title →heading.innerHTMLdoes NOT contain<script>;<b>in description →descEl.innerHTMLdoes NOT contain<b>
apps/pwa/src/test-setup.ts (deviation fix): Added import '@testing-library/jest-dom' to enable toHaveTextContent and other jest-dom matchers project-wide.
Task 2: Chrome components, state branches, EventProof retired
apps/pwa/src/components/ColorLegend.tsx:
- One row per member: 12px color circle (
aria-label="{name}: {hex}") + display name - "Family" row always rendered last using
--color-shared-family(#F25C7A) - Font: 13px label weight,
--color-text-secondary
apps/pwa/src/components/AppNav.tsx:
- Phone: 48px top bar — "FamilySync" display text left, user avatar right with
aria-label+titleper reviewer note - Tablet/desktop: 240px left sidebar — app name + "Calendars" section header +
<ColorLegend>
apps/pwa/src/components/ViewToolbar.tsx:
- Today | ‹ | › | Day | Week | Month | Agenda
- 44px min-height on all buttons; keyboard-activatable
- Active view:
rgba(74, 144, 217, 0.12)surface tint (NOT accent color) per UI-SPEC 60/30/10 rule - Navigation via
calendarApp.$app.calendarState.setRange()/setView()(internal Schedule-X API)
apps/pwa/src/components/SkeletonCalendar.tsx:
- Month variant: 6×7 grid of shimmer cells + 7-col header
- Agenda variant: 4 date-group blocks, 2–3 rows each at 60–90% widths
aria-busy="true",aria-label="Loading calendar"on root- Shimmer:
@keyframes shimmerfrom tokens.css,background-size: 200% 100%, 1.5s infinite
apps/pwa/src/components/EmptyState.tsx:
CalendarDayslucide icon (32px,--color-text-muted)- Heading "Nothing here" + body "No events in this period. Try a different date or switch views." per UI-SPEC copywriting
apps/pwa/src/components/CalendarShell.tsx (major refactor):
- Phone: AppNav top bar → ViewToolbar → calendar content → ColorLegend below
- Tablet/desktop: AppNav sidebar (240px) + main area (ViewToolbar → calendar content)
- State branches:
isInitialLoading→SkeletonCalendar;isEventsError→ "Couldn't load events" + "Check your connection" + Retry button callingqueryClient.refetchQueries({ queryKey: ['events'] });isEmptyResult→EmptyState; success+data →ScheduleXCalendar EventDetailPopoverrendered standalone after layout wrapper
EventProof.tsx deleted; CalendarEvent / EventsResponse / fetchEventsLegacy removed from client.ts.
Verification Results
pnpm --filter @familysync/pwa test
Test Files 5 passed (5)
Tests 36 passed (36)
tsc --noEmit: clean (0 errors)
vite build: clean (490.30 kB, 827ms)
Deviations from Plan
Auto-fixed Issues
1. [Rule 3 - Blocking] @testing-library/jest-dom not imported in test-setup.ts
- Found during: Task 1 GREEN phase —
toHaveTextContentthrew "Invalid Chai property" - Issue:
@testing-library/jest-domextends Vitest/Chai'sexpectwith DOM matchers. It was installed (in devDependencies) but never imported insrc/test-setup.ts, so matchers were not registered. - Fix: Added
import '@testing-library/jest-dom'tosrc/test-setup.ts - Files modified:
apps/pwa/src/test-setup.ts - Commit:
3eebfbf(bundled with Task 1 GREEN commit)
2. [Rule 1 - Bug] CalendarShell.test.tsx: synchronous getByTestId fails after loading state added
- Found during: Task 2 verification — existing CalendarShell smoke test failed
- Issue: The test did
screen.getByTestId('schedule-x-calendar')synchronously, but CalendarShell now shows SkeletonCalendar while loading. The calendar element only appears after queries resolve. - Fix: Changed to
await screen.findByTestId('schedule-x-calendar')(async, waits for element) - Files modified:
apps/pwa/src/components/CalendarShell.test.tsx - Commit:
216ddce(bundled with Task 2 commit)
3. [Rule 1 - Bug] ViewToolbar: CalendarApp.setDate/decrementRange/incrementRange/setView don't exist on public API
- Found during: Task 2 tsc check — 4 type errors
- Issue:
CalendarAppclass only exposesrender,destroy,setTheme,getTheme, andevents(EventsFacade). Navigation methods (setRange,setView) live on the internal$app.calendarState(aCalendarAppSingletonproperty). - Fix: Changed
calendarAppprop type toany, accessed internal state viacalendarApp.$app.calendarStatewith runtime null-guards. Navigation usesTemporal.Now.plainDateISO()for today andZonedDateTime.until().daysfor range inference. - Files modified:
apps/pwa/src/components/ViewToolbar.tsx - Commit:
216ddce(bundled with Task 2 commit)
Task 3 Status
Task 3 (checkpoint:human-verify) is pending operator verification — see "Human Verify Checkpoint" section below. No code changes in Task 3.
Known Stubs
None — all components render from live data (TanStack Query cache) or accurate zero-state UI. The Phase 3 footer in EventDetailPopover is an intentionally empty reserved area, not a stub.
Threat Flags
T-02e-01 mitigated:
- EventDetailPopover: title, description, location, calendarName all rendered as plain-text JSX children
- Test asserts
<script>alert("xss")</script>in title →heading.innerHTMLdoes NOT contain<script>, textContent DOES contain the literal string - Test asserts
<b>Bold</b>in description →descEl.innerHTMLdoes NOT contain<b>
No new threat surface beyond the plan's threat model.
Human Verify Checkpoint (Task 3 — awaiting operator)
The plan gates on operator visual verification. The automated tasks (1 and 2) are complete and committed. Task 3 requires the operator to run the dev stack and confirm the four phase success criteria. See the structured checkpoint returned in the agent's final message.
Self-Check: PASSED
Files created:
- apps/pwa/src/components/EventDetailPopover.tsx
- apps/pwa/src/components/EventDetailPopover.test.tsx
- apps/pwa/src/components/ColorLegend.tsx
- apps/pwa/src/components/AppNav.tsx
- apps/pwa/src/components/ViewToolbar.tsx
- apps/pwa/src/components/SkeletonCalendar.tsx
- apps/pwa/src/components/EmptyState.tsx
Files deleted:
- apps/pwa/src/components/EventProof.tsx (confirmed ABSENT)
Commits: