Milestone v1.0: FamilySync MVP #1
@@ -56,41 +56,6 @@ 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(),
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
* - Event popover: driven exclusively by Zustand openEventId via onEventClick → standalone
|
||||
* EventDetailPopover; createEventModalPlugin and customComponents.eventModal are NOT used
|
||||
* - Threat T-02d-01: all event fields are plain-text JSX children — no raw HTML injection
|
||||
* - Navigation: Schedule-X's built-in header is the sole navigation bar (Today/‹›/view switcher
|
||||
* + weekday-name row). The custom ViewToolbar and calendar-controls plugin have been removed.
|
||||
*
|
||||
* Layout:
|
||||
* - Phone (≤767px): AppNav top bar → ViewToolbar → calendar grid (primary focal point)
|
||||
* - Tablet/Desktop (≥768px): AppNav left sidebar (240px) + main area (ViewToolbar + grid)
|
||||
* - Phone (≤767px): AppNav top bar → Schedule-X (header + grid)
|
||||
* - Tablet/Desktop (≥768px): AppNav left sidebar (240px) + main area (Schedule-X header + grid)
|
||||
*
|
||||
* State branches:
|
||||
* isLoading (initial) → SkeletonCalendar
|
||||
@@ -37,7 +39,6 @@ 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'
|
||||
@@ -45,7 +46,6 @@ import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig
|
||||
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'
|
||||
@@ -96,7 +96,6 @@ 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(() => {
|
||||
@@ -148,7 +147,7 @@ export function CalendarShell() {
|
||||
},
|
||||
},
|
||||
},
|
||||
[eventsService, calendarControls],
|
||||
[eventsService],
|
||||
)
|
||||
|
||||
// Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard)
|
||||
@@ -205,9 +204,6 @@ export function CalendarShell() {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* ViewToolbar */}
|
||||
<ViewToolbar controls={calendarControls} />
|
||||
|
||||
{/* Main calendar area */}
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden' }}>
|
||||
{isInitialLoading ? (
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* 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: 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'
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
type CalendarControlsPlugin = ReturnType<typeof createCalendarControlsPlugin>
|
||||
|
||||
interface ViewToolbarProps {
|
||||
controls: CalendarControlsPlugin | null
|
||||
}
|
||||
|
||||
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 calendar-controls plugin API.
|
||||
* - today: setDate(Temporal.Now.plainDateISO())
|
||||
* - prev/next: read current date, step by the active view's unit, call setDate()
|
||||
*
|
||||
* 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') => {
|
||||
if (!controls) return
|
||||
|
||||
if (direction === 'today') {
|
||||
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) => {
|
||||
if (!controls) return
|
||||
controls.setView(viewId)
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
flexWrap: 'wrap',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
minHeight: '48px',
|
||||
}}
|
||||
role="toolbar"
|
||||
aria-label="Calendar navigation"
|
||||
>
|
||||
{/* Today button */}
|
||||
<button
|
||||
role="button"
|
||||
onClick={() => navigate('today')}
|
||||
style={buttonBase}
|
||||
aria-label="Go to today"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
|
||||
{/* Prev / Next */}
|
||||
<button
|
||||
role="button"
|
||||
onClick={() => navigate('prev')}
|
||||
style={{ ...buttonBase, minWidth: '44px' }}
|
||||
aria-label="Previous period"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
role="button"
|
||||
onClick={() => navigate('next')}
|
||||
style={{ ...buttonBase, minWidth: '44px' }}
|
||||
aria-label="Next period"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* View switcher */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 'var(--space-1)',
|
||||
}}
|
||||
role="group"
|
||||
aria-label="View selector"
|
||||
>
|
||||
{VIEWS.map((view) => (
|
||||
<button
|
||||
key={view.id}
|
||||
role="button"
|
||||
onClick={() => switchView(view.id)}
|
||||
style={selectedView === view.id ? activeButtonStyle : buttonBase}
|
||||
aria-pressed={selectedView === view.id}
|
||||
aria-label={`Switch to ${view.label} view`}
|
||||
>
|
||||
{view.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user