Files
familysync/apps/pwa/src/store/calendarStore.ts
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
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.
2026-06-11 20:35:18 -04:00

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 }),
}));