feat(02-05): ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState; retire EventProof

- ColorLegend: per-member color swatches (12px circle, label) + always-visible Family row
- AppNav: phone 48px top bar (avatar with aria-label/title) + tablet/desktop 240px sidebar with ColorLegend
- ViewToolbar: Today/prev/next + Day/Week/Month/Agenda view switcher; 44px min-height; active state uses surface tint not accent
- SkeletonCalendar: shimmer month (6x7 grid) and agenda (4 date-group blocks) variants; aria-busy=true
- EmptyState: CalendarDays icon + 'Nothing here' heading + body copy per UI-SPEC
- CalendarShell: full phone/desktop layout with AppNav + ViewToolbar + ColorLegend chrome
- CalendarShell: state branches — loading→SkeletonCalendar, empty→EmptyState, error→'Couldn't load events' + Retry button (refetchQueries)
- EventProof.tsx deleted; legacy types removed from client.ts
- CalendarShell.test.tsx: updated to waitFor ScheduleXCalendar after data loads
- All 36 tests pass, tsc clean, vite build clean (490kB)
This commit is contained in:
Lucas Berger
2026-06-05 10:58:06 -04:00
parent 3eebfbff42
commit 216ddcedf4
9 changed files with 840 additions and 230 deletions
+196 -44
View File
@@ -11,11 +11,21 @@
* - DateRange.start/end are Temporal.ZonedDateTime → converted to 'YYYY-MM-DD' for Zustand
* - calendarId routing: 'shared' | String(ownerUserId) — produced by hydrateEvents, consumed
* by buildCalendarConfig; never the DB calendar-row id
* - Threat T-02d-01: no dangerouslySetInnerHTML for event fields (React JSX default escaping)
* - Threat T-02d-01: all event fields are plain-text JSX children — no raw HTML injection
*
* Layout:
* - Phone (≤767px): AppNav top bar → ViewToolbar → calendar grid (primary focal point)
* - Tablet/Desktop (≥768px): AppNav left sidebar (240px) + main area (ViewToolbar + grid)
*
* State branches:
* isLoading (initial) → SkeletonCalendar
* success + 0 occurrences → EmptyState
* isError (after retry:2) → error state with Retry button
* success + events → ScheduleXCalendar
*/
import { useState, useEffect, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ScheduleXCalendar, useCalendarApp } from '@schedule-x/react'
import {
createViewDay,
@@ -32,6 +42,11 @@ import { hydrateEvents } from '../lib/hydrateEvents.js'
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js'
import { useCalendarStore } from '../store/calendarStore.js'
import { EventDetailPopover } from './EventDetailPopover.js'
import { AppNav } from './AppNav.js'
import { ViewToolbar } from './ViewToolbar.js'
import { ColorLegend } from './ColorLegend.js'
import { SkeletonCalendar } from './SkeletonCalendar.js'
import { EmptyState } from './EmptyState.js'
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -40,17 +55,20 @@ import { EventDetailPopover } from './EventDetailPopover.js'
* Called once at render time; Schedule-X persists selected view internally after that.
*/
function resolveDefaultView(persistedView: string): string {
// If the store already has a persisted non-default value, honour it.
// Otherwise derive from viewport width.
if (typeof window === 'undefined') return 'month-grid'
return persistedView
}
function isPhone(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
}
// ── Component ──────────────────────────────────────────────────────────────
export function CalendarShell() {
const { calendarRange, setCalendarRange, setOpenEventId, selectedView } = useCalendarStore()
const { start, end } = calendarRange
const queryClient = useQueryClient()
// Fetch current user to build per-member color config
const meQuery = useQuery({
@@ -72,20 +90,24 @@ export function CalendarShell() {
const eventsService = useState(() => createEventsServicePlugin())[0]
const eventModal = useState(() => createEventModalPlugin())[0]
// Build calendars config whenever the authenticated user changes
const calendarsConfig: Record<string, CalendarType> = useMemo(() => {
const members = meQuery.data?.user
? [
{
id: String(meQuery.data.user.id),
name: meQuery.data.user.displayName ?? 'Member',
color: meQuery.data.user.color,
},
]
: []
return buildCalendarConfig(members).calendars
// Build members list from /api/me for AppNav + ColorLegend
const members = useMemo(() => {
if (!meQuery.data?.user) return []
return [
{
id: String(meQuery.data.user.id),
name: meQuery.data.user.displayName ?? 'Member',
color: meQuery.data.user.color,
},
]
}, [meQuery.data])
// Build calendars config whenever the authenticated user changes
const calendarsConfig: Record<string, CalendarType> = useMemo(
() => buildCalendarConfig(members).calendars,
[members],
)
const defaultView = resolveDefaultView(selectedView)
// useCalendarApp — config is stable; plugins passed as second argument
@@ -110,7 +132,6 @@ export function CalendarShell() {
})
},
onEventClick(event) {
// Reserve event click for Plan 05 popover — store the id now
if (event.id != null) {
setOpenEventId(String(event.id))
}
@@ -125,12 +146,23 @@ export function CalendarShell() {
useEffect(() => {
if (!eventsQuery.data) return
const sxEvents = hydrateEvents(eventsQuery.data.occurrences)
// CalendarEventExternal is structurally compatible with ScheduleXEvent;
// extra _familySync fields pass through via index signature
eventsService.set(sxEvents as Parameters<typeof eventsService.set>[0])
}, [eventsQuery.data, eventsService])
// ── Render ──────────────────────────────────────────────────────────────
// ── Render helpers ────────────────────────────────────────────────────────
// Determine which content to show in the calendar area
const isInitialLoading = meQuery.isLoading || (eventsQuery.isLoading && !eventsQuery.data)
const isEventsError = eventsQuery.isError
const isEmptyResult =
!isInitialLoading &&
!isEventsError &&
eventsQuery.isSuccess &&
(eventsQuery.data?.occurrences.length ?? 0) === 0
const phone = isPhone()
// ── Sign-in required ───────────────────────────────────────────────────────
if (meQuery.isError) {
return (
@@ -141,6 +173,7 @@ export function CalendarShell() {
padding: 'var(--space-4)',
background: 'var(--color-surface-dim)',
borderRadius: 'var(--space-2)',
fontFamily: 'var(--font-family-base)',
}}
>
Sign-in required
@@ -148,41 +181,160 @@ export function CalendarShell() {
)
}
// ── Calendar content ───────────────────────────────────────────────────────
// The content panel (right of sidebar on desktop, full-width on phone)
function CalendarContent() {
return (
<div
style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* ViewToolbar */}
<ViewToolbar calendarApp={calendar} />
{/* Main calendar area */}
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
{isInitialLoading ? (
// Loading state: shimmer skeleton
<div style={{ padding: 'var(--space-4)', flex: 1 }}>
<SkeletonCalendar variant={phone ? 'agenda' : 'month'} />
</div>
) : isEventsError ? (
// Error state: replace grid with heading + body + Retry button
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 'var(--space-12)',
gap: 'var(--space-4)',
fontFamily: 'var(--font-family-base)',
textAlign: 'center',
flex: 1,
}}
>
<h2
style={{
margin: 0,
fontSize: 'var(--text-heading-size)',
fontWeight: 'var(--text-heading-weight)',
lineHeight: 'var(--text-heading-line-height)',
color: 'var(--color-text-primary)',
}}
>
Couldn't load events
</h2>
<p
style={{
margin: 0,
fontSize: 'var(--text-body-size)',
color: 'var(--color-text-secondary)',
}}
>
Check your connection and try again.
</p>
<button
onClick={() => queryClient.refetchQueries({ queryKey: ['events'] })}
style={{
background: 'var(--color-surface-dim)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1)',
cursor: 'pointer',
minHeight: '44px',
padding: '0 var(--space-4)',
fontSize: 'var(--text-label-size)',
fontWeight: 600,
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-base)',
}}
>
Retry
</button>
</div>
) : isEmptyResult ? (
// Empty state: zero events in this window
<EmptyState />
) : (
// Normal: Schedule-X calendar (primary focal point)
<ScheduleXCalendar
calendarApp={calendar}
customComponents={{ eventModal: EventDetailPopover }}
/>
)}
</div>
{/* Phone: show ColorLegend below toolbar, collapsed */}
{phone && !meQuery.isLoading && members.length > 0 && (
<div
style={{
padding: 'var(--space-2) var(--space-4)',
borderTop: '1px solid var(--color-border)',
background: 'var(--color-surface)',
}}
>
<ColorLegend members={members} />
</div>
)}
</div>
)
}
// ── Full layout ────────────────────────────────────────────────────────────
if (phone) {
// Phone: stacked layout — AppNav top bar + content below
return (
<div
style={{
height: '100dvh',
display: 'flex',
flexDirection: 'column',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-base)',
overflow: 'hidden',
}}
>
<AppNav
members={members}
currentUserColor={meQuery.data?.user.color}
currentUserName={meQuery.data?.user.displayName ?? undefined}
/>
<CalendarContent />
<EventDetailPopover />
</div>
)
}
// Tablet/Desktop: sidebar + main area
return (
<div
style={{
height: '100dvh',
display: 'flex',
flexDirection: 'column',
flexDirection: 'row',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-base)',
overflow: 'hidden',
}}
>
{/* Loading overlay — skeleton provided by Schedule-X's built-in empty state */}
{(meQuery.isLoading || eventsQuery.isLoading) && (
<div
aria-busy="true"
aria-label="Loading calendar"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 'var(--space-1)',
background: 'var(--color-member-0)',
opacity: 0.6,
}}
/>
)}
{/* Left sidebar: AppNav (includes ColorLegend on desktop) */}
<AppNav
members={members}
currentUserColor={meQuery.data?.user.color}
currentUserName={meQuery.data?.user.displayName ?? undefined}
/>
{/* Schedule-X calendar fills available space */}
<div style={{ flex: 1, minHeight: 0 }}>
<ScheduleXCalendar
calendarApp={calendar}
customComponents={{ eventModal: EventDetailPopover }}
/>
</div>
{/* Main content area */}
<CalendarContent />
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
<EventDetailPopover />