Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
193 lines
7.8 KiB
TypeScript
193 lines
7.8 KiB
TypeScript
/**
|
|
* 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<CalendarStore>((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 }),
|
|
}));
|