Files
familysync/.planning/phases/02-calendar-display/02-03-PLAN.md
T

257 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 02-calendar-display
plan: 03
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- apps/pwa/src/styles/tokens.css
- apps/pwa/src/styles/tokens.ts
- apps/pwa/src/styles/index.css
- apps/pwa/src/lib/colorUtils.ts
- apps/pwa/src/lib/colorUtils.test.ts
- apps/pwa/src/lib/calendarConfig.ts
- apps/pwa/src/lib/calendarConfig.test.ts
- apps/pwa/src/lib/hydrateEvents.ts
- apps/pwa/src/lib/hydrateEvents.test.ts
- apps/pwa/src/store/calendarStore.ts
- apps/pwa/src/api/client.ts
- apps/pwa/src/main.tsx
- apps/pwa/package.json
autonomous: true
requirements: [CAL-02, CAL-03, CAL-07]
user_setup: []
must_haves:
truths:
- "A CSS custom-property token layer (the clean theme) defines all colors, spacing, typography, breakpoints from UI-SPEC; no hard-coded hex/px will be needed by components — tuned to stay legible/informational at tablet distance, not ultra-minimal (D-03)"
- "Schedule-X --sx-color-* vars are mapped to project tokens so no Schedule-X default colors bleed through"
- "colorUtils derives Schedule-X lightColors (main/container/onContainer) from a member hex"
- "calendarConfig translates WEEK_START_DAY=0 (Sunday/JS) to Schedule-X firstDayOfWeek=7 (Temporal Sunday) and builds the per-calendar config keyed by String(userId) + 'shared'"
- "hydrateEvents converts all-day occurrences to Temporal.PlainDate and timed occurrences to Temporal.ZonedDateTime"
- "hydrateEvents routes each event's Schedule-X calendarId to 'shared' (isShared) or String(ownerUserId), matching the userId-keyed calendars config — never String(calendarId)"
- "calendarStore (Zustand) holds selectedView (persisted per breakpoint group), selectedDate, openEventId, calendarRange — no server data"
- "fetchEvents(start,end) calls the windowed /api/events with credentials:include and returns OccurrencesResponse"
artifacts:
- path: "apps/pwa/src/styles/tokens.css"
provides: "clean-theme CSS custom properties + Schedule-X var overrides"
contains: "--color-shared-family"
- path: "apps/pwa/src/lib/calendarConfig.ts"
provides: "WEEK_START_DAY, firstDayOfWeek translation, buildCalendarConfig()"
exports: ["WEEK_START_DAY", "buildCalendarConfig"]
- path: "apps/pwa/src/lib/hydrateEvents.ts"
provides: "hydrateEvents() ISO→Temporal with all-day PlainDate guard + isShared/ownerUserId calendarId routing"
exports: ["hydrateEvents"]
- path: "apps/pwa/src/store/calendarStore.ts"
provides: "Zustand UI-state store with localStorage view persistence"
exports: ["useCalendarStore"]
key_links:
- from: "apps/pwa/src/main.tsx"
to: "temporal-polyfill/global"
via: "import before any Schedule-X mount"
pattern: "temporal-polyfill/global"
- from: "apps/pwa/src/lib/calendarConfig.ts"
to: "apps/pwa/src/lib/colorUtils.ts"
via: "deriveScheduleXColors() for lightColors"
pattern: "deriveScheduleXColors"
- from: "apps/pwa/src/lib/hydrateEvents.ts"
to: "apps/pwa/src/lib/calendarConfig.ts"
via: "calendarId = isShared ? 'shared' : String(ownerUserId) matches buildCalendarConfig keys"
pattern: "ownerUserId"
---
<objective>
Build the frontend foundation the calendar render slice depends on: the CSS custom-property token
layer (D-01/D-02 — the load-bearing deliverable), the color-derivation utility, the Schedule-X
calendar config with the firstDayOfWeek translation, the ISO→Temporal hydration util with the
all-day PlainDate guard, the Zustand UI-state store, the windowed fetchEvents client, and the
Temporal-polyfill + theme-CSS imports in main.tsx.
Purpose: These are pure PWA library/style/store files with zero overlap with the backend plan, so
this runs in parallel with Plan 02. Plan 04 mounts Schedule-X and wires all of this into a
rendering calendar.
Output: tokens.css/ts/index.css, colorUtils, calendarConfig, hydrateEvents, calendarStore,
windowed fetchEvents, updated main.tsx, Schedule-X deps installed.
</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-RESEARCH.md
@.planning/phases/02-calendar-display/02-PATTERNS.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Install Schedule-X deps + token layer (tokens.css/ts/index.css) + main.tsx imports</name>
<files>apps/pwa/package.json, apps/pwa/src/styles/tokens.css, apps/pwa/src/styles/tokens.ts, apps/pwa/src/styles/index.css, apps/pwa/src/main.tsx</files>
<read_first>
- apps/pwa/src/main.tsx (current import order + QueryClientProvider setup)
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"Token Layer", §"Color Tokens", §"Spacing Scale", §"Typography", §"Breakpoints", §"Schedule-X CSS Override Strategy"
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Installation (PWA only)" (exact package versions) + §"Schedule-X CSS Token Override Pattern"
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/main.tsx" (Temporal-polyfill-first import order)
</read_first>
<action>
Install the Schedule-X stack in apps/pwa at the pinned versions from RESEARCH: `@schedule-x/calendar@4.6.0 @schedule-x/react@4.1.0 @schedule-x/theme-default@4.6.0 @schedule-x/event-modal@4.6.0 @schedule-x/events-service@4.6.0 temporal-polyfill@0.3.2 lucide-react@1.17.0` (via pnpm add in apps/pwa). These are the Approved packages from RESEARCH §Package Legitimacy.
Create `apps/pwa/src/styles/tokens.css` declaring on `:root` every token from UI-SPEC §Color Tokens (--color-surface, --color-surface-dim, --color-surface-raised, --color-border, --color-border-subtle, --color-text-primary/secondary/muted, --color-focus-ring, --color-overlay, --color-member-0..5 with the exact UI-SPEC hexes, --color-shared-family:#F25C7A, --color-destructive:#DC2626), §Spacing Scale (--space-1..12), §Typography (--font-family-base, --text-body/label/heading/display sizes+weights+line-heights), and §Breakpoints (--bp-phone/tablet/desktop). Then add the Schedule-X override block mapping --sx-color-* vars to these tokens per UI-SPEC §Schedule-X CSS Override Strategy (--sx-color-primary→--color-member-0, surface/on-surface/outline/neutral, --sx-font-family→--font-family-base). Include the `@keyframes shimmer` from PATTERNS for the skeleton.
Create `apps/pwa/src/styles/tokens.ts` exporting a typed object mirroring the same token values (so components can use them in inline-style props). Keep names aligned with the CSS var names.
Create `apps/pwa/src/styles/index.css` importing tokens.css, plus a minimal global reset (box-sizing border-box, body font-family var, margin 0) — no third-party reset library.
Update `apps/pwa/src/main.tsx`: as the FIRST three imports (before React), add `import 'temporal-polyfill/global'`, `import '@schedule-x/theme-default/dist/index.css'`, `import './styles/index.css'` (in that order — Temporal must register before any Schedule-X usage, and token overrides must come after the Schedule-X default CSS so they win). Leave the QueryClientProvider tree intact.
</action>
<verify>
<automated>cd apps/pwa && grep -q "temporal-polyfill/global" src/main.tsx && grep -q "@schedule-x/theme-default/dist/index.css" src/main.tsx && grep -q "./styles/index.css" src/main.tsx && echo MAIN_IMPORTS_OK</automated>
<automated>cd apps/pwa && grep -q -- "--color-shared-family: #F25C7A" src/styles/tokens.css && grep -q -- "--sx-color-" src/styles/tokens.css && echo TOKENS_OK</automated>
<automated>cd apps/pwa && node -e "require('@schedule-x/calendar');require('temporal-polyfill');require('lucide-react');console.log('DEPS_OK')"</automated>
</verify>
<acceptance_criteria>
- apps/pwa/package.json dependencies include all six @schedule-x/* + temporal-polyfill + lucide-react at the RESEARCH-pinned versions
- tokens.css declares --color-shared-family:#F25C7A, all --color-member-0..5, the spacing/typography/breakpoint tokens, and a --sx-color-* override block
- main.tsx imports temporal-polyfill/global FIRST, then theme-default CSS, then styles/index.css
- tokens.ts exports a token object mirroring the CSS var values
</acceptance_criteria>
<done>Schedule-X stack installed; clean-theme token layer + Schedule-X var overrides present; main.tsx imports Temporal polyfill + theme + tokens in correct order.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: colorUtils + calendarConfig (firstDayOfWeek translation) — turn RED stubs green</name>
<files>apps/pwa/src/lib/colorUtils.ts, apps/pwa/src/lib/colorUtils.test.ts, apps/pwa/src/lib/calendarConfig.ts, apps/pwa/src/lib/calendarConfig.test.ts</files>
<read_first>
- apps/pwa/src/lib/calendarConfig.test.ts (RED stub from Plan 01 — its 0→7 assertion is the contract)
- apps/pwa/src/App.tsx (ColorSwatch inline-style pattern, lines 1834, the colorUtils analog)
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"Color derivation rule for event chips" (container=15% over white; onContainer=darken 40%) + §"Calendar config constant"
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 3: Schedule-X Calendar Configuration" + §"Pitfall 1" (firstDayOfWeek 0→7) + §"Pitfall 6" (limit to confirmed Schedule-X API surface)
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/lib/colorUtils.ts" + §"apps/pwa/src/lib/calendarConfig.ts"
</read_first>
<behavior>
- colorUtils: deriveScheduleXColors('#4A90D9') returns { main:'#4A90D9', container: <15% over white>, onContainer: <darkened 40%> }
- calendarConfig: WEEK_START_DAY === 0 translates to Schedule-X firstDayOfWeek 7
- calendarConfig: buildCalendarConfig([{id:'1',name:'Lucas',color:'#4A90D9'}]) yields calendars['1'] with lightColors and a 'shared' entry colored from #F25C7A
</behavior>
<action>
Create `apps/pwa/src/lib/colorUtils.ts` exporting `hexToContainer(hex)` (main at 15% opacity blended over #FFFFFF → returns a hex/rgb string), `hexToOnContainer(hex)` (main darkened 40%), and `deriveScheduleXColors(main)` returning `{ main, container, onContainer }`. Implement the math inline (no third-party color lib per RESEARCH Don't-Hand-Roll note — it's simple enough). Write colorUtils.test.ts asserting the derivations for a known hex.
Create `apps/pwa/src/lib/calendarConfig.ts` exporting `export const WEEK_START_DAY = 0` with the inline comment that Schedule-X uses 7=Sunday, a translation `const sxFirstDay = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY` exposed as an exported `SX_FIRST_DAY_OF_WEEK`, the view factory list (`createViewDay`, `createViewWeek`, `createViewMonthGrid`, `createViewMonthAgenda` from @schedule-x/calendar), and `buildCalendarConfig(members: MemberCalendarConfig[])` returning `{ calendars }` keyed by String(userId) plus a reserved `'shared'` entry using deriveScheduleXColors('#F25C7A'). Per-member entries use deriveScheduleXColors(member.color). The `String(userId)` + `'shared'` key scheme here is the routing contract hydrateEvents (Task 3) must match — keep them aligned. Limit usage to the confirmed Schedule-X API surface (Pitfall 6). Turn the Plan 01 RED calendarConfig.test.ts green (it asserts the 0→7 translation).
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- src/lib/colorUtils.test.ts src/lib/calendarConfig.test.ts</automated>
<automated>cd apps/pwa && grep -q "WEEK_START_DAY === 0 ? 7" src/lib/calendarConfig.ts && echo WEEKSTART_TRANSLATED</automated>
</verify>
<acceptance_criteria>
- colorUtils.ts exports deriveScheduleXColors, hexToContainer, hexToOnContainer; colorUtils.test.ts green
- calendarConfig.ts exports WEEK_START_DAY (=0), the 0→7 firstDayOfWeek translation, and buildCalendarConfig
- buildCalendarConfig output keys per-member by String(userId) and includes a 'shared' entry from #F25C7A
- calendarConfig.test.ts (Plan 01 RED stub) passes the 0→7 assertion
</acceptance_criteria>
<done>colorUtils + calendarConfig built; firstDayOfWeek 0→7 translation encoded; both test files green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: hydrateEvents (Temporal, all-day guard, ownership-routed calendarId) + Zustand store + windowed fetchEvents</name>
<files>apps/pwa/src/lib/hydrateEvents.ts, apps/pwa/src/lib/hydrateEvents.test.ts, apps/pwa/src/store/calendarStore.ts, apps/pwa/src/api/client.ts</files>
<read_first>
- apps/pwa/src/lib/hydrateEvents.test.ts (RED stub from Plan 01 — PlainDate-vs-ZonedDateTime AND the calendarId='shared'/String(ownerUserId) routing contract)
- apps/pwa/src/api/client.ts (existing fetchMe pattern + the OLD unwindowed fetchEvents/EventsResponse to replace)
- apps/pwa/src/lib/calendarConfig.ts (Task 2 — buildCalendarConfig keys: String(userId) + 'shared'; hydrateEvents must produce calendarId values that match these keys)
- apps/api/src/broker/expand.ts CalendarOccurrence shape (if Plan 02 merged first: fields incl. calendarId, ownerUserId, isShared) OR .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 1" interface (the server JSON contract)
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 2" (hydrateEvents PlainDate/ZonedDateTime) + §"Pitfall 2/4"
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/src/store/calendarStore.ts" (Zustand state shape) + §"apps/pwa/src/api/client.ts"
- .planning/phases/02-calendar-display/02-UI-SPEC.md §"State Management Contract" + §"View default logic (D-05)"
</read_first>
<behavior>
- hydrateEvents: occurrence with allDay:true and start '2026-06-15' → start is Temporal.PlainDate (NOT ZonedDateTime — guards Pitfall 2)
- hydrateEvents: timed occurrence → start/end Temporal.ZonedDateTime from the offset-aware ISO string
- hydrateEvents: a shared occurrence (isShared:true) → Schedule-X calendarId === 'shared'
- hydrateEvents: a personal occurrence (isShared:false, ownerUserId:7) → Schedule-X calendarId === '7' (String(ownerUserId)), NOT String(occ.calendarId)
- hydrateEvents: passes uid/color/isShared through on a _familySync field
- calendarStore: setSelectedView persists to localStorage keyed by breakpoint group ('phone' | 'tablet-desktop')
</behavior>
<action>
Create `apps/pwa/src/lib/hydrateEvents.ts` exporting `ScheduleXEvent` interface and `hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[]`. Branch on `occ.allDay`: if true use `Temporal.PlainDate.from(occ.start)` for start/end (never construct a ZonedDateTime from midnight UTC — Pitfall 2); else `Temporal.ZonedDateTime.from(occ.start/end)`.
CRITICAL — calendarId routing: set the Schedule-X `calendarId` to `occ.isShared ? 'shared' : String(occ.ownerUserId)`. Do NOT use `String(occ.calendarId)``occ.calendarId` is the DB calendar-row id, but `buildCalendarConfig` keys the calendars config by `String(userId)` plus `'shared'`. A member who owns multiple calendars (e.g. the broker account exposes "Calendar" and "USA Holidays") would otherwise produce a calendarId that matches no config key, and Schedule-X would render those events with no color. Routing by `'shared' | String(ownerUserId)` is the contract that aligns with buildCalendarConfig's keys.
Carry uid/color/isShared on `_familySync`. Temporal is global via the main.tsx polyfill import; in tests import `'temporal-polyfill/global'` at the top of hydrateEvents.test.ts. Turn the Plan 01 RED hydrateEvents.test.ts green — including its shared→'shared' and personal→String(ownerUserId) assertions.
Create `apps/pwa/src/store/calendarStore.ts` exporting `useCalendarStore` (Zustand `create`) with state: `selectedView:string`, `selectedDate:string`, `openEventId:string|null`, `calendarRange:{start:string;end:string}` and setters. selectedView is initialized from localStorage keyed by breakpoint group (`window.matchMedia('(max-width:767px)').matches ? 'phone' : 'tablet-desktop'`), defaulting to 'month-agenda' on phone / 'month-grid' on tablet-desktop (D-05); setSelectedView writes back to localStorage under `calendarView.{group}`. calendarRange defaults to the current month ± 1 week (do NOT depend on Schedule-X onRangeUpdate for the first fetch — A4/Open Q2). Server events NEVER enter this store. Add `zustand` to apps/pwa deps if not already present.
In `apps/pwa/src/api/client.ts`, REPLACE the old unwindowed `fetchEvents()` and its `CalendarEvent`/`EventsResponse` types with: `CalendarOccurrence` interface (mirror the server contract — include calendarId, ownerUserId, isShared so hydrateEvents can route), `OccurrencesResponse { occurrences: CalendarOccurrence[] }`, and `fetchEvents(start:string, end:string): Promise<OccurrencesResponse>` calling `/api/events?start=${start}&end=${end}` with `credentials:'include'` and the same `if(!res.ok) throw` pattern as fetchMe. Note: EventProof.tsx referenced the old fetchEvents — leave EventProof for Plan 05 to remove; if the type change breaks its build, update EventProof minimally to compile (it is replaced in Plan 05).
</action>
<verify>
<automated>cd apps/pwa && pnpm test -- src/lib/hydrateEvents.test.ts</automated>
<automated>cd apps/pwa && grep -q "Temporal.PlainDate.from" src/lib/hydrateEvents.ts && grep -q "ownerUserId" src/lib/hydrateEvents.ts && grep -q "credentials: 'include'" src/api/client.ts && grep -q "calendarRange" src/store/calendarStore.ts && echo SLICE_LIB_OK</automated>
<automated>cd apps/pwa && pnpm exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- hydrateEvents.test.ts (Plan 01 RED stub) passes: all-day→PlainDate, timed→ZonedDateTime, shared→'shared', personal→String(ownerUserId)
- hydrateEvents.ts uses Temporal.PlainDate.from for all-day and never ZonedDateTime for all-day
- hydrateEvents.ts sets calendarId to `occ.isShared ? 'shared' : String(occ.ownerUserId)` (NOT String(occ.calendarId))
- calendarStore exports useCalendarStore with selectedView/selectedDate/openEventId/calendarRange and persists selectedView to localStorage per breakpoint group
- client.ts fetchEvents takes (start,end), hits /api/events?start=&end= with credentials:'include', returns OccurrencesResponse; CalendarOccurrence includes ownerUserId + isShared
- tsc --noEmit clean in apps/pwa
</acceptance_criteria>
<done>hydrateEvents (all-day PlainDate guard + ownership-routed calendarId), Zustand UI store, and windowed fetchEvents all built; hydrateEvents.test.ts green; PWA typechecks.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| localStorage → store init | persisted view string read at startup |
| server JSON → hydrateEvents | occurrence strings parsed into Temporal objects |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02c-01 | Tampering | localStorage selectedView | accept | UI-only state; an invalid stored view falls back to the D-05 default; no security impact (single-device, two-person app) |
| T-02c-02 | Tampering | hydrateEvents string parsing | accept | Temporal.from throws on malformed input surfaced as a React Query error, not a security boundary; data already passed server zod validation |
| T-02c-SC | Tampering | @schedule-x/*, temporal-polyfill, lucide-react, zustand installs | mitigate | All Approved in RESEARCH §Package Legitimacy (mainstream, no postinstall); pinned versions; no [ASSUMED]/[SUS] |
</threat_model>
<verification>
- `pnpm --filter @familysync/pwa test` green (colorUtils, calendarConfig, hydrateEvents)
- `tsc --noEmit` clean in apps/pwa
- main.tsx import order correct (Temporal first)
</verification>
<success_criteria>
- Clean-theme token layer + Schedule-X overrides present (D-01/D-02)
- firstDayOfWeek 0→7 translation encoded; per-calendar config built from member colors
- All-day Temporal PlainDate guard in place
- hydrateEvents calendarId routes by isShared/ownerUserId to match buildCalendarConfig keys
- Zustand UI store + windowed fetchEvents ready for Plan 04
</success_criteria>
<artifacts_produced>
## Artifacts this phase produces (Plan 03)
- CSS custom properties: --color-*, --space-*, --text-*, --font-family-base, --bp-*, --sx-color-* overrides (tokens.css)
- token object export (tokens.ts); styles/index.css global reset
- `hexToContainer`, `hexToOnContainer`, `deriveScheduleXColors` (colorUtils.ts)
- `WEEK_START_DAY`, `SX_FIRST_DAY_OF_WEEK`, `buildCalendarConfig`, `MemberCalendarConfig` (calendarConfig.ts)
- `hydrateEvents`, `ScheduleXEvent` (hydrateEvents.ts) — calendarId routed by isShared/ownerUserId
- `useCalendarStore` Zustand store + CalendarStore state shape (calendarStore.ts)
- `fetchEvents(start,end)`, `CalendarOccurrence`, `OccurrencesResponse` (client.ts — replaces old unwindowed versions)
- @schedule-x/* + temporal-polyfill + lucide-react + zustand dependencies
- main.tsx Temporal-polyfill-first import block
</artifacts_produced>
<output>
Create `.planning/phases/02-calendar-display/02-03-SUMMARY.md` when done
</output>