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": {
|
"dependencies": {
|
||||||
"@schedule-x/calendar": "4.6.0",
|
"@schedule-x/calendar": "4.6.0",
|
||||||
|
"@schedule-x/calendar-controls": "4.6.0",
|
||||||
"@schedule-x/event-modal": "4.6.0",
|
"@schedule-x/event-modal": "4.6.0",
|
||||||
"@schedule-x/events-service": "4.6.0",
|
"@schedule-x/events-service": "4.6.0",
|
||||||
"@schedule-x/react": "4.1.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
|
// Mock the API client
|
||||||
vi.mock('../api/client.js', () => ({
|
vi.mock('../api/client.js', () => ({
|
||||||
fetchMe: vi.fn(),
|
fetchMe: vi.fn(),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
type CalendarType,
|
type CalendarType,
|
||||||
} from '@schedule-x/calendar'
|
} from '@schedule-x/calendar'
|
||||||
import { createEventsServicePlugin } from '@schedule-x/events-service'
|
import { createEventsServicePlugin } from '@schedule-x/events-service'
|
||||||
|
import { createCalendarControlsPlugin } from '@schedule-x/calendar-controls'
|
||||||
|
|
||||||
import { fetchMe, fetchEvents } from '../api/client.js'
|
import { fetchMe, fetchEvents } from '../api/client.js'
|
||||||
import { hydrateEvents } from '../lib/hydrateEvents.js'
|
import { hydrateEvents } from '../lib/hydrateEvents.js'
|
||||||
@@ -67,7 +68,13 @@ function isPhone(): boolean {
|
|||||||
// ── Component ──────────────────────────────────────────────────────────────
|
// ── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function CalendarShell() {
|
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 { start, end } = calendarRange
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
@@ -89,6 +96,7 @@ export function CalendarShell() {
|
|||||||
|
|
||||||
// Create plugins once (stable across renders)
|
// Create plugins once (stable across renders)
|
||||||
const eventsService = useState(() => createEventsServicePlugin())[0]
|
const eventsService = useState(() => createEventsServicePlugin())[0]
|
||||||
|
const calendarControls = useState(() => createCalendarControlsPlugin())[0]
|
||||||
|
|
||||||
// Build members list from /api/me for AppNav + ColorLegend
|
// Build members list from /api/me for AppNav + ColorLegend
|
||||||
const members = useMemo(() => {
|
const members = useMemo(() => {
|
||||||
@@ -140,7 +148,7 @@ export function CalendarShell() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[eventsService],
|
[eventsService, calendarControls],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard)
|
// Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard)
|
||||||
@@ -198,7 +206,7 @@ export function CalendarShell() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ViewToolbar */}
|
{/* ViewToolbar */}
|
||||||
<ViewToolbar calendarApp={calendar} />
|
<ViewToolbar controls={calendarControls} />
|
||||||
|
|
||||||
{/* Main calendar area */}
|
{/* Main calendar area */}
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
|
<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.
|
* Reviewer note: ViewToolbar is secondary chrome — accent colors NOT on chrome.
|
||||||
* Active state uses a subtle surface tint, not the accent color directly.
|
* Active state uses a subtle surface tint, not the accent color directly.
|
||||||
*
|
*
|
||||||
* Schedule-X integration: selected view drives the Schedule-X calendar's view
|
* Schedule-X integration: navigation and view-switching via the official
|
||||||
* via calendarApp API; prev/next/today drive navigation.
|
* @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 { useCalendarStore } from '../store/calendarStore.js'
|
||||||
|
import type { createCalendarControlsPlugin } from '@schedule-x/calendar-controls'
|
||||||
|
|
||||||
type ViewId = 'day' | 'week' | 'month-grid' | 'month-agenda'
|
type ViewId = 'day' | 'week' | 'month-grid' | 'month-agenda'
|
||||||
|
|
||||||
@@ -31,69 +36,58 @@ const VIEWS: ViewConfig[] = [
|
|||||||
{ id: 'month-agenda', label: 'Agenda' },
|
{ id: 'month-agenda', label: 'Agenda' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
type CalendarControlsPlugin = ReturnType<typeof createCalendarControlsPlugin>
|
||||||
|
|
||||||
interface ViewToolbarProps {
|
interface ViewToolbarProps {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
controls: CalendarControlsPlugin | null
|
||||||
calendarApp: any | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ViewToolbar({ calendarApp }: ViewToolbarProps) {
|
export function ViewToolbar({ controls }: ViewToolbarProps) {
|
||||||
const { selectedView, setSelectedView } = useCalendarStore()
|
// 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.
|
* Navigate using the calendar-controls plugin API.
|
||||||
* CalendarApp.$app is private in TypeScript but accessible at runtime.
|
* - today: setDate(Temporal.Now.plainDateISO())
|
||||||
* CalendarState.setRange(date) navigates to the given date's range.
|
* - prev/next: read current date, step by the active view's unit, call setDate()
|
||||||
* CalendarState.setView(viewId, date) switches view.
|
|
||||||
*
|
*
|
||||||
* 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') => {
|
const navigate = (direction: 'prev' | 'next' | 'today') => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
if (!controls) return
|
||||||
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') {
|
if (direction === 'today') {
|
||||||
// Navigate to today using Temporal.PlainDate
|
controls.setDate(Temporal.Now.plainDateISO())
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
return
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) => {
|
const switchView = (viewId: ViewId) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
if (!controls) return
|
||||||
const $app = calendarApp?.$app
|
controls.setView(viewId)
|
||||||
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)
|
setSelectedView(viewId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+8
@@ -62,6 +62,9 @@ importers:
|
|||||||
'@schedule-x/calendar':
|
'@schedule-x/calendar':
|
||||||
specifier: 4.6.0
|
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)
|
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':
|
'@schedule-x/event-modal':
|
||||||
specifier: 4.6.0
|
specifier: 4.6.0
|
||||||
version: 4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)
|
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':
|
'@rolldown/pluginutils@1.0.1':
|
||||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
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':
|
'@schedule-x/calendar@4.6.0':
|
||||||
resolution: {integrity: sha512-8Rf5bUf0DVphJB2BaudtG4IHkw21wgMJiiuk5o+7SIOq6YH4hkVJo+Kk12pWSfp2KTZ9mnnUuLV+szBVhiEPlg==}
|
resolution: {integrity: sha512-8Rf5bUf0DVphJB2BaudtG4IHkw21wgMJiiuk5o+7SIOq6YH4hkVJo+Kk12pWSfp2KTZ9mnnUuLV+szBVhiEPlg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2271,6 +2277,8 @@ snapshots:
|
|||||||
|
|
||||||
'@rolldown/pluginutils@1.0.1': {}
|
'@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)':
|
'@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:
|
dependencies:
|
||||||
'@preact/signals': 2.9.1(preact@10.29.2)
|
'@preact/signals': 2.9.1(preact@10.29.2)
|
||||||
|
|||||||
Reference in New Issue
Block a user