From 216ddcedf444bbd32db56997384d385665b22581 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 10:58:06 -0400 Subject: [PATCH] feat(02-05): ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState; retire EventProof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/pwa/src/api/client.ts | 42 +-- apps/pwa/src/components/AppNav.tsx | 152 +++++++++++ .../pwa/src/components/CalendarShell.test.tsx | 5 +- apps/pwa/src/components/CalendarShell.tsx | 240 ++++++++++++++---- apps/pwa/src/components/ColorLegend.tsx | 94 +++++++ apps/pwa/src/components/EmptyState.tsx | 62 +++++ apps/pwa/src/components/EventProof.tsx | 144 ----------- apps/pwa/src/components/SkeletonCalendar.tsx | 133 ++++++++++ apps/pwa/src/components/ViewToolbar.tsx | 198 +++++++++++++++ 9 files changed, 840 insertions(+), 230 deletions(-) create mode 100644 apps/pwa/src/components/AppNav.tsx create mode 100644 apps/pwa/src/components/ColorLegend.tsx create mode 100644 apps/pwa/src/components/EmptyState.tsx delete mode 100644 apps/pwa/src/components/EventProof.tsx create mode 100644 apps/pwa/src/components/SkeletonCalendar.tsx create mode 100644 apps/pwa/src/components/ViewToolbar.tsx diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts index b5d706f..f5f7a10 100644 --- a/apps/pwa/src/api/client.ts +++ b/apps/pwa/src/api/client.ts @@ -95,43 +95,5 @@ export async function fetchEvents( return res.json() as Promise } -// ── 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 { - 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 -} +// Phase 1 legacy types (CalendarEvent, EventsResponse, fetchEventsLegacy) removed in Plan 05 +// when the Phase 1 broker-proof component was retired. diff --git a/apps/pwa/src/components/AppNav.tsx b/apps/pwa/src/components/AppNav.tsx new file mode 100644 index 0000000..99c4887 --- /dev/null +++ b/apps/pwa/src/components/AppNav.tsx @@ -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 + } + + return +} + +/** 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 ( +
+ {/* App name */} + + FamilySync + + + {/* User color avatar — aria-label + title per reviewer note */} +
+
+
+
+ ) +} + +/** Tablet/Desktop: 240px left sidebar — app name + color legend */ +function DesktopNav({ members }: { members: LegendMember[] }) { + return ( + + ) +} diff --git a/apps/pwa/src/components/CalendarShell.test.tsx b/apps/pwa/src/components/CalendarShell.test.tsx index c109510..6d10b02 100644 --- a/apps/pwa/src/components/CalendarShell.test.tsx +++ b/apps/pwa/src/components/CalendarShell.test.tsx @@ -152,9 +152,10 @@ describe('CalendarShell — CAL-03 render smoke', () => { expect(() => renderWithClient()).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() - 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') }) diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index 5c8edd7..5cf0a0d 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -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 = 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 = 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[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 ( +
+ {/* ViewToolbar */} + + + {/* Main calendar area */} +
+ {isInitialLoading ? ( + // Loading state: shimmer skeleton +
+ +
+ ) : isEventsError ? ( + // Error state: replace grid with heading + body + Retry button +
+

+ Couldn't load events +

+

+ Check your connection and try again. +

+ +
+ ) : isEmptyResult ? ( + // Empty state: zero events in this window + + ) : ( + // Normal: Schedule-X calendar (primary focal point) + + )} +
+ + {/* Phone: show ColorLegend below toolbar, collapsed */} + {phone && !meQuery.isLoading && members.length > 0 && ( +
+ +
+ )} +
+ ) + } + + // ── Full layout ──────────────────────────────────────────────────────────── + + if (phone) { + // Phone: stacked layout — AppNav top bar + content below + return ( +
+ + + +
+ ) + } + + // Tablet/Desktop: sidebar + main area return (
- {/* Loading overlay — skeleton provided by Schedule-X's built-in empty state */} - {(meQuery.isLoading || eventsQuery.isLoading) && ( -
- )} + {/* Left sidebar: AppNav (includes ColorLegend on desktop) */} + - {/* Schedule-X calendar fills available space */} -
- -
+ {/* Main content area */} + {/* EventDetailPopover — standalone mode driven by Zustand openEventId */} diff --git a/apps/pwa/src/components/ColorLegend.tsx b/apps/pwa/src/components/ColorLegend.tsx new file mode 100644 index 0000000..c387f36 --- /dev/null +++ b/apps/pwa/src/components/ColorLegend.tsx @@ -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 ( +
+ {/* Per-member rows */} + {members.map((member) => ( + + ))} + + {/* Shared-family row — always last */} + +
+ ) +} + +function LegendRow({ name, color }: { name: string; color: string }) { + return ( +
+ {/* 12px color circle */} + + {/* Display name */} + + {name} + +
+ ) +} diff --git a/apps/pwa/src/components/EmptyState.tsx b/apps/pwa/src/components/EmptyState.tsx new file mode 100644 index 0000000..f105320 --- /dev/null +++ b/apps/pwa/src/components/EmptyState.tsx @@ -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 ( +
+
+ ) +} diff --git a/apps/pwa/src/components/EventProof.tsx b/apps/pwa/src/components/EventProof.tsx deleted file mode 100644 index 224b5b8..0000000 --- a/apps/pwa/src/components/EventProof.tsx +++ /dev/null @@ -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 ( -
- Loading calendar events... -
- ) - } - - if (eventsQuery.isError) { - return ( -
- Could not load events -
- ) - } - - const events = eventsQuery.data?.events ?? [] - - if (events.length === 0) { - return ( -
- No cached events yet — broker poller will run in the next 5 minutes. -
- ) - } - - const first = events[0] - const summary = extractSummary(first.rawVevent) - const date = formatDate(first) - - return ( -
-
- Broker proof — 1 event cached -
-
- {summary} -
-
- {date} - {events.length > 1 && ` (+${events.length - 1} more)`} -
-
- ) -} diff --git a/apps/pwa/src/components/SkeletonCalendar.tsx b/apps/pwa/src/components/SkeletonCalendar.tsx new file mode 100644 index 0000000..0cd6951 --- /dev/null +++ b/apps/pwa/src/components/SkeletonCalendar.tsx @@ -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 ( +
+ {variant === 'month' ? : } +
+ ) +} + +/** 6×7 month grid of shimmer cells */ +function MonthSkeleton() { + return ( +
+ {/* Day-of-week header row */} +
+ {Array.from({ length: 7 }).map((_, i) => ( +
+ ))} +
+ + {/* 6 rows × 7 columns */} +
+ {Array.from({ length: 42 }).map((_, i) => ( +
+ ))} +
+
+ ) +} + +/** 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 ( +
+ {groups.map((group, gi) => ( +
+ {/* Date group header placeholder */} +
+ {/* Event rows */} +
+ {group.rows.map((widthPct, ri) => ( +
+ ))} +
+
+ ))} +
+ ) +} diff --git a/apps/pwa/src/components/ViewToolbar.tsx b/apps/pwa/src/components/ViewToolbar.tsx new file mode 100644 index 0000000..c2e0539 --- /dev/null +++ b/apps/pwa/src/components/ViewToolbar.tsx @@ -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 ( +
+ {/* Today button */} + + + {/* Prev / Next */} + + + + {/* Spacer */} +
+ + {/* View switcher */} +
+ {VIEWS.map((view) => ( + + ))} +
+
+ ) +}