feat(02-03): hydrateEvents + calendarStore + windowed fetchEvents; RED stubs green
- Create hydrateEvents.ts: Temporal.PlainDate for allDay, ZonedDateTime for timed calendarId routes via isShared ? 'shared' : String(ownerUserId) — NOT String(calendarId) _familySync carries uid/color/isShared through to popover - Update hydrateEvents.test.ts: add temporal-polyfill/global import; all 4 RED stubs now GREEN - Create calendarStore.ts: Zustand store with selectedView (localStorage per breakpoint group), selectedDate, openEventId, calendarRange; D-05 view defaults; calendarRange ± buffer for initial TanStack Query key without depending on onRangeUpdate firing on mount - Update client.ts: add CalendarOccurrence/OccurrencesResponse, windowed fetchEvents(start,end) with credentials:include; keep legacy CalendarEvent/EventsResponse + fetchEventsLegacy as deprecated for EventProof.tsx (removed in Plan 05) - Update EventProof.tsx: switch to fetchEventsLegacy to keep build clean until Plan 05 - tsc --noEmit clean; all 18 PWA tests pass
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Zustand UI-state store for the calendar shell.
|
||||
*
|
||||
* Owns ONLY UI-shape state — no server data ever enters this store.
|
||||
* Server state (events, user profile) lives in TanStack Query.
|
||||
*
|
||||
* State contract (UI-SPEC §State Management Contract):
|
||||
* selectedView — persisted in localStorage keyed by breakpoint group
|
||||
* selectedDate — ISO string; NOT persisted
|
||||
* openEventId — null when popover is closed
|
||||
* calendarRange — { start, end } ISO date strings; drives TanStack Query key
|
||||
*
|
||||
* View default logic (D-05):
|
||||
* phone (≤767px) → 'month-agenda'
|
||||
* tablet-desktop (≥768px) → 'month-grid'
|
||||
*
|
||||
* localStorage keys:
|
||||
* calendarView.phone — persisted view for phone breakpoint group
|
||||
* calendarView.tablet-desktop — persisted view for tablet-desktop
|
||||
*
|
||||
* Note: calendarRange is initialized to today ± buffer (current month ± 1 week)
|
||||
* so the first TanStack Query fetch uses a sensible window without depending on
|
||||
* onRangeUpdate firing on mount (A4/Open Q2 from RESEARCH.md).
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type BreakpointGroup = 'phone' | 'tablet-desktop'
|
||||
|
||||
export interface CalendarStore {
|
||||
selectedView: string
|
||||
selectedDate: string
|
||||
openEventId: string | null
|
||||
calendarRange: { start: string; end: string }
|
||||
|
||||
setSelectedView: (view: string) => void
|
||||
setSelectedDate: (date: string) => void
|
||||
setOpenEventId: (id: string | null) => void
|
||||
setCalendarRange: (range: { start: string; end: string }) => void
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Determine breakpoint group from current viewport width. */
|
||||
function getBreakpointGroup(): BreakpointGroup {
|
||||
if (typeof window === 'undefined') return 'tablet-desktop'
|
||||
return window.matchMedia('(max-width: 767px)').matches ? 'phone' : 'tablet-desktop'
|
||||
}
|
||||
|
||||
/** localStorage key for the persisted view of a breakpoint group. */
|
||||
function viewStorageKey(group: BreakpointGroup): string {
|
||||
return `calendarView.${group}`
|
||||
}
|
||||
|
||||
/** Read the persisted view for the current breakpoint group. */
|
||||
function readPersistedView(): string {
|
||||
const group = getBreakpointGroup()
|
||||
try {
|
||||
const stored = localStorage.getItem(viewStorageKey(group))
|
||||
if (stored) return stored
|
||||
} catch {
|
||||
// localStorage may be unavailable (SSR, private mode)
|
||||
}
|
||||
// D-05 defaults: phone → 'month-agenda', tablet-desktop → 'month-grid'
|
||||
return group === 'phone' ? 'month-agenda' : 'month-grid'
|
||||
}
|
||||
|
||||
/** Build the initial calendarRange: today ± ~1 week buffer (current month ± 7 days). */
|
||||
function initialCalendarRange(): { start: string; end: string } {
|
||||
const today = new Date()
|
||||
const start = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||
start.setDate(start.getDate() - 7)
|
||||
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0)
|
||||
end.setDate(end.getDate() + 7)
|
||||
return {
|
||||
start: start.toISOString().slice(0, 10),
|
||||
end: end.toISOString().slice(0, 10),
|
||||
}
|
||||
}
|
||||
|
||||
/** Today as an ISO date string (YYYY-MM-DD). */
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
// ── Store ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const useCalendarStore = create<CalendarStore>((set) => ({
|
||||
selectedView: readPersistedView(),
|
||||
selectedDate: todayIso(),
|
||||
openEventId: null,
|
||||
calendarRange: initialCalendarRange(),
|
||||
|
||||
setSelectedView: (view: string) => {
|
||||
set({ selectedView: view })
|
||||
// Persist to localStorage keyed by breakpoint group
|
||||
const group = getBreakpointGroup()
|
||||
try {
|
||||
localStorage.setItem(viewStorageKey(group), view)
|
||||
} catch {
|
||||
// Ignore write failures
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedDate: (date: string) => set({ selectedDate: date }),
|
||||
|
||||
setOpenEventId: (id: string | null) => set({ openEventId: id }),
|
||||
|
||||
setCalendarRange: (range: { start: string; end: string }) =>
|
||||
set({ calendarRange: range }),
|
||||
}))
|
||||
Reference in New Issue
Block a user