Auto-reload listings on task completion and show all POIs in detail view
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.
This commit is contained in:
parent
01dae5dfbd
commit
81d31eaecf
5 changed files with 227 additions and 88 deletions
|
|
@ -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<GeoJSONFeatureCollection | null>(null);
|
||||
|
|
@ -31,6 +32,14 @@ function App() {
|
|||
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 [maxTravelMinutes, setMaxTravelMinutes] = useState<number | undefined>(undefined);
|
||||
|
||||
// Ref to track accumulated features during streaming
|
||||
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
||||
|
|
@ -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<string, unknown>)[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 <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
||||
}
|
||||
|
|
@ -179,7 +230,7 @@ function App() {
|
|||
};
|
||||
|
||||
const renderMainContent = () => {
|
||||
if (!listingData) {
|
||||
if (!processedListingData) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
||||
<div className="text-center p-8 max-w-md">
|
||||
|
|
@ -205,7 +256,7 @@ function App() {
|
|||
);
|
||||
}
|
||||
|
||||
if (listingData.features.length === 0) {
|
||||
if (processedListingData.features.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
|
|
@ -225,10 +276,14 @@ function App() {
|
|||
{(viewMode === 'map' || viewMode === 'split') && (
|
||||
<div className={`relative ${viewMode === 'split' ? 'w-1/2' : 'flex-1'}`} style={{ minHeight: 0 }}>
|
||||
<Map
|
||||
listingData={listingData}
|
||||
listingData={processedListingData}
|
||||
queryParameters={queryParameters}
|
||||
effectiveMetric={effectiveMetric}
|
||||
onPropertyClick={handlePropertyClick}
|
||||
pois={userPOIs}
|
||||
isPickingPOI={poiPickerActive}
|
||||
onPoiLocationPick={handlePoiLocationPick}
|
||||
onCancelPoiPicking={handleCancelPoiPicking}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -237,9 +292,10 @@ function App() {
|
|||
{(viewMode === 'list' || viewMode === 'split') && (
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2 border-l' : 'flex-1'}`}>
|
||||
<ListView
|
||||
listingData={listingData}
|
||||
listingData={processedListingData}
|
||||
onPropertyClick={handlePropertyClick}
|
||||
highlightedPropertyUrl={highlightedProperty}
|
||||
poiMetricSelection={poiMetricSelection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -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 (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
|
@ -316,10 +399,10 @@ function App() {
|
|||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
{listingData && listingData.features.length > 0 && (
|
||||
{processedListingData && processedListingData.features.length > 0 && (
|
||||
<div className="shrink-0">
|
||||
<StatsBar
|
||||
listingData={listingData}
|
||||
listingData={processedListingData}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<HealthIndicator />
|
||||
|
||||
{/* Task Indicator */}
|
||||
<TaskIndicator taskID={taskID ?? null} onTaskCancelled={onTaskCancelled} />
|
||||
<TaskIndicator taskID={taskID ?? null} onTaskCancelled={onTaskCancelled} onTaskCompleted={onTaskCompleted} />
|
||||
|
||||
{/* Filter Toggle (mobile) */}
|
||||
{showFilterToggle && (
|
||||
|
|
|
|||
|
|
@ -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<boolean>(false);
|
||||
const lastDataLengthRef = useRef<number>(0);
|
||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
|
||||
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<string, unknown>)[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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -384,6 +381,18 @@ export function Map(props: MapProps) {
|
|||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<div id='map-container' ref={mapContainerRef}></div>
|
||||
{props.isPickingPOI && (
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 bg-primary text-primary-foreground px-4 py-2 rounded-lg shadow-lg flex items-center gap-3 text-sm font-medium">
|
||||
<Crosshair className="h-4 w-4" />
|
||||
Click anywhere to place your POI
|
||||
<button
|
||||
onClick={() => props.onCancelPoiPicking?.()}
|
||||
className="ml-1 px-2 py-0.5 bg-primary-foreground/20 hover:bg-primary-foreground/30 rounded text-xs transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div id="legend">
|
||||
<svg id="svg"></svg>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, POIDistanceInfo>();
|
||||
if (distances) {
|
||||
for (const d of distances) {
|
||||
distMap.set(`${d.poi_id}_${d.travel_mode}`, d);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{pois.map(poi => (
|
||||
<div key={poi.id} className="flex items-center gap-1 text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">
|
||||
<span className="font-medium">{poi.name}:</span>
|
||||
{TRAVEL_MODES.map(mode => {
|
||||
const dist = distMap.get(`${poi.id}_${mode}`);
|
||||
return (
|
||||
<span key={mode} className="flex items-center gap-0.5" title={`${mode} to ${poi.name}`}>
|
||||
<TravelModeIcon mode={mode} />
|
||||
{dist ? formatDuration(dist.duration_seconds) : '—'}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
|||
</div>
|
||||
|
||||
{/* POI Distances */}
|
||||
{property.poi_distances && property.poi_distances.length > 0 && (
|
||||
{allPOIs && allPOIs.length > 0 ? (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Travel times</div>
|
||||
<AllPOIDistances pois={allPOIs} distances={property.poi_distances} />
|
||||
</div>
|
||||
) : property.poi_distances && property.poi_distances.length > 0 ? (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Travel times</div>
|
||||
<POIDistanceBadges distances={property.poi_distances} />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Price history */}
|
||||
{property.price_history.length > 1 && (
|
||||
|
|
|
|||
|
|
@ -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<AuthUser | null>(null);
|
||||
const [progressPercentage, setProgressPercentage] = useState<number>(0);
|
||||
const [processed, setProcessed] = useState<number | null>(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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue