Milestone v1.0: FamilySync MVP #1

Merged
luckberg merged 376 commits from gsd/v1.0-milestone into main 2026-06-10 17:39:19 -04:00
3 changed files with 5 additions and 236 deletions
Showing only changes of commit d240657059 - Show all commits
@@ -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(),
+5 -9
View File
@@ -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 ? (
-192
View File
@@ -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>
)
}