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:
@@ -95,43 +95,5 @@ export async function fetchEvents(
|
|||||||
return res.json() as Promise<OccurrencesResponse>
|
return res.json() as Promise<OccurrencesResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Legacy types + function for EventProof (Phase 1 broker-proof component) ──
|
// Phase 1 legacy types (CalendarEvent, EventsResponse, fetchEventsLegacy) removed in Plan 05
|
||||||
// EventProof.tsx uses these; the component is removed in Plan 05.
|
// when the Phase 1 broker-proof component was retired.
|
||||||
// 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>
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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()
|
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 />)
|
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).toBeDefined()
|
||||||
expect(calEl.getAttribute('data-has-app')).toBe('true')
|
expect(calEl.getAttribute('data-has-app')).toBe('true')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,11 +11,21 @@
|
|||||||
* - DateRange.start/end are Temporal.ZonedDateTime → converted to 'YYYY-MM-DD' for Zustand
|
* - DateRange.start/end are Temporal.ZonedDateTime → converted to 'YYYY-MM-DD' for Zustand
|
||||||
* - calendarId routing: 'shared' | String(ownerUserId) — produced by hydrateEvents, consumed
|
* - calendarId routing: 'shared' | String(ownerUserId) — produced by hydrateEvents, consumed
|
||||||
* by buildCalendarConfig; never the DB calendar-row id
|
* 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 { 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 { ScheduleXCalendar, useCalendarApp } from '@schedule-x/react'
|
||||||
import {
|
import {
|
||||||
createViewDay,
|
createViewDay,
|
||||||
@@ -32,6 +42,11 @@ import { hydrateEvents } from '../lib/hydrateEvents.js'
|
|||||||
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js'
|
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js'
|
||||||
import { useCalendarStore } from '../store/calendarStore.js'
|
import { useCalendarStore } from '../store/calendarStore.js'
|
||||||
import { EventDetailPopover } from './EventDetailPopover.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 ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -40,17 +55,20 @@ import { EventDetailPopover } from './EventDetailPopover.js'
|
|||||||
* Called once at render time; Schedule-X persists selected view internally after that.
|
* Called once at render time; Schedule-X persists selected view internally after that.
|
||||||
*/
|
*/
|
||||||
function resolveDefaultView(persistedView: string): string {
|
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'
|
if (typeof window === 'undefined') return 'month-grid'
|
||||||
return persistedView
|
return persistedView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPhone(): boolean {
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ──────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CalendarShell() {
|
export function CalendarShell() {
|
||||||
const { calendarRange, setCalendarRange, setOpenEventId, selectedView } = useCalendarStore()
|
const { calendarRange, setCalendarRange, setOpenEventId, selectedView } = useCalendarStore()
|
||||||
const { start, end } = calendarRange
|
const { start, end } = calendarRange
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
// Fetch current user to build per-member color config
|
// Fetch current user to build per-member color config
|
||||||
const meQuery = useQuery({
|
const meQuery = useQuery({
|
||||||
@@ -72,20 +90,24 @@ export function CalendarShell() {
|
|||||||
const eventsService = useState(() => createEventsServicePlugin())[0]
|
const eventsService = useState(() => createEventsServicePlugin())[0]
|
||||||
const eventModal = useState(() => createEventModalPlugin())[0]
|
const eventModal = useState(() => createEventModalPlugin())[0]
|
||||||
|
|
||||||
// Build calendars config whenever the authenticated user changes
|
// Build members list from /api/me for AppNav + ColorLegend
|
||||||
const calendarsConfig: Record<string, CalendarType> = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
const members = meQuery.data?.user
|
if (!meQuery.data?.user) return []
|
||||||
? [
|
return [
|
||||||
{
|
{
|
||||||
id: String(meQuery.data.user.id),
|
id: String(meQuery.data.user.id),
|
||||||
name: meQuery.data.user.displayName ?? 'Member',
|
name: meQuery.data.user.displayName ?? 'Member',
|
||||||
color: meQuery.data.user.color,
|
color: meQuery.data.user.color,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []
|
|
||||||
return buildCalendarConfig(members).calendars
|
|
||||||
}, [meQuery.data])
|
}, [meQuery.data])
|
||||||
|
|
||||||
|
// Build calendars config whenever the authenticated user changes
|
||||||
|
const calendarsConfig: Record<string, CalendarType> = useMemo(
|
||||||
|
() => buildCalendarConfig(members).calendars,
|
||||||
|
[members],
|
||||||
|
)
|
||||||
|
|
||||||
const defaultView = resolveDefaultView(selectedView)
|
const defaultView = resolveDefaultView(selectedView)
|
||||||
|
|
||||||
// useCalendarApp — config is stable; plugins passed as second argument
|
// useCalendarApp — config is stable; plugins passed as second argument
|
||||||
@@ -110,7 +132,6 @@ export function CalendarShell() {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onEventClick(event) {
|
onEventClick(event) {
|
||||||
// Reserve event click for Plan 05 popover — store the id now
|
|
||||||
if (event.id != null) {
|
if (event.id != null) {
|
||||||
setOpenEventId(String(event.id))
|
setOpenEventId(String(event.id))
|
||||||
}
|
}
|
||||||
@@ -125,12 +146,23 @@ export function CalendarShell() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!eventsQuery.data) return
|
if (!eventsQuery.data) return
|
||||||
const sxEvents = hydrateEvents(eventsQuery.data.occurrences)
|
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])
|
eventsService.set(sxEvents as Parameters<typeof eventsService.set>[0])
|
||||||
}, [eventsQuery.data, eventsService])
|
}, [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) {
|
if (meQuery.isError) {
|
||||||
return (
|
return (
|
||||||
@@ -141,6 +173,7 @@ export function CalendarShell() {
|
|||||||
padding: 'var(--space-4)',
|
padding: 'var(--space-4)',
|
||||||
background: 'var(--color-surface-dim)',
|
background: 'var(--color-surface-dim)',
|
||||||
borderRadius: 'var(--space-2)',
|
borderRadius: 'var(--space-2)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sign-in required
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: '100dvh',
|
height: '100dvh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'row',
|
||||||
background: 'var(--color-surface)',
|
background: 'var(--color-surface)',
|
||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
fontFamily: 'var(--font-family-base)',
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Loading overlay — skeleton provided by Schedule-X's built-in empty state */}
|
{/* Left sidebar: AppNav (includes ColorLegend on desktop) */}
|
||||||
{(meQuery.isLoading || eventsQuery.isLoading) && (
|
<AppNav
|
||||||
<div
|
members={members}
|
||||||
aria-busy="true"
|
currentUserColor={meQuery.data?.user.color}
|
||||||
aria-label="Loading calendar"
|
currentUserName={meQuery.data?.user.displayName ?? undefined}
|
||||||
style={{
|
/>
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: 'var(--space-1)',
|
|
||||||
background: 'var(--color-member-0)',
|
|
||||||
opacity: 0.6,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Schedule-X calendar fills available space */}
|
{/* Main content area */}
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<CalendarContent />
|
||||||
<ScheduleXCalendar
|
|
||||||
calendarApp={calendar}
|
|
||||||
customComponents={{ eventModal: EventDetailPopover }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
|
{/* EventDetailPopover — standalone mode driven by Zustand openEventId */}
|
||||||
<EventDetailPopover />
|
<EventDetailPopover />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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, 2–3 rows each, varying widths (60–90%)
|
||||||
|
* - 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, 2–3 event rows each, varying widths */
|
||||||
|
function AgendaSkeleton() {
|
||||||
|
// Each group has: a date header + 2–3 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user