fix(02): wire calendar-controls plugin and Zustand selectors
Bug A — navigation no-op: replace $app.calendarState private-API poking with the official @schedule-x/calendar-controls plugin. CalendarShell creates the plugin once via useState stable initialiser and passes it to ViewToolbar as `controls`. ViewToolbar calls controls.setDate(PlainDate) and controls.setView(id) for all navigation and view-switching. Step size matches the active view: day→±1 day, week→±1 week, month-*→±1 month. Bug B — popover-open calendar flash: replace the unselected useCalendarStore() destructuring in CalendarShell and ViewToolbar with per-field selectors. Neither component now subscribes to openEventId, so popover open/close no longer triggers a re-render that rebuilds the Schedule-X config. - Add @schedule-x/calendar-controls@4.6.0 dependency - Update CalendarShell.test.tsx: add vi.mock for calendar-controls - typecheck, vitest (37/37), build all pass
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 */}
|
||||
<ViewToolbar calendarApp={calendar} />
|
||||
<ViewToolbar controls={calendarControls} />
|
||||
|
||||
{/* Main calendar area */}
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
|
||||
|
||||
@@ -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<typeof createCalendarControlsPlugin>
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user