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, Heart } from 'lucide-react'; import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types'; import { refreshListings, streamListingGeoJSON, fetchUserPOIs, fetchBulkPOIDistances, type StreamingProgress } from '@/services'; import { setOnUnauthorized } from '@/services/apiClient'; import { clearPasskeyUser } from './auth/passkeyService'; import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils'; import { useTaskProgress } from '@/hooks/useTaskProgress'; import { useDecisions } from '@/hooks/useDecisions'; import { useIsMobile } from '@/hooks/use-mobile'; import { MobileBottomSheet } from './components/MobileBottomSheet'; import { SwipeReviewMode } from './components/SwipeReviewMode'; import { FavoritesView } from './components/FavoritesView'; import { ListingDetailSheet } from './components/ListingDetailSheet'; function isTerminalStatus(status: string): boolean { return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; } function App() { const [listingData, setListingData] = 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); const isMobile = useIsMobile(); const [, setActiveCardFeature] = useState(null); const [showReviewMode, setShowReviewMode] = useState(false); const [selectedListingId, setSelectedListingId] = useState(null); // Decision state (like/dislike) const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user); // Explicit task ID set by fetch-data action (to track as "active") const [explicitTaskId, setExplicitTaskId] = useState(null); // Unified task progress: WS primary, polling fallback const { tasks, isConnected, subscribe, cancelTask, clearAllTasks } = useTaskProgress(user); // Derive activeTaskId: explicit ID if set, else most recent non-terminal task const activeTaskId = useMemo(() => { if (explicitTaskId && tasks[explicitTaskId]) return explicitTaskId; // Fall back to any non-terminal task const nonTerminal = Object.entries(tasks).filter( ([, t]) => !isTerminalStatus(t.status), ); if (nonTerminal.length > 0) return nonTerminal[0][0]; // Fall back to explicit even if terminal (to show final status) if (explicitTaskId && tasks[explicitTaskId]) return explicitTaskId; // Show most recent task if any const allIds = Object.keys(tasks); return allIds.length > 0 ? allIds[allIds.length - 1] : null; }, [explicitTaskId, tasks]); // 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)); } }); } }, []); useEffect(() => { setOnUnauthorized(() => { clearPasskeyUser(); setUser(null); }); }, []); const handlePasskeyLogin = (passkeyUser: AuthUser) => { setUser(passkeyUser); }; // 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); }, { 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]); // Merge POI distances into listing data after both are available useEffect(() => { if (!user || !listingData || userPOIs.length === 0 || !queryParameters) return; let cancelled = false; fetchBulkPOIDistances(user, (queryParameters.listing_type as 'RENT' | 'BUY') ?? 'RENT') .then((distanceLookup) => { if (cancelled) return; const updatedFeatures = accumulatedFeaturesRef.current.map(feature => { const id = feature.properties?.id; if (id && distanceLookup[id]) { return { ...feature, properties: { ...feature.properties, poi_distances: distanceLookup[id], }, }; } return feature; }); accumulatedFeaturesRef.current = updatedFeatures; setListingData({ type: 'FeatureCollection', features: [...updatedFeatures], }); }) .catch(() => {}); // POI distances are best-effort return () => { cancelled = true; }; }, [user, listingData?.features.length, userPOIs.length, queryParameters]); // 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; }); }); } // Filter out disliked listings (client-side for instant feedback) if (isDecisionsLoaded) { features = features.filter((f) => { const parts = f.properties.url.split('/'); const id = parseInt(parts[parts.length - 1], 10); const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; return getDecision(id, type) !== 'disliked'; }); } return { ...listingData, features }; }, [listingData, poiMetricSelection, poiTravelFilters, isDecisionsLoaded, getDecision]); // 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]); const handleTaskCancelled = useCallback(() => { setExplicitTaskId(null); }, []); const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => { setActiveCardFeature(feature); }, []); 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); setExplicitTaskId(data.task_id); if (data.task_id) subscribe(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.

); } if (viewMode === 'saved') { return ( setSelectedListingId(id)} onRemoveFavorite={(id, type) => clear(id, type)} /> ); } return ( <> {/* Map View */} {(viewMode === 'map' || viewMode === 'split') && (
)} {/* List View */} {(viewMode === 'list' || viewMode === 'split') && (
setSelectedListingId(id)} onSwipeRight={(id) => decide(id, 'liked', (queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY')} onSwipeLeft={(id) => decide(id, 'disliked', (queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY')} getDecision={(id) => getDecision(id, queryParameters?.listing_type || 'RENT')} />
)} ); }; const renderMobileLayout = () => ( <> {/* Full-screen map */}
{/* Streaming Progress Bar */}
abortControllerRef.current?.abort()} />
{processedListingData && processedListingData.features.length > 0 ? ( ) : (
{isLoading ? ( <>
🏠

Loading...

) : ( <>
🏠

Property Explorer

Use the filter button to find properties.

)}
)}
{/* Filter & Review FABs */}
{/* Bottom Sheet */} {processedListingData && processedListingData.features.length > 0 && ( )} ); const handlePOITaskCreated = (taskId: string) => { setExplicitTaskId(taskId); if (taskId) subscribe(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 */}
{ const result = await clearAllTasks(); if (result) { handleTaskCancelled(); } return result; }} onTaskCompleted={handleTaskCompleted} /> {isMobile ? ( renderMobileLayout() ) : ( /* Desktop layout */
{/* Filter Panel - Desktop (fixed sidebar) */}
{/* Main View Area */}
{/* Streaming Progress Bar */}
abortControllerRef.current?.abort()} />
{/* Map/List Container */}
{renderMainContent()}
{/* Stats Bar */} {processedListingData && processedListingData.features.length > 0 && (
)}
)} {/* Swipe Review Mode Overlay */} {showReviewMode && processedListingData && ( setShowReviewMode(false)} onSelectListing={(id) => setSelectedListingId(id)} getDecision={getDecision} /> )} {/* Listing Detail Bottom Sheet */} setSelectedListingId(null)} onDecide={(id, decision, type) => decide(id, decision, type)} onClearDecision={(id, type) => clear(id, type)} currentDecision={selectedListingId ? getDecision(selectedListingId, queryParameters?.listing_type || 'RENT') : undefined} /> {/* Error Dialog */}
); } export default App;