Files
time_to_leave/apps/web/src/hooks/useEventsStore.tsx
T
fegger 7da5acbba3
CI / lint-typecheck-test (push) Has been cancelled
Add tests and improve localStorage resilience
2026-05-19 14:46:03 +02:00

122 lines
3.7 KiB
TypeScript

// ── Events Store ──
// React context-based store for events, persisted to localStorage.
// Provides add/update/remove/merge operations and automatic serialization.
"use client";
import { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from "react";
import type { Event, CalendarEvent } from "@timetoleave/core";
const STORAGE_KEY = "ttl_events";
/**
* Load events from localStorage, converting ISO-string eventTime back
* to Date objects. Returns empty array on SSR or parse failure.
*/
function loadFromStorage(): Event[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
return parsed.map((e) => ({ ...e, eventTime: new Date(e.eventTime as string) })) as Event[];
} catch {
return [];
}
}
/**
* Context value shape for the events store.
*/
interface EventsContextType {
events: Event[];
addEvent: (event: Event) => void;
updateEvent: (id: string, updates: Partial<Event>) => void;
removeEvent: (id: string) => void;
clearEvents: () => void;
setEvents: (events: Event[]) => void;
mergeEvents: (events: CalendarEvent[]) => void;
}
const EventsContext = createContext<EventsContextType | undefined>(undefined);
/**
* Provider component that wraps the app with event state management.
* Persists events to localStorage on every change (skips initial hydration write).
*/
export function EventsProvider({ children }: { children: ReactNode }) {
const [events, setEventsState] = useState<Event[]>(loadFromStorage);
const skipInitialWrite = useRef(true);
useEffect(() => {
if (skipInitialWrite.current) {
skipInitialWrite.current = false;
return;
}
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(events));
} catch (err) {
// Silently fail on quota exceeded / privacy mode
console.warn("[EventsStore] Failed to persist events:", err);
}
}, [events]);
const addEvent = useCallback((event: Event) => {
setEventsState((prev) => {
if (prev.some((e) => e.id === event.id)) return prev;
return [...prev, event];
});
}, []);
const updateEvent = useCallback((id: string, updates: Partial<Event>) => {
setEventsState((prev) => prev.map((event) => (event.id === id ? { ...event, ...updates } : event)));
}, []);
const removeEvent = useCallback((id: string) => {
setEventsState((prev) => prev.filter((event) => event.id !== id));
}, []);
const clearEvents = useCallback(() => {
setEventsState([]);
}, []);
const setEvents = useCallback((evts: Event[]) => {
setEventsState(evts);
}, []);
const mergeEvents = useCallback((calendarEvents: CalendarEvent[]) => {
const converted: Event[] = calendarEvents.map((e) => ({
id: e.id,
title: e.title,
destination: e.destination,
eventTime: new Date(e.eventTime),
source: e.source,
}));
setEventsState((prev) => {
const merged = new Map<string, Event>();
prev.forEach((event) => merged.set(event.id, event));
converted.forEach((event) => merged.set(event.id, event));
return Array.from(merged.values());
});
}, []);
return (
<EventsContext.Provider value={{ events, addEvent, updateEvent, removeEvent, clearEvents, setEvents, mergeEvents }}>
{children}
</EventsContext.Provider>
);
}
/**
* Access the events store from any component inside `EventsProvider`.
* Throws if called outside the provider (safety check).
*/
export function useEventsStore() {
const context = useContext(EventsContext);
if (context === undefined) {
throw new Error("useEventsStore must be used within an EventsProvider");
}
return context;
}