From 81d31eaecf92206fc95a1a28245b67c049999636 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 8 Feb 2026 15:11:21 +0000 Subject: [PATCH] Auto-reload listings on task completion and show all POIs in detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread onTaskCompleted callback from TaskIndicator through Header to App.tsx so listings auto-refresh when a background task (e.g. POI distance calculation) completes. Add AllPOIDistances component to PropertyCard that shows all user POIs with travel times or — placeholder for missing modes. --- frontend/src/App.tsx | 105 +++++++++++++-- frontend/src/components/Header.tsx | 4 +- frontend/src/components/Map.tsx | 151 ++++++++++++---------- frontend/src/components/PropertyCard.tsx | 44 ++++++- frontend/src/components/TaskIndicator.tsx | 11 +- 5 files changed, 227 insertions(+), 88 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8f4b8d..67f5e58 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef, useCallback } from 'react'; +import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import './App.css'; import { getUser } from './auth/authService'; import { getStoredPasskeyUser } from './auth/passkeyService'; @@ -17,6 +17,7 @@ import { Button } from './components/ui/button'; import { Filter } from 'lucide-react'; import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI } from '@/types'; import { refreshListings, fetchTasksForUser, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services'; +import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils'; function App() { const [listingData, setListingData] = useState(null); @@ -31,6 +32,14 @@ function App() { 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 [maxTravelMinutes, setMaxTravelMinutes] = useState(undefined); // Ref to track accumulated features during streaming const accumulatedFeaturesRef = useRef([]); @@ -108,7 +117,7 @@ function App() { try { for await (const batch of streamListingGeoJSON(user, parameters, (progress) => { setStreamingProgress(progress); - })) { + }, { includePoiDistances: userPOIs.length > 0 })) { accumulatedFeaturesRef.current.push(...batch); scheduleUpdate(); } @@ -125,7 +134,43 @@ function App() { setIsLoading(false); setStreamingProgress(null); } - }, [user]); + }, [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 max travel time + if (maxTravelMinutes !== undefined && poiMetricSelection) { + const maxSeconds = maxTravelMinutes * 60; + const propName = poiMetricPropertyName(poiMetricSelection.poiId, poiMetricSelection.travelMode); + features = features.filter((f) => { + const value = (f.properties as Record)[propName] as number | undefined; + return value !== undefined && value <= maxSeconds; + }); + } + + return { ...listingData, features }; + }, [listingData, poiMetricSelection, maxTravelMinutes]); + + // 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(() => { @@ -142,6 +187,12 @@ function App() { loadListings(defaultParams); }, [user, loadListings]); + const handleTaskCompleted = useCallback(() => { + if (queryParameters) { + loadListings(queryParameters); + } + }, [queryParameters, loadListings]); + if (!user) { return ; } @@ -179,7 +230,7 @@ function App() { }; const renderMainContent = () => { - if (!listingData) { + if (!processedListingData) { return (
@@ -205,7 +256,7 @@ function App() { ); } - if (listingData.features.length === 0) { + if (processedListingData.features.length === 0) { return (
@@ -225,10 +276,14 @@ function App() { {(viewMode === 'map' || viewMode === 'split') && (
)} @@ -237,9 +292,10 @@ function App() { {(viewMode === 'list' || viewMode === 'split') && (
)} @@ -259,6 +315,20 @@ function App() { } }; + const handleStartPoiPicking = () => { + setPoiPickerActive(true); + setPickedPoiLocation(null); + }; + + const handlePoiLocationPick = (lat: number, lng: number) => { + setPickedPoiLocation({ lat, lng }); + setPoiPickerActive(false); + }; + + const handleCancelPoiPicking = () => { + setPoiPickerActive(false); + }; + return (
{/* Header */} @@ -266,6 +336,7 @@ function App() { user={user} taskID={taskID} onTaskCancelled={handleTaskCancelled} + onTaskCompleted={handleTaskCompleted} /> {/* Main content area */} @@ -276,9 +347,15 @@ function App() { onSubmit={onSubmit} onMetricChange={handleMetricChange} isLoading={isLoading} - listingCount={listingData?.features.length} + listingCount={processedListingData?.features.length} user={user} onTaskCreated={handlePOITaskCreated} + onStartPoiPicking={handleStartPoiPicking} + pickedPoiLocation={pickedPoiLocation} + userPOIs={userPOIs} + onPoiMetricChange={setPoiMetricSelection} + maxTravelMinutes={maxTravelMinutes} + onMaxTravelMinutesChange={setMaxTravelMinutes} />
@@ -295,9 +372,15 @@ function App() { onSubmit={onSubmit} onMetricChange={handleMetricChange} isLoading={isLoading} - listingCount={listingData?.features.length} + listingCount={processedListingData?.features.length} user={user} onTaskCreated={handlePOITaskCreated} + onStartPoiPicking={handleStartPoiPicking} + pickedPoiLocation={pickedPoiLocation} + userPOIs={userPOIs} + onPoiMetricChange={setPoiMetricSelection} + maxTravelMinutes={maxTravelMinutes} + onMaxTravelMinutesChange={setMaxTravelMinutes} /> @@ -316,10 +399,10 @@ function App() {
{/* Stats Bar */} - {listingData && listingData.features.length > 0 && ( + {processedListingData && processedListingData.features.length > 0 && (
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 29d0cf0..c49f377 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -15,6 +15,7 @@ interface HeaderProps { onToggleFilters?: () => void; showFilterToggle?: boolean; onTaskCancelled?: () => void; + onTaskCompleted?: () => void; } export function Header({ @@ -24,6 +25,7 @@ export function Header({ onToggleFilters, showFilterToggle = false, onTaskCancelled, + onTaskCompleted, }: HeaderProps) { const handleLogout = async () => { if (user.provider === 'passkey') { @@ -48,7 +50,7 @@ export function Header({ {/* Task Indicator */} - + {/* Filter Toggle (mobile) */} {showFilterToggle && ( diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index ffbc095..4ff0074 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -1,8 +1,8 @@ -import crossfilter from "crossfilter2"; import * as d3 from "d3"; import mapboxgl from "mapbox-gl"; import 'mapbox-gl/dist/mapbox-gl.css'; import { useEffect, useRef, useMemo, useCallback } from "react"; +import { Crosshair } from "lucide-react"; import { renderToString } from 'react-dom/server'; import "../assets/Map.css"; import { Metric, type ParameterValues } from "./Parameters"; @@ -11,7 +11,7 @@ import { ScrollArea } from "./ui/scroll-area"; import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POI } from "@/types"; import { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants"; import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes"; -import { clone, percentile, calculateColorStops } from "@/utils/mapUtils"; +import { percentile, calculateColorStops } from "@/utils/mapUtils"; // Type declaration for the external HexgridHeatmap library declare class HexgridHeatmap { @@ -32,22 +32,15 @@ interface PropertyWithCoords { properties: PropertyProperties; } -interface CrossfilterRecord extends PropertyProperties { - index: number; -} - interface MapProps { listingData: GeoJSONFeatureCollection; queryParameters: ParameterValues | null; + effectiveMetric?: string; onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void; pois?: POI[]; -} - -interface FilterState { - city: string; - country: string | null; - mode: string; - count?: number; + isPickingPOI?: boolean; + onPoiLocationPick?: (lat: number, lng: number) => void; + onCancelPoiPicking?: () => void; } export function Map(props: MapProps) { @@ -60,20 +53,19 @@ export function Map(props: MapProps) { const isMapLoadedRef = useRef(false); const lastDataLengthRef = useRef(0); const poiMarkersRef = useRef([]); + const isPickingPOIRef = useRef(props.isPickingPOI ?? false); + const onPoiLocationPickRef = useRef(props.onPoiLocationPick); - const filter: FilterState = { city: 'London', country: null, mode: Metric.qmprice }; - if (props.queryParameters) { - filter.mode = props.queryParameters.metric; - } + const metricMode = props.effectiveMetric ?? props.queryParameters?.metric ?? Metric.qmprice; // Get appropriate color scheme based on metric const colorScheme = useMemo(() => { - return getColorSchemeForMetric(filter.mode); - }, [filter.mode]); + return getColorSchemeForMetric(metricMode); + }, [metricMode]); const metricInfo = useMemo(() => { - return getMetricInterpretation(filter.mode); - }, [filter.mode]); + return getMetricInterpretation(metricMode); + }, [metricMode]); // Calculate average price per sqm for property cards const avgPricePerSqm = useMemo(() => { @@ -85,25 +77,9 @@ export function Map(props: MapProps) { : 0; }, [data]); - // Build crossfilter data - const buildCrossfilterData = useCallback(() => { - return data.features.map(function (d: PropertyFeature, i: number) { - const propsCopy = clone(d.properties) as CrossfilterRecord; - propsCopy.index = i; - return propsCopy; - }); - }, [data]); - const updateHeatmap = useCallback(() => { if (!mapRef.current || !isMapLoadedRef.current) return; - const crossData = buildCrossfilterData(); - const cf = crossfilter(crossData); - const qmDim = cf.dimension(function (d: CrossfilterRecord) { return d.qm; }); - const cityDim = cf.dimension(function (d: CrossfilterRecord) { return d.city; }); - const countryDim = cf.dimension(function (d: CrossfilterRecord) { return d.country; }); - const indexDim = cf.dimension(function (d: CrossfilterRecord) { return d.index; }); - // Create heatmap if it doesn't exist if (!heatmapRef.current) { heatmapRef.current = new HexgridHeatmap(mapRef.current, "hexgrid-heatmap", "waterway-label"); @@ -113,44 +89,36 @@ export function Map(props: MapProps) { } const heatmap = heatmapRef.current; - heatmap.setPropertyName(filter.mode); + heatmap.setPropertyName(metricMode); - if (filter.mode === Metric.qmprice) { - qmDim.filter((d) => (d as number) > 0); + // Pass all features to the heatmap — filtering is done server-side + heatmap.setData(data); + + // Compute color scale from valid metric values only + const values = data.features + .map(function (d: PropertyFeature) { + return (d.properties as Record)[metricMode] as number; + }) + .filter(function (v: number) { return typeof v === 'number' && v > 0; }) + .sort(function (a: number, b: number) { return a - b; }); + + if (values.length > 0) { + const minIndex = Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND); + const maxIndex = Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND); + const min = values[minIndex]; + const max = values[maxIndex]; + + makeLegend(colorScheme, min, max); + const colorStopsValue = calculateColorStops(colorScheme, min, max); + heatmap.setColorStops(colorStopsValue); } - if (filter.city) { - cityDim.filterExact(filter.city); - } else if (filter.country) { - countryDim.filterExact(filter.country); - } - - const subset: GeoJSONFeatureCollection = { type: "FeatureCollection", features: [] }; - indexDim.top(Infinity).forEach(function (i: CrossfilterRecord) { - subset.features.push(data.features[i.index]); - }); - - // Update heatmap data - heatmap.setData(subset); - let values = subset.features.map(function (d: PropertyFeature) { - return d.properties[filter.mode as keyof PropertyProperties] as number; - }); - values = values.sort(function (a: number, b: number) { return a - b; }); - - const minIndex = Math.round(values.length * PERCENTILE_CONFIG.MIN_BOUND); - const maxIndex = Math.round(values.length * PERCENTILE_CONFIG.MAX_BOUND); - const min = values[minIndex]; - const max = values[maxIndex]; - - makeLegend(colorScheme, min, max); - const colorStopsValue = calculateColorStops(colorScheme, min, max); - heatmap.setColorStops(colorStopsValue); heatmap.update(); // Fit bounds only on first load or significant data change - if (lastDataLengthRef.current === 0 && subset.features.length > 0) { - const longitudes = subset.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[0]; }).sort(function (a: number, b: number) { return a - b; }); - const latitudes = subset.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[1]; }).sort(function (a: number, b: number) { return a - b; }); + if (lastDataLengthRef.current === 0 && data.features.length > 0) { + const longitudes = data.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[0]; }).sort(function (a: number, b: number) { return a - b; }); + const latitudes = data.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[1]; }).sort(function (a: number, b: number) { return a - b; }); const minlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN); const maxlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX); const minlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN); @@ -162,8 +130,8 @@ export function Map(props: MapProps) { ], { duration: 0 }); } - lastDataLengthRef.current = subset.features.length; - }, [data, filter.mode, filter.city, filter.country, colorScheme, buildCrossfilterData]); + lastDataLengthRef.current = data.features.length; + }, [data, metricMode, colorScheme]); // Initialize map useEffect(() => { @@ -183,6 +151,11 @@ export function Map(props: MapProps) { }); mapRef.current.on('click', function (e: mapboxgl.MapMouseEvent) { if (!mapRef.current) return; + // If picking POI, capture the click location and skip hexgrid behavior + if (isPickingPOIRef.current) { + onPoiLocationPickRef.current?.(e.lngLat.lat, e.lngLat.lng); + return; + } const layers = ['hexgrid-heatmap', 'hexgrid-heatmap-back'] .filter(l => mapRef.current!.getLayer(l)); if (layers.length === 0) return; @@ -201,6 +174,10 @@ export function Map(props: MapProps) { }); mapRef.current.on('mousemove', function (e: mapboxgl.MapMouseEvent) { if (!mapRef.current) return; + if (isPickingPOIRef.current) { + mapRef.current.getCanvas().style.cursor = 'crosshair'; + return; + } const layers = ['hexgrid-heatmap', 'hexgrid-heatmap-back'] .filter(l => mapRef.current!.getLayer(l)); if (layers.length === 0) return; @@ -239,6 +216,21 @@ export function Map(props: MapProps) { }; }, [data, updateHeatmap]); + // Keep POI picking refs in sync with props + useEffect(() => { + isPickingPOIRef.current = props.isPickingPOI ?? false; + onPoiLocationPickRef.current = props.onPoiLocationPick; + + if (mapRef.current) { + const canvas = mapRef.current.getCanvas(); + if (props.isPickingPOI) { + canvas.style.cursor = 'crosshair'; + } else { + canvas.style.cursor = ''; + } + } + }, [props.isPickingPOI, props.onPoiLocationPick]); + // Update POI markers when pois prop changes useEffect(() => { if (!mapRef.current || !isMapLoadedRef.current) return; @@ -312,8 +304,12 @@ export function Map(props: MapProps) { }); const xScale = d3.scaleLinear().range([gradientHeight - 10, 0]).domain([minValue, maxValue]); + const isTravel = typeof metricMode === 'string' && metricMode.startsWith('poi_travel'); const xAxis = d3.axisRight(xScale).ticks(5).tickFormat((d) => { const num = d as number; + if (isTravel) { + return `${Math.round(num / 60)}m`; + } if (num >= 1000) { return `${(num / 1000).toFixed(1)}k`; } @@ -373,6 +369,7 @@ export function Map(props: MapProps) { property={property.properties} variant="full" avgPricePerSqm={avgPricePerSqm} + allPOIs={props.pois} /> ))}
@@ -384,6 +381,18 @@ export function Map(props: MapProps) { return (
+ {props.isPickingPOI && ( +
+ + Click anywhere to place your POI + +
+ )}
diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index 64b3f2a..b0a632e 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -1,6 +1,6 @@ import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react'; import { Button } from './ui/button'; -import type { PropertyProperties, POIDistanceInfo } from '@/types'; +import type { PropertyProperties, POIDistanceInfo, POI } from '@/types'; function formatDuration(seconds: number): string { const minutes = Math.round(seconds / 60); @@ -47,11 +47,43 @@ function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) { ); } +const TRAVEL_MODES: Array<'WALK' | 'BICYCLE' | 'TRANSIT'> = ['WALK', 'BICYCLE', 'TRANSIT']; + +function AllPOIDistances({ pois, distances }: { pois: POI[]; distances?: POIDistanceInfo[] }) { + // Index distances by poi_id + travel_mode for O(1) lookup + const distMap = new Map(); + if (distances) { + for (const d of distances) { + distMap.set(`${d.poi_id}_${d.travel_mode}`, d); + } + } + + return ( +
+ {pois.map(poi => ( +
+ {poi.name}: + {TRAVEL_MODES.map(mode => { + const dist = distMap.get(`${poi.id}_${mode}`); + return ( + + + {dist ? formatDuration(dist.duration_seconds) : '—'} + + ); + })} +
+ ))} +
+ ); +} + interface PropertyCardProps { property: PropertyProperties; variant?: 'compact' | 'full'; isHighlighted?: boolean; avgPricePerSqm?: number; + allPOIs?: POI[]; onClick?: () => void; } @@ -60,6 +92,7 @@ export function PropertyCard({ variant = 'compact', isHighlighted = false, avgPricePerSqm, + allPOIs, onClick, }: PropertyCardProps) { const lastSeenDate = property.last_seen.split('T')[0]; @@ -218,12 +251,17 @@ export function PropertyCard({
{/* POI Distances */} - {property.poi_distances && property.poi_distances.length > 0 && ( + {allPOIs && allPOIs.length > 0 ? ( +
+
Travel times
+ +
+ ) : property.poi_distances && property.poi_distances.length > 0 ? (
Travel times
- )} + ) : null} {/* Price history */} {property.price_history.length > 1 && ( diff --git a/frontend/src/components/TaskIndicator.tsx b/frontend/src/components/TaskIndicator.tsx index 6799af5..a297696 100644 --- a/frontend/src/components/TaskIndicator.tsx +++ b/frontend/src/components/TaskIndicator.tsx @@ -4,7 +4,7 @@ import { fromOidcUser, type AuthUser } from '@/auth/types'; import { POLLING_INTERVALS } from '@/constants'; import { fetchTaskStatus, cancelTask, clearAllTasks } from '@/services'; import { TaskStatus, type TaskResult } from '@/types'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Button } from './ui/button'; import { Loader2, CheckCircle2, XCircle, X, Trash2 } from 'lucide-react'; @@ -13,9 +13,10 @@ import { TaskProgressDrawer } from './TaskProgressDrawer'; interface TaskIndicatorProps { taskID: string | null; onTaskCancelled?: () => void; + onTaskCompleted?: () => void; } -export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) { +export function TaskIndicator({ taskID, onTaskCancelled, onTaskCompleted }: TaskIndicatorProps) { const [user, setUser] = useState(null); const [progressPercentage, setProgressPercentage] = useState(0); const [processed, setProcessed] = useState(null); @@ -26,6 +27,11 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) { const [isClearing, setIsClearing] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); + const onTaskCompletedRef = useRef(onTaskCompleted); + useEffect(() => { + onTaskCompletedRef.current = onTaskCompleted; + }, [onTaskCompleted]); + useEffect(() => { const passkeyUser = getStoredPasskeyUser(); if (passkeyUser) { @@ -73,6 +79,7 @@ export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) { // Ignore parsing errors } } + onTaskCompletedRef.current?.(); return true; // Stop polling }