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
+2 -40
View File
@@ -95,43 +95,5 @@ export async function fetchEvents(
return res.json() as Promise<OccurrencesResponse>
}
// ── Legacy types + function for EventProof (Phase 1 broker-proof component) ──
// EventProof.tsx uses these; the component is removed in Plan 05.
// Kept here to prevent build breakage until then.
/**
* @deprecated Phase 1 raw cache shape — use CalendarOccurrence for Phase 2.
* EventProof uses this type; it is removed in Plan 05.
*/
export interface CalendarEvent {
id: number
calendarId: number
uid: string
etag: string | null
rawVevent: string
dtstartUtc: string | null
dtstartDate: string | null
allDay: boolean
updatedAt: string | null
}
/**
* @deprecated Phase 1 raw response shape — use OccurrencesResponse for Phase 2.
*/
export interface EventsResponse {
events: CalendarEvent[]
}
/**
* @deprecated Phase 1 broker-proof fetch — unwindowed, returns raw cache rows.
* Used only by EventProof.tsx; removed in Plan 05.
*/
export async function fetchEventsLegacy(): Promise<EventsResponse> {
const res = await fetch('/api/events', {
credentials: 'include',
})
if (!res.ok) {
throw new Error(`GET /api/events failed: ${res.status}`)
}
return res.json() as Promise<EventsResponse>
}
// Phase 1 legacy types (CalendarEvent, EventsResponse, fetchEventsLegacy) removed in Plan 05
// when the Phase 1 broker-proof component was retired.
+152
View File
@@ -0,0 +1,152 @@
/**
* AppNav — top navigation bar (phone) / left sidebar (tablet/desktop).
*
* UI-SPEC §AppNav:
* - Phone: 48px top bar, app name "FamilySync" left, user color swatch right
* - Tablet/Desktop: 240px left sidebar, app name + ColorLegend
* - Accent colors NOT on chrome (60/30/10 split)
*
* Reviewer note (UI-SPEC §Post-Verification):
* - Phone-nav avatar/color swatch needs aria-label + title (icon-only affordance)
* - Grid is primary focal point; AppNav is secondary chrome
*/
import { ColorLegend, type LegendMember } from './ColorLegend.js'
interface AppNavProps {
members?: LegendMember[]
currentUserColor?: string
currentUserName?: string
}
export function AppNav({ members = [], currentUserColor, currentUserName }: AppNavProps) {
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
if (isMobile) {
return <PhoneNav currentUserColor={currentUserColor} currentUserName={currentUserName} />
}
return <DesktopNav members={members} />
}
/** Phone: 48px top bar — app name left, user avatar right */
function PhoneNav({
currentUserColor,
currentUserName,
}: {
currentUserColor?: string
currentUserName?: string
}) {
const displayName = currentUserName ?? 'User'
const color = currentUserColor ?? 'var(--color-member-0)'
return (
<header
style={{
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 var(--space-4)',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
fontFamily: 'var(--font-family-base)',
}}
>
{/* App name */}
<span
style={{
fontSize: 'var(--text-display-size)',
fontWeight: 'var(--text-display-weight)',
lineHeight: 'var(--text-display-line-height)',
color: 'var(--color-text-primary)',
letterSpacing: '-0.01em',
}}
>
FamilySync
</span>
{/* User color avatar — aria-label + title per reviewer note */}
<div
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
cursor: 'default',
// Ensure 44px tap area with padding
minWidth: '44px',
minHeight: '44px',
padding: '6px',
boxSizing: 'border-box',
}}
aria-label={displayName}
title={displayName}
role="img"
>
<div
style={{
width: '100%',
height: '100%',
borderRadius: '50%',
background: color,
}}
/>
</div>
</header>
)
}
/** Tablet/Desktop: 240px left sidebar — app name + color legend */
function DesktopNav({ members }: { members: LegendMember[] }) {
return (
<nav
style={{
width: '240px',
flexShrink: 0,
background: 'var(--color-surface)',
borderRight: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
padding: 'var(--space-6) var(--space-4)',
fontFamily: 'var(--font-family-base)',
overflowY: 'auto',
}}
aria-label="Main navigation"
>
{/* App name */}
<div
style={{
fontSize: 'var(--text-display-size)',
fontWeight: 'var(--text-display-weight)',
lineHeight: 'var(--text-display-line-height)',
color: 'var(--color-text-primary)',
marginBottom: 'var(--space-6)',
letterSpacing: '-0.01em',
}}
>
FamilySync
</div>
{/* Color legend */}
<div
style={{
fontSize: 'var(--text-label-size)',
fontWeight: 600,
color: 'var(--color-text-muted)',
marginBottom: 'var(--space-2)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
Calendars
</div>
<ColorLegend members={members} />
</nav>
)
}
@@ -152,9 +152,10 @@ describe('CalendarShell — CAL-03 render smoke', () => {
expect(() => renderWithClient(<CalendarShell />)).not.toThrow()
})
it('mounts the ScheduleXCalendar with a non-null calendarApp', () => {
it('mounts the ScheduleXCalendar with a non-null calendarApp after data loads', async () => {
renderWithClient(<CalendarShell />)
const calEl = screen.getByTestId('schedule-x-calendar')
// CalendarShell shows SkeletonCalendar during loading; wait for data to resolve
const calEl = await screen.findByTestId('schedule-x-calendar')
expect(calEl).toBeDefined()
expect(calEl.getAttribute('data-has-app')).toBe('true')
})
+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 />
+94
View File
@@ -0,0 +1,94 @@
/**
* ColorLegend — always-visible member→color legend.
*
* UI-SPEC §ColorLegend:
* - One row per member: 12px color circle + display name
* - Shared-family row: rose swatch (#F25C7A) + "Family" label
* - Font: 13px label weight, --color-text-secondary
* - Always rendered; never interactive in Phase 2 (show/hide filter deferred, D-07)
* - Swatch aria-label="{name}: {hex}" (UI-SPEC §Interaction Contract: accessibility)
*
* Reviewer note (UI-SPEC §Post-Verification): Grid is primary focal point.
* ColorLegend is secondary chrome — no accent colors on the legend container.
*/
const SHARED_FAMILY_COLOR = '#F25C7A'
const SHARED_FAMILY_NAME = 'Family'
export interface LegendMember {
id: string
name: string
color: string
}
interface ColorLegendProps {
members?: LegendMember[]
}
export function ColorLegend({ members = [] }: ColorLegendProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-2)',
padding: 'var(--space-3) 0',
fontFamily: 'var(--font-family-base)',
}}
aria-label="Calendar color legend"
>
{/* Per-member rows */}
{members.map((member) => (
<LegendRow
key={member.id}
name={member.name}
color={member.color}
/>
))}
{/* Shared-family row — always last */}
<LegendRow
name={SHARED_FAMILY_NAME}
color={SHARED_FAMILY_COLOR}
/>
</div>
)
}
function LegendRow({ name, color }: { name: string; color: string }) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
minHeight: '20px',
}}
>
{/* 12px color circle */}
<span
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
background: color,
flexShrink: 0,
display: 'inline-block',
}}
aria-label={`${name}: ${color}`}
role="img"
/>
{/* Display name */}
<span
style={{
fontSize: 'var(--text-label-size)',
fontWeight: 'var(--text-label-weight)',
lineHeight: 'var(--text-label-line-height)',
color: 'var(--color-text-secondary)',
}}
>
{name}
</span>
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
/**
* EmptyState — shown when a calendar fetch succeeds but returns zero events.
*
* UI-SPEC §EmptyState:
* - Centered in the calendar viewport
* - Icon: lucide-react CalendarDays (32px, --color-text-muted)
* - Heading: "Nothing here"
* - Body: "No events in this period. Try a different date or switch views."
*
* Only rendered when fetch succeeded AND zero events in the visible window.
*/
import { CalendarDays } from 'lucide-react'
export function EmptyState() {
return (
<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,
}}
>
<CalendarDays
size={32}
style={{ color: 'var(--color-text-muted)' }}
aria-hidden="true"
/>
<div>
<h2
style={{
margin: '0 0 var(--space-2)',
fontSize: 'var(--text-heading-size)',
fontWeight: 'var(--text-heading-weight)',
lineHeight: 'var(--text-heading-line-height)',
color: 'var(--color-text-primary)',
}}
>
Nothing here
</h2>
<p
style={{
margin: 0,
fontSize: 'var(--text-body-size)',
fontWeight: 'var(--text-body-weight)',
lineHeight: 'var(--text-body-line-height)',
color: 'var(--color-text-muted)',
maxWidth: '280px',
}}
>
No events in this period. Try a different date or switch views.
</p>
</div>
</div>
)
}
-144
View File
@@ -1,144 +0,0 @@
/**
* EventProof — renders one cached event from /api/events as broker proof.
*
* This is the Phase 1 landing page's broker-proof component: it shows a
* single cached event title and date, confirming the CalDAV broker has
* successfully fetched and cached at least one real Fastmail event (CAL-01).
*
* Data flow:
* React Query ['events'] → fetchEvents() → GET /api/events
* → renders first event's summary (parsed from rawVevent) and date
*
* Empty state: "No cached events yet" — shown when the poller hasn't run yet.
* This is expected on first boot before any Fastmail credentials are loaded.
*/
import { useQuery } from '@tanstack/react-query'
// fetchEventsLegacy: Phase 1 broker-proof; replaced by windowed fetchEvents in Plan 04.
// This component is removed in Plan 05.
import { fetchEventsLegacy, type CalendarEvent } from '../api/client'
import ICAL from 'ical.js'
/**
* Extracts the event summary (title) from a raw VCALENDAR/VEVENT string.
* Falls back to 'Untitled event' if parsing fails or SUMMARY is absent.
*/
function extractSummary(rawVevent: string): string {
try {
const parsed = ICAL.parse(rawVevent)
const comp = new ICAL.Component(parsed)
const vevent = comp.getFirstSubcomponent('vevent')
if (!vevent) return 'Untitled event'
return (vevent.getFirstPropertyValue('summary') as string | null) ?? 'Untitled event'
} catch {
return 'Untitled event'
}
}
/**
* Formats the event date for display.
* Uses dtstartDate (for all-day) or dtstartUtc (for timed events).
*/
function formatDate(event: CalendarEvent): string {
if (event.allDay && event.dtstartDate) {
return event.dtstartDate
}
if (event.dtstartUtc) {
try {
return new Date(event.dtstartUtc).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
return event.dtstartUtc
}
}
return 'Date unknown'
}
export function EventProof() {
const eventsQuery = useQuery({
queryKey: ['events-legacy'],
queryFn: fetchEventsLegacy,
retry: false,
staleTime: 5 * 60 * 1000, // 5 min — matches broker poll interval
})
if (eventsQuery.isLoading) {
return (
<div
style={{
padding: '1rem',
borderRadius: '8px',
background: '#f5f5f5',
color: '#666',
fontSize: '0.875rem',
}}
>
Loading calendar events...
</div>
)
}
if (eventsQuery.isError) {
return (
<div
style={{
padding: '0.75rem 1rem',
borderRadius: '8px',
background: '#fee2e2',
color: '#991b1b',
fontSize: '0.875rem',
}}
>
Could not load events
</div>
)
}
const events = eventsQuery.data?.events ?? []
if (events.length === 0) {
return (
<div
style={{
padding: '0.75rem 1rem',
borderRadius: '8px',
background: '#fef9c3',
color: '#713f12',
fontSize: '0.875rem',
}}
>
No cached events yet broker poller will run in the next 5 minutes.
</div>
)
}
const first = events[0]
const summary = extractSummary(first.rawVevent)
const date = formatDate(first)
return (
<div
style={{
padding: '0.75rem 1rem',
borderRadius: '8px',
background: '#f0fdf4',
border: '1px solid #bbf7d0',
fontSize: '0.875rem',
}}
>
<div style={{ fontWeight: 600, color: '#166534', marginBottom: '0.25rem' }}>
Broker proof 1 event cached
</div>
<div style={{ color: '#15803d' }}>
{summary}
</div>
<div style={{ color: '#4ade80', fontSize: '0.75rem', marginTop: '0.25rem' }}>
{date}
{events.length > 1 && ` (+${events.length - 1} more)`}
</div>
</div>
)
}
@@ -0,0 +1,133 @@
/**
* SkeletonCalendar — shimmer loading placeholder.
*
* UI-SPEC §SkeletonCalendar:
* - Month variant: 6×7 grid of rounded rect placeholders, animated shimmer
* - Agenda variant: 4 date-group blocks, 23 rows each, varying widths (6090%)
* - No spinner; shimmer only (matches Fantastical-style)
* - aria-busy="true", aria-label="Loading calendar" on root
*
* Shimmer keyframe declared in tokens.css (@keyframes shimmer).
* The shimmer animation uses the gradient from tokens.css:
* background: linear-gradient(90deg, --color-surface-dim, --color-border-subtle, --color-surface-dim)
*/
type SkeletonVariant = 'month' | 'agenda'
interface SkeletonCalendarProps {
variant?: SkeletonVariant
}
/** Shimmer inline style — gradient + animation referencing the keyframe in tokens.css */
const shimmerStyle: React.CSSProperties = {
background:
'linear-gradient(90deg, var(--color-surface-dim), var(--color-border-subtle), var(--color-surface-dim))',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
borderRadius: 'var(--space-1)',
}
export function SkeletonCalendar({ variant = 'month' }: SkeletonCalendarProps) {
return (
<div
aria-busy="true"
aria-label="Loading calendar"
style={{
padding: 'var(--space-4)',
fontFamily: 'var(--font-family-base)',
width: '100%',
}}
>
{variant === 'month' ? <MonthSkeleton /> : <AgendaSkeleton />}
</div>
)
}
/** 6×7 month grid of shimmer cells */
function MonthSkeleton() {
return (
<div>
{/* Day-of-week header row */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 'var(--space-1)',
marginBottom: 'var(--space-2)',
}}
>
{Array.from({ length: 7 }).map((_, i) => (
<div
key={i}
style={{
...shimmerStyle,
height: '16px',
opacity: 0.5,
}}
/>
))}
</div>
{/* 6 rows × 7 columns */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 'var(--space-1)',
}}
>
{Array.from({ length: 42 }).map((_, i) => (
<div
key={i}
style={{
height: '80px',
...shimmerStyle,
}}
/>
))}
</div>
</div>
)
}
/** 4 date-group blocks, 23 event rows each, varying widths */
function AgendaSkeleton() {
// Each group has: a date header + 23 event rows with varying widths
const groups = [
{ rows: [80, 65, 90] },
{ rows: [75, 60] },
{ rows: [85, 70, 65] },
{ rows: [60, 80] },
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
{groups.map((group, gi) => (
<div key={gi}>
{/* Date group header placeholder */}
<div
style={{
...shimmerStyle,
height: '20px',
width: '40%',
marginBottom: 'var(--space-3)',
}}
/>
{/* Event rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{group.rows.map((widthPct, ri) => (
<div
key={ri}
style={{
...shimmerStyle,
height: '44px',
width: `${widthPct}%`,
}}
/>
))}
</div>
</div>
))}
</div>
)
}
+198
View File
@@ -0,0 +1,198 @@
/**
* ViewToolbar — calendar navigation and view switcher.
*
* UI-SPEC §ViewToolbar:
* - Buttons: Today | < | > | [Day] [Week] [Month] [Agenda]
* - Font: 13px label weight
* - Active view: subtle surface tint (NOT accent) — --color-member-0 at 12% opacity
* - Touch targets: 44px minimum height
* - role="button", keyboard-activatable with Enter/Space
*
* Reviewer note: ViewToolbar is secondary chrome — accent colors NOT on chrome.
* Active state uses a subtle surface tint, not the accent color directly.
*
* Schedule-X integration: selected view drives the Schedule-X calendar's view
* via calendarApp API; prev/next/today drive navigation.
*/
import { useCalendarStore } from '../store/calendarStore.js'
type ViewId = 'day' | 'week' | 'month-grid' | 'month-agenda'
interface ViewConfig {
id: ViewId
label: string
}
const VIEWS: ViewConfig[] = [
{ id: 'day', label: 'Day' },
{ id: 'week', label: 'Week' },
{ id: 'month-grid', label: 'Month' },
{ id: 'month-agenda', label: 'Agenda' },
]
interface ViewToolbarProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calendarApp: any | null
}
export function ViewToolbar({ calendarApp }: ViewToolbarProps) {
const { selectedView, setSelectedView } = useCalendarStore()
/**
* Navigate using the internal Schedule-X CalendarAppSingleton API.
* CalendarApp.$app is private in TypeScript but accessible at runtime.
* CalendarState.setRange(date) navigates to the given date's range.
* CalendarState.setView(viewId, date) switches view.
*
* Access pattern: calendarApp?.$app?.calendarState
*/
const navigate = (direction: 'prev' | 'next' | 'today') => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const $app = calendarApp?.$app
if (!$app) return
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const state = $app.calendarState
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const currentRange = state?.range?.value
if (!state) return
if (direction === 'today') {
// Navigate to today using Temporal.PlainDate
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
state.setRange(Temporal.Now.plainDateISO())
} else {
// Navigate via range increment/decrement using current range
if (!currentRange) return
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const currentStart: Temporal.ZonedDateTime = currentRange.start
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const currentEnd: Temporal.ZonedDateTime = currentRange.end
const duration = currentStart.until(currentEnd)
const unit = Math.abs(duration.days) <= 1 ? { days: 1 } :
Math.abs(duration.days) <= 7 ? { weeks: 1 } :
{ months: 1 }
const newDate =
direction === 'prev'
? currentStart.toPlainDate().subtract(unit)
: currentStart.toPlainDate().add(unit)
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
state.setRange(newDate)
} catch {
// Range navigation failed — no-op rather than crashing the toolbar
}
}
}
const switchView = (viewId: ViewId) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const $app = calendarApp?.$app
if (!$app) return
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const state = $app.calendarState
if (!state) return
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
state.setView(viewId, Temporal.Now.plainDateISO())
setSelectedView(viewId)
}
const buttonBase: React.CSSProperties = {
background: 'none',
border: '1px solid var(--color-border)',
cursor: 'pointer',
minHeight: '44px',
padding: '0 var(--space-3)',
fontSize: 'var(--text-label-size)',
fontWeight: 'var(--text-label-weight)',
lineHeight: 'var(--text-label-line-height)',
color: 'var(--color-text-primary)',
borderRadius: 'var(--space-1)',
fontFamily: 'var(--font-family-base)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.1s',
}
const activeButtonStyle: React.CSSProperties = {
...buttonBase,
// Subtle surface tint — NOT accent color (UI-SPEC 60/30/10 rule)
// Active state: --color-member-0 at 12% opacity over white
background: 'rgba(74, 144, 217, 0.12)',
fontWeight: 600,
border: '1px solid rgba(74, 144, 217, 0.3)',
}
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-4)',
borderBottom: '1px solid var(--color-border)',
background: 'var(--color-surface)',
flexWrap: 'wrap',
fontFamily: 'var(--font-family-base)',
minHeight: '48px',
}}
role="toolbar"
aria-label="Calendar navigation"
>
{/* Today button */}
<button
role="button"
onClick={() => navigate('today')}
style={buttonBase}
aria-label="Go to today"
>
Today
</button>
{/* Prev / Next */}
<button
role="button"
onClick={() => navigate('prev')}
style={{ ...buttonBase, minWidth: '44px' }}
aria-label="Previous period"
>
</button>
<button
role="button"
onClick={() => navigate('next')}
style={{ ...buttonBase, minWidth: '44px' }}
aria-label="Next period"
>
</button>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* View switcher */}
<div
style={{
display: 'flex',
gap: 'var(--space-1)',
}}
role="group"
aria-label="View selector"
>
{VIEWS.map((view) => (
<button
key={view.id}
role="button"
onClick={() => switchView(view.id)}
style={selectedView === view.id ? activeButtonStyle : buttonBase}
aria-pressed={selectedView === view.id}
aria-label={`Switch to ${view.label} view`}
>
{view.label}
</button>
))}
</div>
</div>
)
}