Add frontend POI management and travel time display

POIManager component in FilterPanel for creating/deleting POIs and
triggering distance calculations. PropertyCard shows travel time badges
(walk/cycle/transit) per POI. Map renders POI locations as red markers.
API client extended with POST body support for POI endpoints.
This commit is contained in:
Viktor Barzin 2026-02-08 13:16:32 +00:00
parent bb489c2032
commit 8509a0326f
No known key found for this signature in database
GPG key ID: 0EB088298288D958
9 changed files with 414 additions and 10 deletions

View file

@ -15,8 +15,8 @@ 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 } from '@/types';
import { refreshListings, fetchTasksForUser, streamListingGeoJSON, type StreamingProgress } from '@/services';
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI } from '@/types';
import { refreshListings, fetchTasksForUser, streamListingGeoJSON, fetchUserPOIs, type StreamingProgress } from '@/services';
function App() {
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
@ -30,6 +30,7 @@ function App() {
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
const [userPOIs, setUserPOIs] = useState<POI[]>([]);
// Ref to track accumulated features during streaming
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
@ -70,6 +71,12 @@ function App() {
});
}, [user, taskID]);
// 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;
@ -221,6 +228,7 @@ function App() {
listingData={listingData}
queryParameters={queryParameters}
onPropertyClick={handlePropertyClick}
pois={userPOIs}
/>
</div>
)}
@ -243,6 +251,14 @@ function App() {
setTaskID(null);
};
const handlePOITaskCreated = (taskId: string) => {
setTaskID(taskId);
// Refresh POI list in case new ones were created
if (user) {
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
}
};
return (
<div className="h-screen flex flex-col overflow-hidden">
{/* Header */}
@ -261,6 +277,8 @@ function App() {
onMetricChange={handleMetricChange}
isLoading={isLoading}
listingCount={listingData?.features.length}
user={user}
onTaskCreated={handlePOITaskCreated}
/>
</div>
@ -278,6 +296,8 @@ function App() {
onMetricChange={handleMetricChange}
isLoading={isLoading}
listingCount={listingData?.features.length}
user={user}
onTaskCreated={handlePOITaskCreated}
/>
</SheetContent>
</Sheet>

View file

@ -8,8 +8,10 @@ import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, For
import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./ui/accordion";
import { Loader2, Filter, RefreshCw } from "lucide-react";
import { Loader2, Filter, RefreshCw, MapPin } from "lucide-react";
import { ScrollArea } from "./ui/scroll-area";
import { POIManager } from "./POIManager";
import type { AuthUser } from "@/auth/types";
export enum Metric {
qmprice = 'qmprice',
@ -69,6 +71,8 @@ interface FilterPanelProps {
onMetricChange?: (metric: Metric) => void;
isLoading?: boolean;
listingCount?: number;
user?: AuthUser;
onTaskCreated?: (taskId: string) => void;
}
const formSchema = z.object({
@ -90,7 +94,7 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>;
export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount }: FilterPanelProps) {
export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, user, onTaskCreated }: FilterPanelProps) {
const [availableFromRawInput, setAvailableFromRawInput] = useState("now");
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
@ -530,6 +534,25 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount
</div>
</AccordionContent>
</AccordionItem>
{/* Points of Interest */}
{user && (
<AccordionItem value="poi">
<AccordionTrigger className="py-2 text-sm font-medium">
<span className="flex items-center gap-1.5">
<MapPin className="h-3.5 w-3.5" />
Points of Interest
</span>
</AccordionTrigger>
<AccordionContent>
<POIManager
user={user}
listingType={watchedListingType as 'RENT' | 'BUY'}
onTaskCreated={onTaskCreated}
/>
</AccordionContent>
</AccordionItem>
)}
</Accordion>
</form>
</Form>

View file

@ -8,7 +8,7 @@ import "../assets/Map.css";
import { Metric, type ParameterValues } from "./Parameters";
import { PropertyCard } from "./PropertyCard";
import { ScrollArea } from "./ui/scroll-area";
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties } from "@/types";
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";
@ -40,6 +40,7 @@ interface MapProps {
listingData: GeoJSONFeatureCollection;
queryParameters: ParameterValues | null;
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
pois?: POI[];
}
interface FilterState {
@ -58,6 +59,7 @@ export function Map(props: MapProps) {
const updateTimeoutRef = useRef<number | null>(null);
const isMapLoadedRef = useRef<boolean>(false);
const lastDataLengthRef = useRef<number>(0);
const poiMarkersRef = useRef<mapboxgl.Marker[]>([]);
const filter: FilterState = { city: 'London', country: null, mode: Metric.qmprice };
if (props.queryParameters) {
@ -237,6 +239,33 @@ export function Map(props: MapProps) {
};
}, [data, updateHeatmap]);
// Update POI markers when pois prop changes
useEffect(() => {
if (!mapRef.current || !isMapLoadedRef.current) return;
// Remove existing markers
poiMarkersRef.current.forEach(m => m.remove());
poiMarkersRef.current = [];
if (!props.pois) return;
for (const poi of props.pois) {
const el = document.createElement('div');
el.className = 'poi-marker';
el.style.cssText = 'width:24px;height:24px;background:#ef4444;border:2px solid white;border-radius:50%;box-shadow:0 2px 4px rgba(0,0,0,0.3);cursor:pointer;';
el.title = poi.name;
const marker = new mapboxgl.Marker({ element: el })
.setLngLat([poi.longitude, poi.latitude])
.setPopup(new mapboxgl.Popup({ offset: 12 }).setHTML(
`<div style="padding:4px 8px"><strong>${poi.name}</strong><br/><span style="color:#666;font-size:12px">${poi.address}</span></div>`
))
.addTo(mapRef.current);
poiMarkersRef.current.push(marker);
}
}, [props.pois]);
function makeLegend(colorstops: [number, string][], minValue: number, maxValue: number) {
const svg_height = 280, svg_width = 80;
d3.select('#svg').selectAll('*').remove();

View file

@ -0,0 +1,187 @@
import { useState, useEffect } from 'react';
import { MapPin, Plus, Trash2, Calculator, Loader2 } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import type { AuthUser } from '@/auth/types';
import type { POI } from '@/types';
import { fetchUserPOIs, createPOI, deletePOI, triggerPOICalculation } from '@/services/poiService';
interface POIManagerProps {
user: AuthUser;
listingType: 'RENT' | 'BUY';
onTaskCreated?: (taskId: string) => void;
}
export function POIManager({ user, listingType, onTaskCreated }: POIManagerProps) {
const [pois, setPois] = useState<POI[]>([]);
const [isAdding, setIsAdding] = useState(false);
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [lat, setLat] = useState('');
const [lng, setLng] = useState('');
const [calculating, setCalculating] = useState<number | null>(null);
const [selectedModes, setSelectedModes] = useState<string[]>(['WALK', 'BICYCLE', 'TRANSIT']);
useEffect(() => {
fetchUserPOIs(user).then(setPois).catch(() => {});
}, [user]);
const handleCreate = async () => {
if (!name || !lat || !lng) return;
try {
const poi = await createPOI(user, {
name,
address: address || name,
latitude: parseFloat(lat),
longitude: parseFloat(lng),
});
setPois(prev => [...prev, poi]);
setIsAdding(false);
setName('');
setAddress('');
setLat('');
setLng('');
} catch {
// silently fail
}
};
const handleDelete = async (poiId: number) => {
try {
await deletePOI(user, poiId);
setPois(prev => prev.filter(p => p.id !== poiId));
} catch {
// silently fail
}
};
const handleCalculate = async (poiId: number) => {
setCalculating(poiId);
try {
const result = await triggerPOICalculation(user, poiId, selectedModes, listingType);
onTaskCreated?.(result.task_id);
} catch {
// silently fail
} finally {
setCalculating(null);
}
};
const toggleMode = (mode: string) => {
setSelectedModes(prev =>
prev.includes(mode) ? prev.filter(m => m !== mode) : [...prev, mode]
);
};
return (
<div className="space-y-3">
{/* POI List */}
{pois.map(poi => (
<div key={poi.id} className="flex items-center gap-2 p-2 rounded-md border text-sm">
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="flex-1 truncate font-medium">{poi.name}</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleCalculate(poi.id)}
disabled={calculating === poi.id}
>
{calculating === poi.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Calculator className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
onClick={() => handleDelete(poi.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{/* Travel mode toggles */}
{pois.length > 0 && (
<div className="flex gap-1.5">
{['WALK', 'BICYCLE', 'TRANSIT'].map(mode => (
<button
key={mode}
type="button"
onClick={() => toggleMode(mode)}
className={`px-2 py-0.5 text-xs rounded-md border transition-colors ${
selectedModes.includes(mode)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-muted border-input'
}`}
>
{mode === 'WALK' ? 'Walk' : mode === 'BICYCLE' ? 'Cycle' : 'Transit'}
</button>
))}
</div>
)}
{/* Add POI Form */}
{isAdding ? (
<div className="space-y-2 p-2 border rounded-md">
<Input
placeholder="Name (e.g., Office)"
value={name}
onChange={e => setName(e.target.value)}
className="h-7 text-xs"
/>
<Input
placeholder="Address (optional)"
value={address}
onChange={e => setAddress(e.target.value)}
className="h-7 text-xs"
/>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
step="any"
placeholder="Latitude"
value={lat}
onChange={e => setLat(e.target.value)}
className="h-7 text-xs"
/>
<Input
type="number"
step="any"
placeholder="Longitude"
value={lng}
onChange={e => setLng(e.target.value)}
className="h-7 text-xs"
/>
</div>
<div className="flex gap-2">
<Button size="sm" className="h-7 text-xs flex-1" onClick={handleCreate}>
Save
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setIsAdding(false)}
>
Cancel
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full h-7 text-xs"
onClick={() => setIsAdding(true)}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add POI
</Button>
)}
</div>
);
}

View file

@ -1,6 +1,51 @@
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building } from 'lucide-react';
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building, Footprints, Bike, Train } from 'lucide-react';
import { Button } from './ui/button';
import type { PropertyProperties } from '@/types';
import type { PropertyProperties, POIDistanceInfo } from '@/types';
function formatDuration(seconds: number): string {
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
}
function TravelModeIcon({ mode }: { mode: string }) {
switch (mode) {
case 'WALK': return <Footprints className="h-3 w-3" />;
case 'BICYCLE': return <Bike className="h-3 w-3" />;
case 'TRANSIT': return <Train className="h-3 w-3" />;
default: return null;
}
}
function POIDistanceBadges({ distances }: { distances: POIDistanceInfo[] }) {
if (!distances || distances.length === 0) return null;
// Group by POI name
const byPoi = new Map<string, POIDistanceInfo[]>();
for (const d of distances) {
const existing = byPoi.get(d.poi_name) || [];
existing.push(d);
byPoi.set(d.poi_name, existing);
}
return (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{Array.from(byPoi.entries()).map(([poiName, dists]) => (
<div key={poiName} 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">{poiName}:</span>
{dists.map(d => (
<span key={d.travel_mode} className="flex items-center gap-0.5" title={`${d.travel_mode} to ${poiName}`}>
<TravelModeIcon mode={d.travel_mode} />
{formatDuration(d.duration_seconds)}
</span>
))}
</div>
))}
</div>
);
}
interface PropertyCardProps {
property: PropertyProperties;
@ -91,6 +136,7 @@ export function PropertyCard({
</span>
<span className="truncate">{property.agency}</span>
</div>
<POIDistanceBadges distances={property.poi_distances || []} />
</div>
</div>
);
@ -171,6 +217,14 @@ export function PropertyCard({
<span>Seen {lastSeenDays} days ago</span>
</div>
{/* POI Distances */}
{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>
)}
{/* Price history */}
{property.price_history.length > 1 && (
<div className="mt-3 pt-3 border-t">

View file

@ -6,6 +6,7 @@ import { ApiError } from '@/types';
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
params?: Record<string, string | number | boolean | Date | undefined>;
body?: unknown;
}
/**
@ -35,7 +36,7 @@ export async function apiRequest<T>(
endpoint: string,
options: RequestOptions = {}
): Promise<T> {
const { method = 'GET', params } = options;
const { method = 'GET', params, body } = options;
let url = endpoint;
if (params) {
@ -45,13 +46,19 @@ export async function apiRequest<T>(
}
}
const response = await fetch(url, {
const fetchOptions: RequestInit = {
method,
headers: {
Authorization: `Bearer ${user.accessToken}`,
'Content-Type': 'application/json',
},
});
};
if (body !== undefined) {
fetchOptions.body = JSON.stringify(body);
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new ApiError(`Error: ${response.status}`, response.status);

View file

@ -4,3 +4,4 @@ export { fetchListingGeoJSON, refreshListings } from './listingService';
export { streamListingGeoJSON, type StreamingProgress } from './streamingService';
export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type CancelTaskResponse, type ClearAllTasksResponse } from './taskService';
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';
export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances } from './poiService';

View file

@ -0,0 +1,64 @@
// POI API service for managing Points of Interest
import type { AuthUser } from '@/auth/types';
import type { POI, POIDistanceInfo } from '@/types';
import { apiRequest } from './apiClient';
export async function fetchUserPOIs(user: AuthUser): Promise<POI[]> {
return apiRequest<POI[]>(user, '/api/poi');
}
export async function createPOI(
user: AuthUser,
data: { name: string; address: string; latitude: number; longitude: number }
): Promise<POI> {
return apiRequest<POI>(user, '/api/poi', {
method: 'POST',
body: data,
});
}
export async function updatePOI(
user: AuthUser,
poiId: number,
data: { name?: string; address?: string; latitude?: number; longitude?: number }
): Promise<POI> {
return apiRequest<POI>(user, `/api/poi/${poiId}`, {
method: 'PUT',
body: data,
});
}
export async function deletePOI(user: AuthUser, poiId: number): Promise<void> {
await apiRequest(user, `/api/poi/${poiId}`, { method: 'DELETE' });
}
export async function triggerPOICalculation(
user: AuthUser,
poiId: number,
travelModes: string[],
listingType: 'RENT' | 'BUY',
listingIds?: number[]
): Promise<{ task_id: string; message: string }> {
return apiRequest(user, `/api/poi/${poiId}/calculate`, {
method: 'POST',
body: {
travel_modes: travelModes,
listing_type: listingType,
listing_ids: listingIds,
},
});
}
export async function fetchPOIDistances(
user: AuthUser,
listingId: number,
listingType: 'RENT' | 'BUY' = 'RENT'
): Promise<POIDistanceInfo[]> {
return apiRequest<POIDistanceInfo[]>(user, '/api/poi/distances', {
params: {
listing_id: listingId,
listing_type: listingType,
},
});
}

View file

@ -21,6 +21,7 @@ export interface PropertyProperties {
photo_thumbnail: string;
price_history: PropertyPriceHistory[];
listing_type?: 'RENT' | 'BUY';
poi_distances?: POIDistanceInfo[];
}
export interface PropertyFeature {
@ -96,3 +97,21 @@ export class ApiError extends Error {
this.name = 'ApiError';
}
}
// POI types
export interface POI {
id: number;
name: string;
address: string;
latitude: number;
longitude: number;
created_at: string;
}
export interface POIDistanceInfo {
poi_id: number;
poi_name: string;
travel_mode: 'WALK' | 'BICYCLE' | 'TRANSIT';
duration_seconds: number;
distance_meters: number;
}