---
phase: 02-calendar-display
plan: 05
type: execute
wave: 4
depends_on: ['02-04']
files_modified:
  - apps/pwa/src/components/EventDetailPopover.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
  - apps/pwa/src/components/CalendarShell.tsx
  - apps/pwa/src/components/EventProof.tsx
  - apps/pwa/src/components/EventDetailPopover.test.tsx
autonomous: false
requirements: [CAL-02, CAL-03, CAL-07]
user_setup: []

must_haves:
  truths:
    - 'Tapping any event opens a read-only detail popover (title, date/time, location, description, calendar name + owner color) built for Phase 3 reuse as the edit surface'
    - "A color legend (member → color, plus 'Family' rose row) is always visible so ownership is decodable"
    - 'Initial load shows a shimmer skeleton; a successful fetch with zero events shows the empty state; a failed fetch shows the error state with a working Retry'
    - 'All chrome (AppNav, ViewToolbar, legend) is token-styled with 44px minimum touch targets and is keyboard/focus accessible'
    - 'The popover never renders event fields via dangerouslySetInnerHTML (XSS guard) and traps focus with Escape-to-close'
  artifacts:
    - path: 'apps/pwa/src/components/EventDetailPopover.tsx'
      provides: 'read-only event detail popover; Phase-3-reusable edit surface; focus trap + XSS-safe'
      exports: ['EventDetailPopover']
    - path: 'apps/pwa/src/components/ColorLegend.tsx'
      provides: 'always-visible member→color legend with Family row'
      exports: ['ColorLegend']
    - path: 'apps/pwa/src/components/SkeletonCalendar.tsx'
      provides: 'shimmer loading skeleton (month + agenda variants)'
      exports: ['SkeletonCalendar']
  key_links:
    - from: 'apps/pwa/src/components/CalendarShell.tsx'
      to: 'apps/pwa/src/components/EventDetailPopover.tsx'
      via: 'customComponents.eventModal + openEventId from Zustand'
      pattern: 'EventDetailPopover'
    - from: 'apps/pwa/src/components/CalendarShell.tsx'
      to: 'SkeletonCalendar | EmptyState | error state'
      via: 'TanStack Query isLoading/empty/isError branches'
      pattern: 'SkeletonCalendar'
---

<objective>
Complete the calendar UX: the read-only EventDetailPopover (built for Phase 3 reuse), the
always-visible ColorLegend, the AppNav + ViewToolbar chrome, and the loading / empty / error states
— all token-styled, accessible, and touch-friendly. Wire the popover and state branches into
CalendarShell, retire the Phase 1 EventProof, and gate the phase on a human visual verification.

Purpose: D-07 (legend), D-08 (tap-to-expand popover reusable in Phase 3), and the "slick"
constraint (skeleton/empty/error polish) land here. This closes the four phase success criteria
into a verifiable, glanceable calendar.
Output: popover + legend + nav + toolbar + skeleton + empty/error states wired into CalendarShell;
EventProof removed; human-verify checkpoint.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-calendar-display/02-UI-SPEC.md
@.planning/phases/02-calendar-display/02-PATTERNS.md
@.planning/phases/02-calendar-display/02-04-SUMMARY.md
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: EventDetailPopover (read-only, accessible, XSS-safe, Phase-3-reusable) + wire into CalendarShell</name>
  <files>apps/pwa/src/components/EventDetailPopover.tsx, apps/pwa/src/components/CalendarShell.tsx, apps/pwa/src/components/EventDetailPopover.test.tsx</files>
  <read_first>
    - apps/pwa/src/components/CalendarShell.tsx (Plan 04: openEventId via setOpenEventId, reserved customComponents.eventModal slot, the hydrated events in the TanStack Query cache)
    - apps/pwa/src/App.tsx MemberBadge (component prop + inline-style analog, lines 36–55)
    - apps/pwa/src/store/calendarStore.ts (openEventId / setOpenEventId)
    - .planning/phases/02-calendar-display/02-UI-SPEC.md §"EventDetailPopover" + §"Interaction Contract: Keyboard / accessibility" + §"Copywriting Contract" (close = "×", aria-label="Close")
    - .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/components/EventDetailPopover.tsx" (focus trap, Escape, never dangerouslySetInnerHTML)
    - .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 4" (customComponents eventModal) + §Security (XSS via event fields)
  </read_first>
  <behavior>
    - popover renders title (heading), date/time line, location line when present, description block when present, and calendar name + owner color swatch
    - Escape closes the popover and clears openEventId; clicking the backdrop closes it
    - event title/description rendered as plain text children (no dangerouslySetInnerHTML)
    - close button has aria-label="Close" and a ≥44px touch target
  </behavior>
  <action>
    Create `apps/pwa/src/components/EventDetailPopover.tsx` exporting `EventDetailPopover`. It resolves the open event from the TanStack Query events cache by `openEventId` (Zustand) — or accepts the Schedule-X eventModal `calendarEvent` prop when used as `customComponents.eventModal`. Render per UI-SPEC §EventDetailPopover: color chip + title (--text-heading), date/time (--text-label, secondary), location with a lucide-react MapPin icon if present, description block (max 4 lines then scroll), and a calendar-name + owner-color-swatch footer. Reserve an empty footer action area with a comment noting Phase 3 adds edit/delete here (D-08). On phone render as a bottom sheet; on tablet/desktop as an anchored popover (max-width 360px) — use the --bp-* tokens. Implement: focus trap while open, Escape closes (calls setOpenEventId(null)), focus returns to the triggering element, close "×" button aria-label="Close" with min 44px target, backdrop tap closes. NEVER use dangerouslySetInnerHTML for any event field (XSS guard). Token-styled only.

    Wire into CalendarShell: pass `customComponents={{ eventModal: EventDetailPopover }}` to `<ScheduleXCalendar>`, and ensure the event-click path sets openEventId so the popover opens. Keep the eventsService/eventModal plugin wiring from Plan 04.

    Write `EventDetailPopover.test.tsx` (jsdom): renders an event's fields as text, Escape triggers close, and asserts no dangerouslySetInnerHTML usage (render a title containing an HTML-looking string and assert it appears escaped as text).

  </action>
  <verify>
    <automated>cd apps/pwa && pnpm test -- src/components/EventDetailPopover.test.tsx</automated>
    <automated>cd apps/pwa && grep -q "customComponents" src/components/CalendarShell.tsx && grep -q "EventDetailPopover" src/components/CalendarShell.tsx && ! grep -q "dangerouslySetInnerHTML" src/components/EventDetailPopover.tsx && echo POPOVER_WIRED_XSS_SAFE</automated>
  </verify>
  <acceptance_criteria>
    - EventDetailPopover.tsx exports EventDetailPopover and renders title/time/location/description/calendar-name+color
    - Escape closes and clears openEventId; close button has aria-label="Close" and ≥44px target
    - No dangerouslySetInnerHTML anywhere in EventDetailPopover.tsx
    - CalendarShell passes customComponents.eventModal = EventDetailPopover
    - EventDetailPopover.test.tsx green incl. the escaped-HTML-as-text assertion
  </acceptance_criteria>
  <done>Tap-to-expand read-only popover (accessible, XSS-safe, Phase-3-reusable) wired into the calendar; test green.</done>
</task>

<task type="auto">
  <name>Task 2: ColorLegend + AppNav + ViewToolbar + skeleton/empty/error states; retire EventProof</name>
  <files>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, apps/pwa/src/components/CalendarShell.tsx, apps/pwa/src/components/EventProof.tsx</files>
  <read_first>
    - apps/pwa/src/components/CalendarShell.tsx (Task 1 + Plan 04: eventsQuery isLoading/isError/data, calendars config for legend, selectedView/setSelectedView)
    - apps/pwa/src/App.tsx (MemberBadge + ColorSwatch analogs for legend swatches)
    - apps/pwa/src/components/EventProof.tsx (Phase 1 proof component to delete; confirm no remaining imports)
    - .planning/phases/02-calendar-display/02-UI-SPEC.md §"Component Inventory" (ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState) + §"Copywriting Contract" + §"Interaction Contract" + §"Post-Verification Reviewer Notes" (grid is primary focal point; phone-nav avatar needs aria-label+title)
    - .planning/phases/02-calendar-display/02-PATTERNS.md §"ColorLegend", §"SkeletonCalendar" (shimmer keyframe), §"CSS Token Usage in Components"
  </read_first>
  <action>
    Create the chrome and state components, token-styled, 44px touch targets, accessible per UI-SPEC:
    - `ColorLegend.tsx`: one row per member (12px color circle + display name) plus a "Family" row using --color-shared-family. Always rendered, non-interactive (filter deferred, D-07). Swatch aria-label="{name}: {hex}". Members sourced from the calendars config / /api/me.
    - `AppNav.tsx`: phone = 48px top bar (app name "FamilySync" left, user color swatch right with aria-label + title per reviewer note); tablet/desktop = 240px left sidebar (app name + ColorLegend). Accent colors NOT used on chrome (UI-SPEC 60/30/10).
    - `ViewToolbar.tsx`: Today | < | > | [Day][Week][Month][Agenda]. Buttons role="button", keyboard-activatable, 44px min height, --text-label. Active view uses a subtle surface tint (NOT accent). Calls setSelectedView + drives Schedule-X view; prev/next/today drive Schedule-X navigation.
    - `SkeletonCalendar.tsx`: shimmer (the @keyframes shimmer from tokens.css), month variant = 6×7 placeholder grid, agenda variant = 4 date-group blocks; root aria-busy="true", aria-label="Loading calendar". No spinner.
    - `EmptyState.tsx`: centered lucide-react CalendarDays (32px, --color-text-muted) + heading "Nothing here" + body "No events in this period. Try a different date or switch views." (UI-SPEC copy).

    In CalendarShell, render AppNav + ViewToolbar + ColorLegend chrome around `<ScheduleXCalendar>` (grid is the primary focal point per reviewer note). Branch on the events query: `isLoading` (initial) → SkeletonCalendar; success + `occurrences.length === 0` → EmptyState; `isError` (after retry:2) → error state replacing the grid with heading "Couldn't load events", body "Check your connection and try again.", and a "Retry" button calling `queryClient.refetchQueries({ queryKey: ['events'] })`. All token-styled.

    Delete `apps/pwa/src/components/EventProof.tsx` and remove any remaining imports/references to it (Plan 04 removed it from the render path; confirm the file and its imports are gone).

  </action>
  <verify>
    <automated>cd apps/pwa && grep -q "SkeletonCalendar" src/components/CalendarShell.tsx && grep -q "EmptyState" src/components/CalendarShell.tsx && grep -q "Couldn't load events" src/components/CalendarShell.tsx && grep -q "ColorLegend" src/components/CalendarShell.tsx && echo STATES_WIRED</automated>
    <automated>cd /home/luc/Projects/familysync && ! test -f apps/pwa/src/components/EventProof.tsx && ! grep -rq "EventProof" apps/pwa/src && echo EVENTPROOF_REMOVED</automated>
    <automated>cd apps/pwa && pnpm exec tsc --noEmit && pnpm exec eslint src --max-warnings=0 2>/dev/null || pnpm exec tsc --noEmit</automated>
  </verify>
  <acceptance_criteria>
    - ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState exist and are token-styled (no hard-coded hex/px)
    - CalendarShell renders the chrome and branches loading→Skeleton, empty→EmptyState, error→error state with working Retry (refetchQueries(['events']))
    - ViewToolbar buttons are 44px min height, keyboard-activatable; active view uses a surface tint not accent
    - EventProof.tsx is deleted and no references to it remain anywhere in apps/pwa/src
    - tsc --noEmit clean in apps/pwa
  </acceptance_criteria>
  <done>Legend, nav, toolbar, and loading/empty/error states are wired and token-styled; EventProof retired; PWA typechecks.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
  <name>Task 3: [CHECKPOINT] Visual + functional verification of the calendar (4 success criteria)</name>
  <action>Operator-only manual verification: run the dev stack behind the dev-auth bypass and confirm all four phase success criteria against the live calendar UI. No code is written in this task. Follow the steps in how-to-verify exactly and report pass/fail per criterion.</action>
  <what-built>
    The complete read-only calendar: unified color-coded events across day/week/month/agenda, the
    always-visible color legend, tap-to-expand read-only detail popover, and polished
    skeleton/empty/error states — all on the clean token theme, behind the dev-auth bypass.
  </what-built>
  <how-to-verify>
    1. Start the stack in dev with the bypass: ensure `NODE_ENV` is not production and `DEV_AUTH_BYPASS=true`, then run the API + PWA dev servers (e.g. `pnpm -r dev` or the project's documented dev command). Confirm the shared-family calendar was marked is_shared (Plan 02 checkpoint).
    2. Open the PWA in a desktop browser. CONFIRM (success criterion 1): events appear color-coded — each member's events in their assigned color, shared-family events in the rose; the legend decodes which color is whom.
    3. Switch Day / Week / Month / Agenda (success criterion 2): all events render correctly in each view; no missing or misplaced events.
    4. Find a recurring event (e.g. a weekly meeting) and confirm (success criterion 3) all its occurrences show in the current window; navigate across a DST boundary (March 2026) and confirm the time does not jump ±1 hour.
    5. Find an all-day event (birthday/holiday) and confirm (success criterion 4) it appears as a full-day banner on the correct date — not shifted a day early/late.
    6. Tap an event: the read-only popover opens with title/time/location/description; Escape and backdrop-tap both close it.
    7. Resize to a phone width (or open on a phone via the dev URL): confirm the default view is Agenda and the popover is a bottom sheet.
    8. Force the empty state (navigate to a far-future empty window) and the error state (stop the API, hit Retry) and confirm both render as specified.
  </how-to-verify>
  <resume-signal>Type "approved" if all four success criteria hold, or describe the specific view/event/state that is wrong.</resume-signal>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary                   | Description                                          |
| -------------------------- | ---------------------------------------------------- |
| event fields → popover DOM | title/location/description rendered into the popover |

## STRIDE Threat Register

| Threat ID | Category          | Component                          | Disposition | Mitigation Plan                                                                                           |
| --------- | ----------------- | ---------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------- |
| T-02e-01  | Tampering (XSS)   | EventDetailPopover event fields    | mitigate    | Plain-text JSX children only; no dangerouslySetInnerHTML; test asserts HTML-looking title renders escaped |
| T-02e-02  | Denial of service | Retry button hammering /api/events | accept      | Manual user action, retry:2 backoff already on the query; two-person self-hosted app, negligible risk     |

</threat_model>

<verification>
- `pnpm --filter @familysync/pwa test` green (popover test)
- `pnpm -r test` + `tsc --noEmit` clean in both workspaces (phase gate)
- Human-verify checkpoint passes all four success criteria
</verification>

<success_criteria>

- Color-coded events + legend decode ownership (criterion 1)
- All four views render events correctly (criterion 2)
- Recurring occurrences correct incl. DST (criterion 3)
- All-day events as full-day banners with no shift (criterion 4)
- Tap-to-expand popover + skeleton/empty/error states polished and accessible
  </success_criteria>

<artifacts_produced>

## Artifacts this phase produces (Plan 05)

- `EventDetailPopover` (React component, Phase-3-reusable edit surface) — EventDetailPopover.tsx
- `ColorLegend`, `AppNav`, `ViewToolbar`, `SkeletonCalendar`, `EmptyState` (React components)
- CalendarShell: chrome + loading/empty/error branches + customComponents.eventModal wiring
- EventProof.tsx DELETED (Phase 1 proof component retired)
- EventDetailPopover.test.tsx
  </artifacts_produced>

<output>
Create `.planning/phases/02-calendar-display/02-05-SUMMARY.md` when done
</output>
