218 lines
16 KiB
Markdown
218 lines
16 KiB
Markdown
---
|
||
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>
|