122 lines
3.7 KiB
TypeScript
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;
|
|
}
|