/** * 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'; import { localDateISO } from '../lib/eventDateTime.js'; // ── Types ────────────────────────────────────────────────────────────────── type BreakpointGroup = 'phone' | 'tablet-desktop'; export interface CalendarStore { selectedView: string; selectedDate: string; openEventId: string | null; calendarRange: { start: string; end: string }; // ── EventForm UI state (Plan 03-05) ────────────────────────────────────── // Server state (events, calendars) lives in TanStack Query. These are pure // UI-shape keys: whether the form is open, what mode it is in, and which // event UID to pre-populate in edit mode. eventFormOpen: boolean; eventFormMode: 'create' | 'edit'; eventFormUid: string | null; // ── Delete dialog + sync-state UI state (Plan 03-06) ───────────────────── // Drives DeleteConfirmationDialog visibility and SyncStateToast polling. deleteDialogOpen: boolean; deleteDialogUid: string | null; /** UID of the most recently enqueued write. SyncStateToast polls sync-status for this. */ lastSyncedUid: string | null; // ── Session-expiry flag (Plan 06-05 / D-11) ───────────────────────────── // Set to true by the global QueryCache/MutationCache onError handler in main.tsx // when a SessionExpiredError is detected from any query or mutation. // Drives the "Session expired / Signing you back in…" interstitial in CalendarShell. sessionExpired: boolean; setSelectedView: (view: string) => void; setSelectedDate: (date: string) => void; setOpenEventId: (id: string | null) => void; setCalendarRange: (range: { start: string; end: string }) => void; /** * Open or close the EventForm. * * @param open true to open, false to close * @param mode 'create' (default) or 'edit' * @param uid UID of the event to pre-populate in edit mode (null otherwise) */ setEventForm: (open: boolean, mode?: 'create' | 'edit', uid?: string | null) => void; /** * Open or close the DeleteConfirmationDialog. * * @param open true to open, false to close * @param uid UID of the event to confirm-delete (null when closing) */ setDeleteDialog: (open: boolean, uid?: string | null) => void; /** * Set the UID of the most recently enqueued write, driving SyncStateToast polling. * Pass null to dismiss the toast. */ setLastSyncedUid: (uid: string | null) => void; /** * Arm the session-expiry interstitial (D-11). * Called imperatively from the global QueryCache/MutationCache onError handler * (outside React, via getState().setSessionExpired) when a SessionExpiredError * is caught from any query or mutation. */ setSessionExpired: (expired: boolean) => 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); // WR-04: localDateISO uses local calendar accessors — NEVER toISOString().slice(0,10), // which returns the UTC date and (for the project's negative-offset zones after ~20:00 // local) seeds the window/selectedDate on the wrong day. return { start: localDateISO(start), end: localDateISO(end), }; } /** Today as an ISO date string (YYYY-MM-DD). Exported for use in EventForm (IN-03). */ export function todayIso(): string { // WR-04: local-date accessor, not the banned UTC slice. return localDateISO(new Date()); } // ── Store ────────────────────────────────────────────────────────────────── export const useCalendarStore = create((set) => ({ selectedView: readPersistedView(), selectedDate: todayIso(), openEventId: null, calendarRange: initialCalendarRange(), // EventForm UI state — closed by default, create mode, no pre-fill UID eventFormOpen: false, eventFormMode: 'create', eventFormUid: null, // Delete dialog + sync-state UI state — closed/null by default deleteDialogOpen: false, deleteDialogUid: null, lastSyncedUid: null, // Session-expiry flag — false by default; set by global QueryCache/MutationCache handler sessionExpired: false, 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 }), setEventForm: (open: boolean, mode: 'create' | 'edit' = 'create', uid: string | null = null) => set({ eventFormOpen: open, eventFormMode: mode, eventFormUid: uid }), setDeleteDialog: (open: boolean, uid: string | null = null) => set({ deleteDialogOpen: open, deleteDialogUid: uid }), setLastSyncedUid: (uid: string | null) => set({ lastSyncedUid: uid }), setSessionExpired: (expired: boolean) => set({ sessionExpired: expired }), }));