import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import './App.css'; import { getUser } from './auth/authService'; import { getStoredPasskeyUser } from './auth/passkeyService'; import { fromOidcUser, type AuthUser } from './auth/types'; import AlertError from './components/AlertError'; import LoginModal from './components/LoginModal'; import AuthCallback from './components/AuthCallback'; import { Map } from './components/Map'; import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel'; import { VisualizationCard } from './components/VisualizationCard'; import { Header } from './components/Header'; import { StatsBar, type ViewMode } from './components/StatsBar'; import { ListView } from './components/ListView'; import { StreamingProgressBar } from './components/StreamingProgressBar'; import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet'; import { Button } from './components/ui/button'; import { Filter } from 'lucide-react'; import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types'; import { refreshListings, fetchTasksForUser, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services'; import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils'; import { useTaskWebSocket } from '@/hooks/useTaskWebSocket'; function App() { const [listingData, setListingData] = useState(null); const [taskID, setTaskID] = useState(null); const [user, setUser] = useState(null); const [queryParameters, setQueryParameters] = useState(null); const [submitError, setSubmitError] = useState(null); const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [viewMode, setViewMode] = useState('map'); const [mobileFilterOpen, setMobileFilterOpen] = useState(false); const [highlightedProperty, setHighlightedProperty] = useState(null); const [streamingProgress, setStreamingProgress] = useState(null); const [userPOIs, setUserPOIs] = useState([]); const [poiPickerActive, setPoiPickerActive] = useState(false); const [pickedPoiLocation, setPickedPoiLocation] = useState<{ lat: number; lng: number } | null>(null); const [poiMetricSelection, setPoiMetricSelection] = useState<{ poiId: number; poiName: string; travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT'; } | null>(null); const [poiTravelFilters, setPoiTravelFilters] = useState>({}); const [currentMetric, setCurrentMetric] = useState(DEFAULT_FILTER_VALUES.metric); // WebSocket-based real-time task progress const { tasks: wsTasks, isConnected: wsConnected, subscribe: wsSubscribe } = useTaskWebSocket(user); // Ref to track accumulated features during streaming const accumulatedFeaturesRef = useRef([]); // Ref to track if initial load has been triggered const initialLoadTriggeredRef = useRef(false); // Ref to abort in-flight streaming requests const abortControllerRef = useRef(null); // Check if this is the callback route - render dedicated component if (window.location.pathname === '/callback') { return ; } useEffect(() => { // Check passkey user first, then fall back to OIDC const passkeyUser = getStoredPasskeyUser(); if (passkeyUser) { setUser(passkeyUser); } else { getUser().then((oidcUser) => { if (oidcUser) { setUser(fromOidcUser(oidcUser)); } }); } }, []); const handlePasskeyLogin = (passkeyUser: AuthUser) => { setUser(passkeyUser); }; useEffect(() => { if (!user) { return; } fetchTasksForUser(user).then((tasks) => { if (tasks && tasks.length > 0) { setTaskID(tasks[0]); } }); }, [user, taskID]); // Load user's POIs useEffect(() => { if (!user) return; fetchUserPOIs(user).then(setUserPOIs).catch(() => {}); }, [user]); // Load listings function - used by both auto-load and manual submit const loadListings = useCallback(async (parameters: ParameterValues) => { if (!user) return; // Abort any in-flight streaming request if (abortControllerRef.current) { abortControllerRef.current.abort(); } const controller = new AbortController(); abortControllerRef.current = controller; setQueryParameters(parameters); setMobileFilterOpen(false); setIsLoading(true); accumulatedFeaturesRef.current = []; setStreamingProgress({ count: 0 }); setListingData(null); // Dedup safety net: track seen URLs to prevent duplicate features const seenUrls = new Set(); let updateScheduled = false; const flushUpdate = () => { updateScheduled = false; setListingData({ type: 'FeatureCollection', features: [...accumulatedFeaturesRef.current] }); }; const scheduleUpdate = () => { if (!updateScheduled) { updateScheduled = true; requestAnimationFrame(flushUpdate); } }; try { for await (const batch of streamListingGeoJSON(user, parameters, (progress) => { setStreamingProgress(progress); }, { includePoiDistances: userPOIs.length > 0, signal: controller.signal })) { // Deduplicate features by URL const uniqueBatch = batch.filter((feature) => { const url = feature.properties?.url; if (!url || seenUrls.has(url)) return false; seenUrls.add(url); return true; }); if (uniqueBatch.length > 0) { accumulatedFeaturesRef.current.push(...uniqueBatch); scheduleUpdate(); } } // Final flush to ensure all data is rendered flushUpdate(); } catch (error) { // Silently ignore AbortError — it means we intentionally cancelled if (error instanceof DOMException && error.name === 'AbortError') { return; } if (error instanceof Error) { setSubmitError(error.message); } else { setSubmitError(String(error)); } setAlertDialogIsOpen(true); } finally { // Only clear loading state if this controller is still the current one if (abortControllerRef.current === controller) { setIsLoading(false); setStreamingProgress(null); } } }, [user, userPOIs]); // Compute processed listing data: inject synthetic POI metric property & apply max travel filter const processedListingData = useMemo(() => { if (!listingData) return null; let features = listingData.features; // Inject synthetic flat property for the selected POI metric if (poiMetricSelection) { features = injectPoiMetricProperty( features, poiMetricSelection.poiId, poiMetricSelection.travelMode, ); } // Filter by per-POI max travel time (AND logic: listing must satisfy all active filters) const activeFilters = Object.entries(poiTravelFilters) .filter(([, f]) => f.maxMinutes !== undefined) .map(([id, f]) => ({ poiId: Number(id), travelMode: f.travelMode, maxMinutes: f.maxMinutes! })); if (activeFilters.length > 0) { features = features.filter((f) => { const distances = f.properties.poi_distances; if (!distances) return false; return activeFilters.every((filter) => { const dist = distances.find( (d) => d.poi_id === filter.poiId && d.travel_mode === filter.travelMode, ); return dist !== undefined && dist.duration_seconds <= filter.maxMinutes * 60; }); }); } return { ...listingData, features }; }, [listingData, poiMetricSelection, poiTravelFilters]); // Compute the effective metric string for the heatmap const effectiveMetric = useMemo(() => { if (queryParameters?.metric === Metric.poi_travel && poiMetricSelection) { return poiMetricPropertyName(poiMetricSelection.poiId, poiMetricSelection.travelMode); } return queryParameters?.metric; }, [queryParameters?.metric, poiMetricSelection]); // Auto-load data with default filters when user is authenticated useEffect(() => { if (!user || initialLoadTriggeredRef.current) { return; } initialLoadTriggeredRef.current = true; const defaultParams: ParameterValues = { ...DEFAULT_FILTER_VALUES, available_from: new Date(), }; loadListings(defaultParams); }, [user, loadListings]); const handleTaskCompleted = useCallback(() => { if (queryParameters) { loadListings(queryParameters); } }, [queryParameters, loadListings]); if (!user) { return ; } const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => { if (action === 'visualize') { loadListings(parameters); } else if (action === 'fetch-data') { setQueryParameters(parameters); setMobileFilterOpen(false); setIsLoading(true); try { const data = await refreshListings(user!, parameters); setTaskID(data.task_id); if (data.task_id) wsSubscribe(data.task_id); } catch (error) { if (error instanceof Error) { setSubmitError(error.message); } else { setSubmitError(String(error)); } setAlertDialogIsOpen(true); } finally { setIsLoading(false); } } }; const handleMetricChange = (metric: Metric) => { setCurrentMetric(metric); setQueryParameters(prev => prev ? { ...prev, metric } : null); }; const handlePropertyClick = (property: PropertyProperties, _coordinates: [number, number]) => { setHighlightedProperty(property.url); // Optionally: pan map to coordinates }; const renderMainContent = () => { if (!processedListingData) { return (
{isLoading ? ( <>
🏠

Loading Properties...

Fetching listings with default filters. You can adjust filters on the left.

) : ( <>
🏠

Welcome to Property Explorer

Use the filters on the left to find properties. Apply filters to visualize existing data or refresh to fetch new listings.

)}
); } if (processedListingData.features.length === 0) { return (
🔍

No listings found

Try adjusting the filters or run a data refresh to fetch new listings.

); } return ( <> {/* Map View */} {(viewMode === 'map' || viewMode === 'split') && (
)} {/* List View */} {(viewMode === 'list' || viewMode === 'split') && (
)} ); }; const handleTaskCancelled = () => { setTaskID(null); }; const handlePOITaskCreated = (taskId: string) => { setTaskID(taskId); if (taskId) wsSubscribe(taskId); // Refresh POI list in case new ones were created if (user) { fetchUserPOIs(user).then(setUserPOIs).catch(() => {}); } }; const handleStartPoiPicking = () => { setPoiPickerActive(true); setPickedPoiLocation(null); }; const handlePoiLocationPick = (lat: number, lng: number) => { setPickedPoiLocation({ lat, lng }); setPoiPickerActive(false); }; const handleCancelPoiPicking = () => { setPoiPickerActive(false); }; return (
{/* Header */}
{/* Main content area */}
{/* Filter Panel - Desktop (fixed sidebar) */}
{/* Filter Panel - Mobile (sheet) */}
{/* Main View Area */}
{/* Streaming Progress Bar */}
{/* Map/List Container */}
{renderMainContent()}
{/* Stats Bar */} {processedListingData && processedListingData.features.length > 0 && (
)}
{/* Error Dialog */}
); } export default App;