When a session token expires, API calls return 401 but nothing caught it — errors were shown as generic dialogs or swallowed. Now both apiClient and streamingService detect 401 responses and clear auth state, which causes App.tsx to render the login modal automatically.
515 lines
18 KiB
TypeScript
515 lines
18 KiB
TypeScript
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, streamListingGeoJSON, fetchUserPOIs, 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';
|
|
|
|
function isTerminalStatus(status: string): boolean {
|
|
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
|
}
|
|
|
|
function App() {
|
|
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [viewMode, setViewMode] = useState<ViewMode>('map');
|
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
|
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
|
|
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
|
|
const [userPOIs, setUserPOIs] = useState<POI[]>([]);
|
|
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<Record<number, POITravelFilter>>({});
|
|
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
|
|
|
// Explicit task ID set by fetch-data action (to track as "active")
|
|
const [explicitTaskId, setExplicitTaskId] = useState<string | null>(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<PropertyFeature[]>([]);
|
|
// Ref to track if initial load has been triggered
|
|
const initialLoadTriggeredRef = useRef(false);
|
|
// Ref to abort in-flight streaming requests
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
// Check if this is the callback route - render dedicated component
|
|
if (window.location.pathname === '/callback') {
|
|
return <AuthCallback />;
|
|
}
|
|
|
|
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<string>();
|
|
|
|
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]);
|
|
|
|
const handleTaskCancelled = useCallback(() => {
|
|
setExplicitTaskId(null);
|
|
}, []);
|
|
|
|
if (!user) {
|
|
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
|
}
|
|
|
|
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 (
|
|
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
|
<div className="text-center p-8 max-w-md">
|
|
{isLoading ? (
|
|
<>
|
|
<div className="text-6xl mb-4 animate-pulse">🏠</div>
|
|
<h2 className="text-xl font-semibold mb-2">Loading Properties...</h2>
|
|
<p className="text-muted-foreground mb-4">
|
|
Fetching listings with default filters. You can adjust filters on the left.
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="text-6xl mb-4">🏠</div>
|
|
<h2 className="text-xl font-semibold mb-2">Welcome to Property Explorer</h2>
|
|
<p className="text-muted-foreground mb-4">
|
|
Use the filters on the left to find properties. Apply filters to visualize existing data or refresh to fetch new listings.
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (processedListingData.features.length === 0) {
|
|
return (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<div className="text-center p-8">
|
|
<div className="text-6xl mb-4">🔍</div>
|
|
<h2 className="text-xl font-semibold mb-2">No listings found</h2>
|
|
<p className="text-muted-foreground">
|
|
Try adjusting the filters or run a data refresh to fetch new listings.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Map View */}
|
|
{(viewMode === 'map' || viewMode === 'split') && (
|
|
<div className={`relative ${viewMode === 'split' ? 'w-1/2' : 'flex-1'}`} style={{ minHeight: 0 }}>
|
|
<Map
|
|
listingData={processedListingData}
|
|
queryParameters={queryParameters}
|
|
effectiveMetric={effectiveMetric}
|
|
onPropertyClick={handlePropertyClick}
|
|
pois={userPOIs}
|
|
isPickingPOI={poiPickerActive}
|
|
onPoiLocationPick={handlePoiLocationPick}
|
|
onCancelPoiPicking={handleCancelPoiPicking}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* List View */}
|
|
{(viewMode === 'list' || viewMode === 'split') && (
|
|
<div className={`${viewMode === 'split' ? 'w-1/2 border-l' : 'flex-1'}`}>
|
|
<ListView
|
|
listingData={processedListingData}
|
|
onPropertyClick={handlePropertyClick}
|
|
highlightedPropertyUrl={highlightedProperty}
|
|
poiMetricSelection={poiMetricSelection}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<div className="h-screen flex flex-col overflow-hidden">
|
|
{/* Header */}
|
|
<Header
|
|
user={user}
|
|
tasks={tasks}
|
|
activeTaskId={activeTaskId}
|
|
isConnected={isConnected}
|
|
onCancelTask={cancelTask}
|
|
onClearAllTasks={async () => {
|
|
const result = await clearAllTasks();
|
|
if (result) {
|
|
handleTaskCancelled();
|
|
}
|
|
return result;
|
|
}}
|
|
onTaskCompleted={handleTaskCompleted}
|
|
/>
|
|
|
|
{/* Main content area */}
|
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
|
{/* Filter Panel - Desktop (fixed sidebar) */}
|
|
<div className="hidden md:block w-80 shrink-0 h-full overflow-hidden">
|
|
<div className="h-full flex flex-col">
|
|
<div className="flex-1 min-h-0">
|
|
<FilterPanel
|
|
onSubmit={onSubmit}
|
|
currentMetric={currentMetric}
|
|
isLoading={isLoading}
|
|
listingCount={processedListingData?.features.length}
|
|
user={user}
|
|
onTaskCreated={handlePOITaskCreated}
|
|
onStartPoiPicking={handleStartPoiPicking}
|
|
pickedPoiLocation={pickedPoiLocation}
|
|
userPOIs={userPOIs}
|
|
poiTravelFilters={poiTravelFilters}
|
|
onPoiTravelFiltersChange={setPoiTravelFilters}
|
|
/>
|
|
</div>
|
|
<div className="shrink-0 p-4 border-r">
|
|
<VisualizationCard
|
|
metric={currentMetric}
|
|
onMetricChange={handleMetricChange}
|
|
userPOIs={userPOIs}
|
|
onPoiMetricChange={setPoiMetricSelection}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter Panel - Mobile (sheet) */}
|
|
<div className="md:hidden fixed bottom-4 right-4 z-50">
|
|
<Sheet open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
|
|
<SheetTrigger asChild>
|
|
<Button size="lg" className="rounded-full shadow-lg h-14 w-14">
|
|
<Filter className="h-6 w-6" />
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="left" className="w-80 p-0">
|
|
<div className="h-full flex flex-col">
|
|
<div className="flex-1 min-h-0">
|
|
<FilterPanel
|
|
onSubmit={onSubmit}
|
|
currentMetric={currentMetric}
|
|
isLoading={isLoading}
|
|
listingCount={processedListingData?.features.length}
|
|
user={user}
|
|
onTaskCreated={handlePOITaskCreated}
|
|
onStartPoiPicking={handleStartPoiPicking}
|
|
pickedPoiLocation={pickedPoiLocation}
|
|
userPOIs={userPOIs}
|
|
poiTravelFilters={poiTravelFilters}
|
|
onPoiTravelFiltersChange={setPoiTravelFilters}
|
|
/>
|
|
</div>
|
|
<div className="shrink-0 p-4">
|
|
<VisualizationCard
|
|
metric={currentMetric}
|
|
onMetricChange={handleMetricChange}
|
|
userPOIs={userPOIs}
|
|
onPoiMetricChange={setPoiMetricSelection}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
|
|
{/* Main View Area */}
|
|
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
{/* Streaming Progress Bar */}
|
|
<div className="relative shrink-0">
|
|
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} />
|
|
</div>
|
|
|
|
{/* Map/List Container */}
|
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
|
{renderMainContent()}
|
|
</div>
|
|
|
|
{/* Stats Bar */}
|
|
{processedListingData && processedListingData.features.length > 0 && (
|
|
<div className="shrink-0">
|
|
<StatsBar
|
|
listingData={processedListingData}
|
|
viewMode={viewMode}
|
|
onViewModeChange={setViewMode}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Dialog */}
|
|
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|