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:
Viktor Barzin 2026-02-08 15:11:21 +00:00
parent 01dae5dfbd
commit 81d31eaecf
No known key found for this signature in database
GPG key ID: 0EB088298288D958
5 changed files with 227 additions and 88 deletions

View file

@ -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}
/>

View file

@ -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 && (

View file

@ -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>

View file

@ -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 && (

View file

@ -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
}