diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 23782ab..4c654b6 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@schedule-x/calendar": "4.6.0", + "@schedule-x/calendar-controls": "4.6.0", "@schedule-x/event-modal": "4.6.0", "@schedule-x/events-service": "4.6.0", "@schedule-x/react": "4.1.0", diff --git a/apps/pwa/src/components/CalendarShell.test.tsx b/apps/pwa/src/components/CalendarShell.test.tsx index 6d10b02..adcd83b 100644 --- a/apps/pwa/src/components/CalendarShell.test.tsx +++ b/apps/pwa/src/components/CalendarShell.test.tsx @@ -56,6 +56,41 @@ vi.mock('@schedule-x/event-modal', () => ({ })), })) +// Mock @schedule-x/calendar-controls so CalendarShell can create the plugin +vi.mock('@schedule-x/calendar-controls', () => ({ + createCalendarControlsPlugin: vi.fn(() => ({ + name: 'calendarControls', + beforeRender: vi.fn(), + onRender: vi.fn(), + setDate: vi.fn(), + setView: vi.fn(), + getDate: vi.fn(() => Temporal.Now.plainDateISO()), + getView: vi.fn(() => 'month-grid'), + setFirstDayOfWeek: vi.fn(), + setLocale: vi.fn(), + setViews: vi.fn(), + setDayBoundaries: vi.fn(), + setWeekOptions: vi.fn(), + setCalendars: vi.fn(), + setMinDate: vi.fn(), + setMaxDate: vi.fn(), + setMonthGridOptions: vi.fn(), + setTimezone: vi.fn(), + setResources: vi.fn(), + getFirstDayOfWeek: vi.fn(), + getLocale: vi.fn(), + getViews: vi.fn(() => []), + getDayBoundaries: vi.fn(), + getWeekOptions: vi.fn(), + getCalendars: vi.fn(() => ({})), + getMinDate: vi.fn(), + getMaxDate: vi.fn(), + getMonthGridOptions: vi.fn(), + getResources: vi.fn(() => []), + getRange: vi.fn(() => null), + })), +})) + // Mock the API client vi.mock('../api/client.js', () => ({ fetchMe: vi.fn(), diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index b244c0e..9be6484 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -37,6 +37,7 @@ import { type CalendarType, } from '@schedule-x/calendar' import { createEventsServicePlugin } from '@schedule-x/events-service' +import { createCalendarControlsPlugin } from '@schedule-x/calendar-controls' import { fetchMe, fetchEvents } from '../api/client.js' import { hydrateEvents } from '../lib/hydrateEvents.js' @@ -67,7 +68,13 @@ function isPhone(): boolean { // ── Component ────────────────────────────────────────────────────────────── export function CalendarShell() { - const { calendarRange, setCalendarRange, setOpenEventId, selectedView } = useCalendarStore() + // Use per-field selectors so CalendarShell does NOT subscribe to openEventId. + // Without selectors, any popover open/close triggers a full re-render here, + // which rebuilds the Schedule-X config and causes a visible calendar flash (Bug B). + const calendarRange = useCalendarStore((s) => s.calendarRange) + const setCalendarRange = useCalendarStore((s) => s.setCalendarRange) + const setOpenEventId = useCalendarStore((s) => s.setOpenEventId) + const selectedView = useCalendarStore((s) => s.selectedView) const { start, end } = calendarRange const queryClient = useQueryClient() @@ -89,6 +96,7 @@ export function CalendarShell() { // Create plugins once (stable across renders) const eventsService = useState(() => createEventsServicePlugin())[0] + const calendarControls = useState(() => createCalendarControlsPlugin())[0] // Build members list from /api/me for AppNav + ColorLegend const members = useMemo(() => { @@ -140,7 +148,7 @@ export function CalendarShell() { }, }, }, - [eventsService], + [eventsService, calendarControls], ) // Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard) @@ -198,7 +206,7 @@ export function CalendarShell() { }} > {/* ViewToolbar */} - + {/* Main calendar area */}
diff --git a/apps/pwa/src/components/ViewToolbar.tsx b/apps/pwa/src/components/ViewToolbar.tsx index c2e0539..b15bbf5 100644 --- a/apps/pwa/src/components/ViewToolbar.tsx +++ b/apps/pwa/src/components/ViewToolbar.tsx @@ -11,11 +11,16 @@ * 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. + * Schedule-X integration: navigation and view-switching via the official + * @schedule-x/calendar-controls plugin (createCalendarControlsPlugin). + * The plugin is created once in CalendarShell and passed down as `controls`. + * + * Zustand subscription: uses per-field selectors so ViewToolbar only re-renders + * when selectedView or setSelectedView change — NOT when openEventId changes. */ import { useCalendarStore } from '../store/calendarStore.js' +import type { createCalendarControlsPlugin } from '@schedule-x/calendar-controls' type ViewId = 'day' | 'week' | 'month-grid' | 'month-agenda' @@ -31,69 +36,58 @@ const VIEWS: ViewConfig[] = [ { id: 'month-agenda', label: 'Agenda' }, ] +type CalendarControlsPlugin = ReturnType + interface ViewToolbarProps { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - calendarApp: any | null + controls: CalendarControlsPlugin | null } -export function ViewToolbar({ calendarApp }: ViewToolbarProps) { - const { selectedView, setSelectedView } = useCalendarStore() +export function ViewToolbar({ controls }: ViewToolbarProps) { + // Per-field selectors: do NOT subscribe to openEventId. + // If the whole store is destructured unselected, every popover open/close + // causes a re-render here, which flashes the toolbar (Bug B). + const selectedView = useCalendarStore((s) => s.selectedView) + const setSelectedView = useCalendarStore((s) => s.setSelectedView) /** - * 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. + * Navigate using the calendar-controls plugin API. + * - today: setDate(Temporal.Now.plainDateISO()) + * - prev/next: read current date, step by the active view's unit, call setDate() * - * Access pattern: calendarApp?.$app?.calendarState + * Step mapping (matches Schedule-X built-in backward/forward behaviour): + * day → ±1 day + * week → ±1 week + * month-grid → ±1 month + * month-agenda → ±1 month */ 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 (!controls) 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 - } + controls.setDate(Temporal.Now.plainDateISO()) + return } + + // Get the current date from the controls plugin (returns Temporal.PlainDate) + const current = controls.getDate() + + let step: Temporal.DurationLike + if (selectedView === 'day') { + step = { days: 1 } + } else if (selectedView === 'week') { + step = { weeks: 1 } + } else { + // month-grid or month-agenda + step = { months: 1 } + } + + const newDate = direction === 'prev' ? current.subtract(step) : current.add(step) + controls.setDate(newDate) } 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()) + if (!controls) return + controls.setView(viewId) setSelectedView(viewId) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index affda64..4467fc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: '@schedule-x/calendar': specifier: 4.6.0 version: 4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2) + '@schedule-x/calendar-controls': + specifier: 4.6.0 + version: 4.6.0 '@schedule-x/event-modal': specifier: 4.6.0 version: 4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2) @@ -863,6 +866,9 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@schedule-x/calendar-controls@4.6.0': + resolution: {integrity: sha512-+VVmJ4AhZkAqTsz/U6L6mftuohBXSdJ10/HFWoMrO5coaoj0K5MExCdcG7UkFK3ZmoPz03koO0RnqRt5FKyXsg==} + '@schedule-x/calendar@4.6.0': resolution: {integrity: sha512-8Rf5bUf0DVphJB2BaudtG4IHkw21wgMJiiuk5o+7SIOq6YH4hkVJo+Kk12pWSfp2KTZ9mnnUuLV+szBVhiEPlg==} peerDependencies: @@ -2271,6 +2277,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@schedule-x/calendar-controls@4.6.0': {} + '@schedule-x/calendar@4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2)': dependencies: '@preact/signals': 2.9.1(preact@10.29.2)