wrongmove/frontend/src/App.tsx
Viktor Barzin 8ef6868881
Eliminate frontend POI waterfall for faster initial load
Listing stream fires immediately on auth without waiting for POI
fetch. POI distances are not needed for initial rendering and are
only computed when user selects POI metric or sets travel filters.
This saves ~200-500ms on initial load and keeps the stream on the
cached Redis path.
2026-02-22 13:31:10 +00:00

682 lines
25 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, 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<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);
const isMobile = useIsMobile();
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
const [showReviewMode, setShowReviewMode] = useState(false);
const [selectedListingId, setSelectedListingId] = useState<number | null>(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<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);
}, { 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 <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>
);
}
if (viewMode === 'saved') {
return (
<FavoritesView
listingData={listingData!}
getDecision={getDecision}
onSelectListing={(id) => setSelectedListingId(id)}
onRemoveFavorite={(id, type) => clear(id, type)}
/>
);
}
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}
onSelectListing={(id) => 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')}
/>
</div>
)}
</>
);
};
const renderMobileLayout = () => (
<>
{/* Full-screen map */}
<div className="flex-1 relative min-h-0">
{/* Streaming Progress Bar */}
<div className="absolute top-0 left-0 right-0 z-10">
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} onCancel={() => abortControllerRef.current?.abort()} />
</div>
{processedListingData && processedListingData.features.length > 0 ? (
<Map
listingData={processedListingData}
queryParameters={queryParameters}
effectiveMetric={effectiveMetric}
onPropertyClick={handlePropertyClick}
pois={userPOIs}
isPickingPOI={poiPickerActive}
onPoiLocationPick={handlePoiLocationPick}
onCancelPoiPicking={handleCancelPoiPicking}
/>
) : (
<div className="flex-1 flex items-center justify-center bg-muted/20 h-full">
<div className="text-center p-8 max-w-md">
{isLoading ? (
<>
<div className="text-4xl mb-4 animate-pulse">🏠</div>
<h2 className="text-lg font-semibold mb-2">Loading...</h2>
</>
) : (
<>
<div className="text-4xl mb-4">🏠</div>
<h2 className="text-lg font-semibold mb-2">Property Explorer</h2>
<p className="text-muted-foreground text-sm">
Use the filter button to find properties.
</p>
</>
)}
</div>
</div>
)}
</div>
{/* Filter & Review FABs */}
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2">
<Button
size="lg"
variant="outline"
className="rounded-full shadow-lg h-14 w-14 bg-background"
onClick={() => setShowReviewMode(true)}
disabled={!processedListingData || processedListingData.features.length === 0}
>
<Heart className="h-6 w-6" />
</Button>
<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>
{/* Bottom Sheet */}
{processedListingData && processedListingData.features.length > 0 && (
<MobileBottomSheet
listingData={processedListingData}
onPropertyClick={handlePropertyClick}
highlightedPropertyUrl={highlightedProperty}
onActiveListingChange={handleActiveListingChange}
poiMetricSelection={poiMetricSelection}
/>
)}
</>
);
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}
/>
{isMobile ? (
renderMobileLayout()
) : (
/* Desktop layout */
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Filter Panel - Desktop (fixed sidebar) */}
<div className="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>
{/* 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} onCancel={() => abortControllerRef.current?.abort()} />
</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}
likedCount={likedCount}
/>
</div>
)}
</div>
</div>
)}
{/* Swipe Review Mode Overlay */}
{showReviewMode && processedListingData && (
<SwipeReviewMode
features={processedListingData.features}
onDecide={decide}
onClear={clear}
onClose={() => setShowReviewMode(false)}
onSelectListing={(id) => setSelectedListingId(id)}
getDecision={getDecision}
/>
)}
{/* Listing Detail Bottom Sheet */}
<ListingDetailSheet
user={user}
listingId={selectedListingId}
listingType={(queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY'}
onClose={() => 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 */}
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
</div>
);
}
export default App;