diff options
Diffstat (limited to 'src/frontend/app/contexts/PlannerContext.tsx')
| -rw-r--r-- | src/frontend/app/contexts/PlannerContext.tsx | 381 |
1 files changed, 381 insertions, 0 deletions
diff --git a/src/frontend/app/contexts/PlannerContext.tsx b/src/frontend/app/contexts/PlannerContext.tsx new file mode 100644 index 0000000..8b64a2e --- /dev/null +++ b/src/frontend/app/contexts/PlannerContext.tsx @@ -0,0 +1,381 @@ +import { useQueryClient } from "@tanstack/react-query"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { type PlannerSearchResult, type RoutePlan } from "../data/PlannerApi"; +import { usePlanQuery } from "../hooks/usePlanQuery"; + +const STORAGE_KEY = "planner_route_history"; +const RECENT_KEY = "recentPlaces"; +const EXPIRY_MS = 2 * 60 * 60 * 1000; // 2 hours + +interface StoredRoute { + timestamp: number; + origin: PlannerSearchResult; + destination: PlannerSearchResult; + plan?: RoutePlan; + searchTime?: Date; + arriveBy?: boolean; + selectedItineraryIndex?: number; +} + +export type PickingMode = "origin" | "destination" | null; + +interface PlannerContextType { + origin: PlannerSearchResult | null; + setOrigin: (origin: PlannerSearchResult | null) => void; + destination: PlannerSearchResult | null; + setDestination: (destination: PlannerSearchResult | null) => void; + plan: RoutePlan | null; + loading: boolean; + error: string | null; + searchTime: Date | null; + setSearchTime: (time: Date | null) => void; + arriveBy: boolean; + setArriveBy: (arriveBy: boolean) => void; + selectedItineraryIndex: number | null; + history: StoredRoute[]; + recentPlaces: PlannerSearchResult[]; + addRecentPlace: (place: PlannerSearchResult) => void; + clearRecentPlaces: () => void; + pickingMode: PickingMode; + setPickingMode: (mode: PickingMode) => void; + isExpanded: boolean; + setIsExpanded: (expanded: boolean) => void; + searchRoute: ( + from: PlannerSearchResult, + to: PlannerSearchResult, + time?: Date, + arriveByParam?: boolean + ) => Promise<void>; + loadRoute: (route: StoredRoute) => void; + clearRoute: () => void; + selectItinerary: (index: number) => void; + deselectItinerary: () => void; +} + +const PlannerContext = createContext<PlannerContextType | undefined>(undefined); + +export const PlannerProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [origin, setOrigin] = useState<PlannerSearchResult | null>(null); + const [destination, setDestination] = useState<PlannerSearchResult | null>( + null + ); + const [plan, setPlan] = useState<RoutePlan | null>(null); + const [searchTime, setSearchTime] = useState<Date | null>(null); + const [arriveBy, setArriveBy] = useState(false); + const [selectedItineraryIndex, setSelectedItineraryIndex] = useState< + number | null + >(null); + const [history, setHistory] = useState<StoredRoute[]>([]); + const [recentPlaces, setRecentPlaces] = useState<PlannerSearchResult[]>([]); + const [pickingMode, setPickingMode] = useState<PickingMode>(null); + const [isExpanded, setIsExpanded] = useState(false); + + const queryClient = useQueryClient(); + + // Load recent places from localStorage + useEffect(() => { + try { + const raw = localStorage.getItem(RECENT_KEY); + if (raw) { + const parsed = JSON.parse(raw) as PlannerSearchResult[]; + setRecentPlaces(parsed.slice(0, 20)); + } + } catch { + setRecentPlaces([]); + } + }, []); + + const addRecentPlace = useCallback((p: PlannerSearchResult) => { + setRecentPlaces((prev) => { + const key = `${p.lat.toFixed(5)},${p.lon.toFixed(5)}`; + const existing = prev.filter( + (rp) => `${rp.lat.toFixed(5)},${rp.lon.toFixed(5)}` !== key + ); + const updated = [ + { + name: p.name, + label: p.label, + lat: p.lat, + lon: p.lon, + layer: p.layer, + }, + ...existing, + ].slice(0, 20); + try { + localStorage.setItem(RECENT_KEY, JSON.stringify(updated)); + } catch {} + return updated; + }); + }, []); + + const clearRecentPlaces = useCallback(() => { + setRecentPlaces([]); + try { + localStorage.removeItem(RECENT_KEY); + } catch {} + }, []); + + const { + data: queryPlan, + isLoading: queryLoading, + error: queryError, + isFetching, + } = usePlanQuery( + origin?.lat, + origin?.lon, + destination?.lat, + destination?.lon, + searchTime ?? undefined, + arriveBy, + !!(origin && destination && searchTime) + ); + + // Sync query result to local state and storage + useEffect(() => { + if (queryPlan) { + setPlan(queryPlan as any); + + if (origin && destination) { + const toStore: StoredRoute = { + timestamp: Date.now(), + origin, + destination, + plan: queryPlan as any, + searchTime: searchTime ?? new Date(), + arriveBy, + selectedItineraryIndex: selectedItineraryIndex ?? undefined, + }; + + setHistory((prev) => { + const filtered = prev.filter( + (r) => + !( + r.origin.lat === origin.lat && + r.origin.lon === origin.lon && + r.destination.lat === destination.lat && + r.destination.lon === destination.lon + ) + ); + const updated = [toStore, ...filtered].slice(0, 3); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + } + } + }, [ + queryPlan, + origin, + destination, + searchTime, + arriveBy, + selectedItineraryIndex, + ]); + + // Load from storage on mount + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const data: StoredRoute[] = JSON.parse(stored); + const valid = data.filter((r) => Date.now() - r.timestamp < EXPIRY_MS); + setHistory(valid); + + if (valid.length > 0) { + const last = valid[0]; + if (last.plan) { + queryClient.setQueryData( + [ + "plan", + last.origin.lat, + last.origin.lon, + last.destination.lat, + last.destination.lon, + last.searchTime + ? new Date(last.searchTime).toISOString() + : undefined, + last.arriveBy ?? false, + ], + last.plan + ); + setPlan(last.plan); + } + setOrigin(last.origin); + setDestination(last.destination); + setSearchTime(last.searchTime ? new Date(last.searchTime) : null); + setArriveBy(last.arriveBy ?? false); + setSelectedItineraryIndex(last.selectedItineraryIndex ?? null); + } + } catch (e) { + localStorage.removeItem(STORAGE_KEY); + } + } + }, [queryClient]); + + const searchRoute = async ( + from: PlannerSearchResult, + to: PlannerSearchResult, + time?: Date, + arriveByParam: boolean = false + ) => { + setOrigin(from); + setDestination(to); + const finalTime = time ?? new Date(); + setSearchTime(finalTime); + setArriveBy(arriveByParam); + setSelectedItineraryIndex(null); + + const toStore: StoredRoute = { + timestamp: Date.now(), + origin: from, + destination: to, + searchTime: finalTime, + arriveBy: arriveByParam, + }; + + setHistory((prev) => { + const filtered = prev.filter( + (r) => + !( + r.origin.lat === from.lat && + r.origin.lon === from.lon && + r.destination.lat === to.lat && + r.destination.lon === to.lon + ) + ); + const updated = [toStore, ...filtered].slice(0, 3); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }; + + const loadRoute = useCallback( + (route: StoredRoute) => { + if (route.plan) { + queryClient.setQueryData( + [ + "plan", + route.origin.lat, + route.origin.lon, + route.destination.lat, + route.destination.lon, + route.searchTime + ? new Date(route.searchTime).toISOString() + : undefined, + route.arriveBy ?? false, + ], + route.plan + ); + setPlan(route.plan); + } + setOrigin(route.origin); + setDestination(route.destination); + setSearchTime(route.searchTime ? new Date(route.searchTime) : null); + setArriveBy(route.arriveBy ?? false); + setSelectedItineraryIndex(route.selectedItineraryIndex ?? null); + + setHistory((prev) => { + const filtered = prev.filter( + (r) => + !( + r.origin.lat === route.origin.lat && + r.origin.lon === route.origin.lon && + r.destination.lat === route.destination.lat && + r.destination.lon === route.destination.lon + ) + ); + const updated = [ + { ...route, timestamp: Date.now() }, + ...filtered, + ].slice(0, 3); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, + [queryClient] + ); + + const clearRoute = useCallback(() => { + setPlan(null); + setOrigin(null); + setDestination(null); + setSearchTime(null); + setArriveBy(false); + setSelectedItineraryIndex(null); + setHistory([]); + localStorage.removeItem(STORAGE_KEY); + }, []); + + const selectItinerary = useCallback((index: number) => { + setSelectedItineraryIndex(index); + setHistory((prev) => { + if (prev.length === 0) return prev; + const updated = [...prev]; + updated[0] = { ...updated[0], selectedItineraryIndex: index }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, []); + + const deselectItinerary = useCallback(() => { + setSelectedItineraryIndex(null); + setHistory((prev) => { + if (prev.length === 0) return prev; + const updated = [...prev]; + updated[0] = { ...updated[0], selectedItineraryIndex: undefined }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + return updated; + }); + }, []); + + return ( + <PlannerContext.Provider + value={{ + origin, + setOrigin, + destination, + setDestination, + plan, + loading: queryLoading || (isFetching && !plan), + error: queryError + ? "Failed to calculate route. Please try again." + : null, + searchTime, + setSearchTime, + arriveBy, + setArriveBy, + selectedItineraryIndex, + history, + recentPlaces, + addRecentPlace, + clearRecentPlaces, + pickingMode, + setPickingMode, + isExpanded, + setIsExpanded, + searchRoute, + loadRoute, + clearRoute, + selectItinerary, + deselectItinerary, + }} + > + {children} + </PlannerContext.Provider> + ); +}; + +export const usePlannerContext = () => { + const context = useContext(PlannerContext); + if (context === undefined) { + throw new Error("usePlannerContext must be used within a PlannerProvider"); + } + return context; +}; |
