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:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user