400 lines
11 KiB
TypeScript
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;
|
|
}
|