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)