Milestone v1.0: FamilySync MVP #1
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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