mission-control/components/calendar/CalendarContext.tsx

400 lines
11 KiB
TypeScript

"use client";
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
import { TokenResponse, useGoogleLogin } from "@react-oauth/google";
interface CalendarEvent {
id: string;
summary: string;
description?: string;
start: {
dateTime?: string;
date?: string;
};
end: {
dateTime?: string;
date?: string;
};
location?: string;
calendarId: string;
calendarName: string;
}
interface Calendar {
id: string;
summary: string;
primary?: boolean;
selected?: boolean;
}
interface CalendarContextType {
events: CalendarEvent[];
calendars: Calendar[];
isAuthenticated: boolean;
isLoading: boolean;
lastSynced: Date | null;
selectedCalendars: string[];
error: string | null;
login: () => void;
logout: () => void;
refreshEvents: () => Promise<void>;
toggleCalendar: (calendarId: string) => void;
selectAllCalendars: () => void;
deselectAllCalendars: () => void;
}
const CalendarContext = createContext<CalendarContextType | undefined>(undefined);
const STORAGE_KEY = "google_calendar_tokens";
const CACHE_KEY = "calendar_events_cache";
const CALENDARS_CACHE_KEY = "calendar_list_cache";
const CACHE_DURATION = 1000 * 60 * 15; // 15 minutes
interface CachedData {
events: CalendarEvent[];
timestamp: number;
selectedCalendars: string[];
}
interface CachedCalendars {
calendars: Calendar[];
timestamp: number;
}
export function CalendarProvider({ children }: { children: ReactNode }) {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [calendars, setCalendars] = useState<Calendar[]>([]);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [lastSynced, setLastSynced] = useState<Date | null>(null);
const [selectedCalendars, setSelectedCalendars] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const isAuthenticated = !!accessToken;
// Load cached data on mount
useEffect(() => {
if (typeof window === "undefined" || isInitialized) return;
const tokens = localStorage.getItem(STORAGE_KEY);
if (tokens) {
try {
const parsed = JSON.parse(tokens);
if (parsed.access_token) {
setAccessToken(parsed.access_token);
}
} catch (e) {
console.error("Failed to parse stored tokens");
}
}
// Load cached events
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
const parsed: CachedData = JSON.parse(cached);
const now = Date.now();
if (now - parsed.timestamp < CACHE_DURATION * 4) { // Cache valid for 1 hour
setEvents(parsed.events);
setSelectedCalendars(parsed.selectedCalendars || []);
setLastSynced(new Date(parsed.timestamp));
}
} catch (e) {
console.error("Failed to parse cached events");
}
}
// Load cached calendars
const cachedCalendars = localStorage.getItem(CALENDARS_CACHE_KEY);
if (cachedCalendars) {
try {
const parsed: CachedCalendars = JSON.parse(cachedCalendars);
const now = Date.now();
if (now - parsed.timestamp < CACHE_DURATION * 4) {
setCalendars(parsed.calendars);
}
} catch (e) {
console.error("Failed to parse cached calendars");
}
}
setIsInitialized(true);
}, [isInitialized]);
const fetchCalendars = useCallback(async (token: string) => {
try {
const response = await fetch(
"https://www.googleapis.com/calendar/v3/users/me/calendarList",
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
if (response.status === 401) {
logout();
throw new Error("Session expired. Please sign in again.");
}
throw new Error("Failed to fetch calendars");
}
const data = await response.json();
const calendarList: Calendar[] = data.items.map((cal: any) => ({
id: cal.id,
summary: cal.summary,
primary: cal.primary || false,
selected: cal.selected !== false,
}));
setCalendars(calendarList);
// Select primary calendar and any previously selected
const primaryCal = calendarList.find((c) => c.primary);
const defaultSelected = primaryCal ? [primaryCal.id] : calendarList.slice(0, 1).map(c => c.id);
// Merge with cached selection if available
const cached = localStorage.getItem(CACHE_KEY);
let finalSelection = defaultSelected;
if (cached) {
try {
const parsed: CachedData = JSON.parse(cached);
const validCached = parsed.selectedCalendars?.filter(id =>
calendarList.some(c => c.id === id)
) || [];
if (validCached.length > 0) {
finalSelection = validCached;
}
} catch (e) {}
}
setSelectedCalendars(finalSelection);
// Cache calendars
const cacheData: CachedCalendars = {
calendars: calendarList,
timestamp: Date.now(),
};
localStorage.setItem(CALENDARS_CACHE_KEY, JSON.stringify(cacheData));
return calendarList;
} catch (err) {
console.error("Error fetching calendars:", err);
throw err;
}
}, []);
const fetchEventsForCalendar = useCallback(async (
token: string,
calendarId: string,
calendarName: string
): Promise<CalendarEvent[]> => {
const now = new Date();
const oneMonthLater = new Date();
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
const timeMin = now.toISOString();
const timeMax = oneMonthLater.toISOString();
const encodedCalendarId = encodeURIComponent(calendarId);
const url = `https://www.googleapis.com/calendar/v3/calendars/${encodedCalendarId}/events?` +
new URLSearchParams({
timeMin,
timeMax,
singleEvents: "true",
orderBy: "startTime",
maxResults: "100",
});
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
if (response.status === 401) {
logout();
throw new Error("Session expired. Please sign in again.");
}
console.error(`Failed to fetch events for calendar ${calendarId}:`, response.statusText);
return [];
}
const data = await response.json();
return (data.items || []).map((event: any) => ({
id: event.id,
summary: event.summary || "(No title)",
description: event.description,
start: event.start,
end: event.end,
location: event.location,
calendarId,
calendarName,
}));
}, []);
const refreshEvents = useCallback(async () => {
if (!accessToken) return;
setIsLoading(true);
setError(null);
try {
// Fetch calendars if not already loaded
if (calendars.length === 0) {
await fetchCalendars(accessToken);
}
// Fetch events from selected calendars
const activeCalendars = calendars.filter(
(cal) => selectedCalendars.includes(cal.id) || (selectedCalendars.length === 0 && cal.primary)
);
if (activeCalendars.length === 0 && calendars.length > 0) {
// If no calendars selected but we have calendars, use primary
const primary = calendars.find(c => c.primary) || calendars[0];
activeCalendars.push(primary);
}
const allEvents: CalendarEvent[] = [];
for (const calendar of activeCalendars) {
try {
const events = await fetchEventsForCalendar(
accessToken,
calendar.id,
calendar.summary
);
allEvents.push(...events);
} catch (err) {
console.error(`Error fetching events for ${calendar.summary}:`, err);
}
}
// Sort by start time
allEvents.sort((a, b) => {
const dateA = new Date(a.start.dateTime || a.start.date || 0);
const dateB = new Date(b.start.dateTime || b.start.date || 0);
return dateA.getTime() - dateB.getTime();
});
setEvents(allEvents);
const now = new Date();
setLastSynced(now);
// Cache the events
const cacheData: CachedData = {
events: allEvents,
timestamp: now.getTime(),
selectedCalendars,
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to refresh events";
setError(message);
console.error("Error refreshing events:", err);
} finally {
setIsLoading(false);
}
}, [accessToken, calendars, selectedCalendars, fetchCalendars, fetchEventsForCalendar]);
// Auto-refresh when authenticated and initialized
useEffect(() => {
if (isAuthenticated && isInitialized && events.length === 0) {
refreshEvents();
}
}, [isAuthenticated, isInitialized, refreshEvents, events.length]);
const login = useGoogleLogin({
onSuccess: async (tokenResponse: TokenResponse) => {
setAccessToken(tokenResponse.access_token);
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokenResponse));
setError(null);
// Fetch calendars and events immediately after login
try {
await fetchCalendars(tokenResponse.access_token);
} catch (err) {
console.error("Error fetching calendars after login:", err);
}
},
onError: () => {
setError("Failed to sign in with Google");
},
scope: "https://www.googleapis.com/auth/calendar.readonly",
});
const logout = useCallback(() => {
setAccessToken(null);
setEvents([]);
setCalendars([]);
setSelectedCalendars([]);
setLastSynced(null);
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(CACHE_KEY);
localStorage.removeItem(CALENDARS_CACHE_KEY);
setError(null);
}, []);
const toggleCalendar = useCallback((calendarId: string) => {
setSelectedCalendars((prev) => {
const newSelection = prev.includes(calendarId)
? prev.filter((id) => id !== calendarId)
: [...prev, calendarId];
// Update cache with new selection
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
try {
const parsed: CachedData = JSON.parse(cached);
parsed.selectedCalendars = newSelection;
localStorage.setItem(CACHE_KEY, JSON.stringify(parsed));
} catch (e) {}
}
return newSelection;
});
}, []);
const selectAllCalendars = useCallback(() => {
const allIds = calendars.map((c) => c.id);
setSelectedCalendars(allIds);
}, [calendars]);
const deselectAllCalendars = useCallback(() => {
setSelectedCalendars([]);
}, []);
return (
<CalendarContext.Provider
value={{
events,
calendars,
isAuthenticated,
isLoading,
lastSynced,
selectedCalendars,
error,
login,
logout,
refreshEvents,
toggleCalendar,
selectAllCalendars,
deselectAllCalendars,
}}
>
{children}
</CalendarContext.Provider>
);
}
export function useCalendar() {
const context = useContext(CalendarContext);
if (context === undefined) {
throw new Error("useCalendar must be used within a CalendarProvider");
}
return context;
}