Add configurable scheduling, UI health/task indicators, and auto-load map with default filters

This commit is contained in:
Viktor Barzin 2026-02-01 17:28:37 +00:00
parent 1c8c3e4657
commit c7ac448f15
18 changed files with 2287 additions and 656 deletions

View file

@ -1,141 +1,128 @@
import { getUser } from '@/auth/authService';
import { POLLING_INTERVALS } from '@/constants';
import { fetchTaskStatus, cancelTask } from '@/services';
import { TaskStatus, type TaskResult } from '@/types';
import type { User } from 'oidc-client-ts';
import React, { useEffect, useState } from 'react';
import AlertError from './AlertError';
import { Spinner } from './Spinner';
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
import { Progress } from './ui/progress';
import { Button } from './ui/button';
import { X } from 'lucide-react';
interface ModalProps {
interface ActiveQueryProps {
taskID: string | null;
onTaskCancelled?: () => void;
}
const fetchTaskStatusData = async (user: User, taskID: string) => {
const accessToken = user?.access_token;
const response = await fetch(`/api/task_status?task_id=${taskID}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`, // Pass the token
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch task status: ${response.status}`);
}
const data =
await response.json();
return data;
};
type TaskStatus = string
// enum TaskStatus {
// QUEUED = 'queued',
// PROCESSING = 'processing',
// COMPLETED = 'completed',
// FAILED = 'failed',
// }
// const taskStatusToProgress = (taskStatus: TaskStatus): number => {
// switch (taskStatus) {
// case TaskStatus.QUEUED:
// return 0.33; // Queued status
// case TaskStatus.PROCESSING:
// return 0.66; // Processing status
// case TaskStatus.COMPLETED:
// return 1.0; // Completed status
// default:
// throw new Error('Unknown task status: ' + status);
// }
// }
// const getTaskStatus = (status: string): TaskStatus => {
// switch (status.toLowerCase()) {
// case 'queued':
// return TaskStatus.QUEUED;
// case 'processing':
// return TaskStatus.PROCESSING;
// case 'completed':
// return TaskStatus.COMPLETED;
// case 'failed':
// return TaskStatus.FAILED;
// default:
// throw new Error('Unknown task status: ' + status);
// }
// };
const ActiveQuery: React.FC<ModalProps> = ({
taskID
}) => {
const ActiveQuery: React.FC<ActiveQueryProps> = ({ taskID, onTaskCancelled }) => {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
getUser().then(setUser);
}, []);
const [progressPercentage, setProgressPercentage] = useState<number>(0);
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>("PENDING");
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(TaskStatus.PENDING);
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
const [fetchStatusError, setFetchStatusError] = useState<string | null>(null);
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const fetchTaskStatus = async (interval: NodeJS.Timeout) => {
if (!user || !taskID) {
return;
}
let data = null
const handleCancelTask = async () => {
if (!user || !taskID || isCancelling) return;
setIsCancelling(true);
try {
data = await fetchTaskStatusData(user, taskID);
} catch (error: any) {
clearInterval(interval);
setTaskStatus("FAILURE")
setAlertDialogIsOpen(true)
if (error instanceof Error) {
setFetchStatusError(error.message)
const result = await cancelTask(user, taskID);
if (result.success) {
setTaskStatus(TaskStatus.REVOKED);
onTaskCancelled?.();
} else {
setFetchStatusError('Failed to update task status: ' + error.toString())
setFetchStatusError(result.message);
setAlertDialogIsOpen(true);
}
} catch (error) {
setFetchStatusError(error instanceof Error ? error.message : 'Failed to cancel task');
setAlertDialogIsOpen(true);
} finally {
setIsCancelling(false);
}
};
const pollTaskStatus = async (interval: NodeJS.Timeout) => {
if (!user || !taskID) {
return;
}
try {
const data = await fetchTaskStatus(user, taskID);
setLastUpdateTime(new Date());
const status = data.status as TaskStatus;
setTaskStatus(status);
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
clearInterval(interval);
setFetchStatusError('Task failed with status: ' + status);
setAlertDialogIsOpen(true);
return;
}
if (status === TaskStatus.SUCCESS) {
clearInterval(interval);
setProgressPercentage(100);
return;
}
// Only parse result for in-progress tasks
if (data.result) {
try {
const parsedResult: TaskResult = JSON.parse(data.result);
setProgressPercentage(parsedResult.progress * 100);
} catch {
// Result parsing failed, but task is still running - ignore
}
}
} catch (error) {
clearInterval(interval);
setTaskStatus(TaskStatus.FAILURE);
setAlertDialogIsOpen(true);
if (error instanceof Error) {
setFetchStatusError(error.message);
} else {
setFetchStatusError('Failed to update task status: ' + String(error));
}
}
if (!data) {
clearInterval(interval);
return;
}
setLastUpdateTime(new Date());
// const taskStatus = getTaskStatus(data.status);
const taskStatus = data.status;
setTaskStatus(taskStatus);
if (taskStatus === "FAILURE" || taskStatus === "REVOKED") {
clearInterval(interval);
throw new Error('Task failed. status: ' + taskStatus);
}
// const progress = taskStatusToProgress(taskStatus);
const parsedResult = JSON.parse(data.result)
setProgressPercentage(parsedResult.progress * 100);
if (taskStatus === "SUCCESS") {
clearInterval(interval);
return;
}
};
// fetch status periodically
// maybe move to ws one day
useEffect(() => {
const interval = setInterval
(() => fetchTaskStatus(interval), 5000); // every 5 seconds
const interval = setInterval(
() => pollTaskStatus(interval),
POLLING_INTERVALS.TASK_STATUS_MS
);
return () => clearInterval(interval);
}, [taskID]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskID, user]);
if (!taskID) {
return null;
}
const isInProgress = taskStatus &&
taskStatus !== TaskStatus.SUCCESS &&
taskStatus !== TaskStatus.FAILURE &&
taskStatus !== TaskStatus.REVOKED;
return (
<>
<div>
<div className="flex items-center gap-2 p-2 border-t bg-muted/50">
<HoverCard>
<HoverCardTrigger>
{taskStatus && <>Task status: {taskStatus} </>}
<Progress value={progressPercentage} />
{taskStatus && taskStatus !== 'SUCCESS' && taskStatus !== 'FAILURE' && taskStatus !== 'REVOKED' && <Spinner />}
<HoverCardTrigger className="flex-1">
<div className="flex items-center gap-2">
{taskStatus && <span className="text-sm">Task: {taskStatus}</span>}
{isInProgress && <Spinner />}
</div>
<Progress value={progressPercentage} className="mt-1" />
</HoverCardTrigger>
<HoverCardContent>
Task ID: {taskID}
@ -143,10 +130,22 @@ const ActiveQuery: React.FC<ModalProps> = ({
Last updated: {lastUpdateTime.toLocaleString()}
</HoverCardContent>
</HoverCard>
{isInProgress && (
<Button
variant="ghost"
size="sm"
onClick={handleCancelTask}
disabled={isCancelling}
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<X className="h-4 w-4" />
<span className="ml-1 hidden sm:inline">Cancel</span>
</Button>
)}
</div>
<AlertError message={fetchStatusError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
</>
)
);
};
export default ActiveQuery;

View file

@ -0,0 +1,546 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "./ui/button";
import { Calendar29 } from "./ui/DatePicker";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
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 { ScrollArea } from "./ui/scroll-area";
export enum Metric {
qmprice = 'qmprice',
rooms = 'rooms',
qm = 'qm',
price = 'total_price',
}
export enum ListingType {
RENT = 'RENT',
BUY = 'BUY'
}
export enum FurnishType {
FURNISHED = 'furnished',
PART_FURNISHED = 'partFurnished',
UNFURNISHED = 'unfurnished',
}
// Default filter values - exported so App.tsx can use them for initial load
export const DEFAULT_FILTER_VALUES = {
metric: Metric.qmprice,
listing_type: ListingType.RENT,
min_bedrooms: 1,
max_bedrooms: 3,
max_price: 3000,
min_price: 2000,
min_sqm: 50,
max_sqm: undefined,
min_price_per_sqm: undefined,
max_price_per_sqm: undefined,
last_seen_days: 28,
available_from: new Date(),
district: '',
furnish_types: [] as FurnishType[],
} as const;
export interface ParameterValues {
metric: Metric
listing_type: ListingType
min_bedrooms?: number
max_bedrooms?: number
min_price?: number
max_price?: number
min_sqm?: number
max_sqm?: number
min_price_per_sqm?: number
max_price_per_sqm?: number
last_seen_days?: number
available_from?: Date
district: string
furnish_types?: FurnishType[]
}
interface FilterPanelProps {
onSubmit: (action: 'fetch-data' | 'visualize', fromValues: ParameterValues) => void;
isLoading?: boolean;
listingCount?: number;
}
const formSchema = z.object({
metric: z.nativeEnum(Metric, { required_error: "Metric is required" }),
listing_type: z.nativeEnum(ListingType, { required_error: "Listing Type is required" }),
min_bedrooms: z.number().min(0).max(10).optional(),
max_bedrooms: z.number().min(0).max(10).optional(),
max_price: z.number().optional(),
min_price: z.number().min(0).optional(),
min_sqm: z.number().optional(),
max_sqm: z.number().optional(),
min_price_per_sqm: z.number().optional(),
max_price_per_sqm: z.number().optional(),
last_seen_days: z.number().min(0).optional(),
available_from: z.date(),
district: z.string(),
furnish_types: z.array(z.nativeEnum(FurnishType)).optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function FilterPanel({ onSubmit, isLoading, listingCount }: FilterPanelProps) {
const [availableFromRawInput, setAvailableFromRawInput] = useState("now");
const [selectedFurnishTypes, setSelectedFurnishTypes] = useState<FurnishType[]>([]);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
...DEFAULT_FILTER_VALUES,
available_from: new Date(), // Fresh date on each render
},
});
const handleFormSubmit = (action: 'fetch-data' | 'visualize') => {
return form.handleSubmit((values) => {
const params: ParameterValues = {
...values,
furnish_types: selectedFurnishTypes.length > 0 ? selectedFurnishTypes : undefined,
};
onSubmit(action, params);
})();
};
const toggleFurnishType = (type: FurnishType) => {
setSelectedFurnishTypes(prev =>
prev.includes(type)
? prev.filter(t => t !== type)
: [...prev, type]
);
};
// Count active filters
const countActiveFilters = (): number => {
const values = form.getValues();
let count = 0;
if (values.min_bedrooms && values.min_bedrooms > 0) count++;
if (values.max_bedrooms && values.max_bedrooms < 10) count++;
if (values.min_price && values.min_price > 0) count++;
if (values.max_price) count++;
if (values.min_sqm && values.min_sqm > 0) count++;
if (values.max_sqm) count++;
if (values.min_price_per_sqm) count++;
if (values.max_price_per_sqm) count++;
if (values.district && values.district.length > 0) count++;
if (selectedFurnishTypes.length > 0) count++;
if (values.last_seen_days && values.last_seen_days < 365) count++;
return count;
};
const [activeFilterCount, setActiveFilterCount] = useState(0);
useEffect(() => {
const subscription = form.watch(() => {
setActiveFilterCount(countActiveFilters());
});
return () => subscription.unsubscribe();
}, [form, selectedFurnishTypes]);
return (
<div className="h-full flex flex-col bg-background border-r overflow-hidden">
{/* Header */}
<div className="p-4 border-b shrink-0">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5" />
<h2 className="font-semibold text-lg">Filters</h2>
{activeFilterCount > 0 && (
<span className="ml-auto bg-primary text-primary-foreground text-xs px-2 py-0.5 rounded-full">
{activeFilterCount}
</span>
)}
</div>
{listingCount !== undefined && (
<p className="text-sm text-muted-foreground mt-1">
{listingCount.toLocaleString()} listings
</p>
)}
</div>
{/* Filters */}
<ScrollArea className="flex-1 min-h-0">
<Form {...form}>
<form className="p-4 space-y-4">
<Accordion type="multiple" defaultValue={["visualization", "price-size", "features"]} className="w-full">
{/* Visualization Options */}
<AccordionItem value="visualization">
<AccordionTrigger className="py-2 text-sm font-medium">
Visualization
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<FormField
control={form.control}
name="metric"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Color by</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Metric" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={Metric.qmprice}>Price per m²</SelectItem>
<SelectItem value={Metric.rooms}>Bedrooms</SelectItem>
<SelectItem value={Metric.qm}>Size (m²)</SelectItem>
<SelectItem value={Metric.price}>Total Price</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="listing_type"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={ListingType.RENT}>For Rent</SelectItem>
<SelectItem value={ListingType.BUY}>For Sale</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
{/* Price & Size */}
<AccordionItem value="price-size">
<AccordionTrigger className="py-2 text-sm font-medium">
Price & Size
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2">
<FormField
control={form.control}
name="min_price"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min Price (£)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_price"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max Price (£)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Any"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<FormField
control={form.control}
name="min_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min Size (m²)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max Size (m²)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Any"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<FormField
control={form.control}
name="min_price_per_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min £/m²</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_price_per_sqm"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max £/m²</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Any"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* Features */}
<AccordionItem value="features">
<AccordionTrigger className="py-2 text-sm font-medium">
Features
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2">
<FormField
control={form.control}
name="min_bedrooms"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Min Beds</FormLabel>
<FormControl>
<Input
type="number"
placeholder="0"
className="h-8 text-sm"
min={0}
max={10}
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="max_bedrooms"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Max Beds</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Any"
className="h-8 text-sm"
min={0}
max={10}
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div>
<FormLabel className="text-xs">Furnishing</FormLabel>
<div className="flex flex-wrap gap-2 mt-2">
{[
{ value: FurnishType.FURNISHED, label: 'Furnished' },
{ value: FurnishType.PART_FURNISHED, label: 'Part' },
{ value: FurnishType.UNFURNISHED, label: 'Unfurn.' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => toggleFurnishType(option.value)}
className={`px-2 py-1 text-xs rounded-md border transition-colors ${
selectedFurnishTypes.includes(option.value)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background hover:bg-muted border-input'
}`}
>
{option.label}
</button>
))}
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* Location */}
<AccordionItem value="location">
<AccordionTrigger className="py-2 text-sm font-medium">
Location
</AccordionTrigger>
<AccordionContent>
<FormField
control={form.control}
name="district"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">District</FormLabel>
<FormControl>
<Input
type="text"
placeholder="e.g., Westminster, Camden"
className="h-8 text-sm"
{...field}
/>
</FormControl>
<FormDescription className="text-xs">
Comma-separated list of districts
</FormDescription>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
{/* Availability */}
<AccordionItem value="availability">
<AccordionTrigger className="py-2 text-sm font-medium">
Availability
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<FormField
control={form.control}
name="available_from"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Available From</FormLabel>
<FormControl>
<Calendar29
onSelect={field.onChange}
selected={field.value}
rawInputValue={availableFromRawInput}
onChangeRawInputValue={setAvailableFromRawInput}
/>
</FormControl>
<FormDescription className="text-xs">
Rental listings only
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="last_seen_days"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs">Last Seen (days)</FormLabel>
<FormControl>
<Input
type="number"
placeholder="28"
className="h-8 text-sm"
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
</FormControl>
<FormDescription className="text-xs">
Show listings seen in last N days
</FormDescription>
</FormItem>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</form>
</Form>
</ScrollArea>
{/* Action Buttons */}
<div className="p-4 border-t space-y-2 shrink-0">
<Button
className="w-full"
onClick={() => handleFormSubmit('visualize')}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
<>
<Filter className="mr-2 h-4 w-4" />
Apply Filters
</>
)}
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => handleFormSubmit('fetch-data')}
disabled={isLoading}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh Data
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,80 @@
import type { User } from 'oidc-client-ts';
import { Button } from './ui/button';
import { Separator } from './ui/separator';
import { LogOut, Home, Filter } from 'lucide-react';
import { logout } from '@/auth/authService';
import { HealthIndicator } from './HealthIndicator';
import { TaskIndicator } from './TaskIndicator';
interface HeaderProps {
user: User;
activeFilterCount?: number;
taskID?: string | null;
isLoading?: boolean;
onToggleFilters?: () => void;
showFilterToggle?: boolean;
onTaskCancelled?: () => void;
}
export function Header({
user,
activeFilterCount = 0,
taskID,
onToggleFilters,
showFilterToggle = false,
onTaskCancelled,
}: HeaderProps) {
return (
<header className="flex h-14 shrink-0 items-center gap-3 border-b bg-background px-4">
{/* Logo / Brand */}
<div className="flex items-center gap-2">
<Home className="h-5 w-5 text-primary" />
<span className="font-semibold text-lg hidden sm:inline">Wrongmove</span>
</div>
<Separator orientation="vertical" className="h-6" />
{/* Health Indicator */}
<HealthIndicator />
{/* Task Indicator */}
<TaskIndicator taskID={taskID ?? null} onTaskCancelled={onTaskCancelled} />
{/* Filter Toggle (mobile) */}
{showFilterToggle && (
<Button
variant="outline"
size="sm"
className="sm:hidden"
onClick={onToggleFilters}
>
<Filter className="h-4 w-4" />
{activeFilterCount > 0 && (
<span className="ml-1 bg-primary text-primary-foreground text-xs px-1.5 py-0.5 rounded-full">
{activeFilterCount}
</span>
)}
</Button>
)}
{/* Spacer */}
<div className="flex-1" />
{/* User Menu */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground hidden md:inline">
{user.profile.email}
</span>
<Button
variant="ghost"
size="sm"
onClick={logout}
className="gap-2"
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Logout</span>
</Button>
</div>
</header>
);
}

View file

@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { checkBackendHealth, type HealthStatus, type HealthCheckResult } from '@/services';
import { Circle, Loader2 } from 'lucide-react';
interface HealthIndicatorProps {
/** How often to check health in milliseconds (default: 30000 = 30s) */
interval?: number;
}
export function HealthIndicator({ interval = 30000 }: HealthIndicatorProps) {
const [health, setHealth] = useState<HealthCheckResult>({ status: 'checking' });
useEffect(() => {
// Initial check
checkBackendHealth().then(setHealth);
// Periodic checks
const intervalId = setInterval(() => {
checkBackendHealth().then(setHealth);
}, interval);
return () => clearInterval(intervalId);
}, [interval]);
const getStatusColor = (status: HealthStatus) => {
switch (status) {
case 'healthy':
return 'text-green-500';
case 'unhealthy':
return 'text-red-500';
case 'checking':
return 'text-muted-foreground';
}
};
const getStatusLabel = (status: HealthStatus) => {
switch (status) {
case 'healthy':
return 'Connected';
case 'unhealthy':
return 'Disconnected';
case 'checking':
return 'Checking...';
}
};
const getTooltipContent = () => {
if (health.status === 'checking') {
return 'Checking backend connection...';
}
if (health.status === 'healthy') {
return `Backend connected (${health.latencyMs}ms)`;
}
return `Backend unavailable: ${health.error || 'Unknown error'}`;
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
{health.status === 'checking' ? (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
) : (
<Circle
className={`h-2.5 w-2.5 fill-current ${getStatusColor(health.status)}`}
/>
)}
<span className={`text-xs ${getStatusColor(health.status)} hidden sm:inline`}>
{getStatusLabel(health.status)}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{getTooltipContent()}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}

View file

@ -1,169 +1,241 @@
// @ts-nocheck
import crossfilter from "crossfilter2";
import * as d3 from "d3";
import mapboxgl from "mapbox-gl";
import 'mapbox-gl/dist/mapbox-gl.css'; // this hides the map for some reason
import { useEffect, useRef } from "react";
import 'mapbox-gl/dist/mapbox-gl.css';
import { useEffect, useRef, useMemo, useCallback } from "react";
import { renderToString } from 'react-dom/server';
import "../assets/Map.css";
import { Metric, type ParameterValues } from "./Parameters";
import { Button } from "./ui/button";
import { PropertyCard } from "./PropertyCard";
import { ScrollArea } from "./ui/scroll-area";
import { Separator } from "./ui/separator";
import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties } from "@/types";
import { MAP_CONFIG, HEATMAP_CONFIG, PERCENTILE_CONFIG } from "@/constants";
import { getColorSchemeForMetric, getMetricInterpretation } from "@/constants/colorSchemes";
import { clone, percentile, calculateColorStops } from "@/utils/mapUtils";
export function Map(
props: {
listingData: any;
queryParameters: ParameterValues | null;
}
) {
// Type declaration for the external HexgridHeatmap library
declare class HexgridHeatmap {
_tree: {
search: (bounds: { minX: number; maxX: number; minY: number; maxY: number }) => PropertyWithCoords[];
};
constructor(map: mapboxgl.Map, id: string, beforeLayer: string);
setIntensity(value: number): void;
setSpread(value: number): void;
setCellDensity(value: number): void;
setPropertyName(name: string): void;
setData(data: GeoJSONFeatureCollection): void;
setColorStops(stops: [number, string][]): void;
update(): void;
}
interface PropertyWithCoords {
properties: PropertyProperties;
}
interface CrossfilterRecord extends PropertyProperties {
index: number;
}
interface MapProps {
listingData: GeoJSONFeatureCollection;
queryParameters: ParameterValues | null;
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
}
interface FilterState {
city: string;
country: string | null;
mode: string;
count?: number;
}
export function Map(props: MapProps) {
const data = props.listingData;
var crossData = data.features.map(function (d, i) {
//clone properties
var props = clone(d['properties']);
props['index'] = i;
return props;
});
const cf = crossfilter(crossData);
const qmDim = cf.dimension(function (d) { return d.qm; });
const cityDim = cf.dimension(function (d) { return d.city; });
const countryDim = cf.dimension(function (d) { return d.country; });
const rentDim = cf.dimension(function (d) { return d.total_price; });
const roomsDim = cf.dimension(function (d) { return d.rooms; });
const urlDim = cf.dimension(function (d) { return d.url; });
const indexDim = cf.dimension(function (d) { return d.index; });
let heatmap = null;
// rivet
var filter = { city: 'London', country: null, mode: Metric.qmprice };
// filter['countries'] = Array.from(new Set(data.features.map(function (d) { return d['properties']['country'] })));
const mapRef = useRef<mapboxgl.Map | null>(null);
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const heatmapRef = useRef<HexgridHeatmap | null>(null);
const updateTimeoutRef = useRef<number | null>(null);
const isMapLoadedRef = useRef<boolean>(false);
const lastDataLengthRef = useRef<number>(0);
const filter: FilterState = { city: 'London', country: null, mode: Metric.qmprice };
if (props.queryParameters) {
filter['mode'] = props.queryParameters.metric;
filter.mode = props.queryParameters.metric;
}
// rivets.bind(document.getElementById('overlay'), { filter: filter });
const mapRef = useRef(mapboxgl.Map)
const mapContainerRef = useRef('map-container')
useEffect(() => {
mapboxgl.accessToken = 'pk.eyJ1IjoiZGktdG8iLCJhIjoiY2o0bnBoYXcxMW1mNzJ3bDhmc2xiNWttaiJ9.ZccatVk_4shzoAsEUXXecA';
mapRef.current = new mapboxgl.Map({
container: mapContainerRef.current,
style: 'mapbox://styles/mapbox/light-v9',
center: [13.38032, 49.994210],
zoom: 5
// Get appropriate color scheme based on metric
const colorScheme = useMemo(() => {
return getColorSchemeForMetric(filter.mode);
}, [filter.mode]);
const metricInfo = useMemo(() => {
return getMetricInterpretation(filter.mode);
}, [filter.mode]);
// Calculate average price per sqm for property cards
const avgPricePerSqm = useMemo(() => {
const validPrices = data.features
.map((f) => f.properties.qmprice)
.filter((p): p is number => typeof p === 'number' && p > 0);
return validPrices.length > 0
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
: 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;
});
mapRef.current.on('load', function () {
update()
})
mapRef.current.on('click', function (e) {
openListingsDialog(e.lngLat.lng, e.lngLat.lat);
})
return () => {
mapRef.current.remove()
}, [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");
heatmapRef.current.setIntensity(HEATMAP_CONFIG.INTENSITY);
heatmapRef.current.setSpread(HEATMAP_CONFIG.SPREAD);
heatmapRef.current.setCellDensity(HEATMAP_CONFIG.CELL_DENSITY);
}
}, [data])
function clone(d) {
return JSON.parse(JSON.stringify(d));
}
function percentile(arr, p) {
if (arr.length === 0) return 0;
if (typeof p !== 'number') throw new TypeError('p must be a number');
if (p <= 0) return arr[0];
if (p >= 1) return arr[arr.length - 1];
var index = arr.length * p,
lower = Math.floor(index),
upper = lower + 1,
weight = index % 1;
if (upper >= arr.length) return arr[lower];
return arr[lower] * (1 - weight) + arr[upper] * weight;
}
function update() {
// init heatmap
heatmap = new HexgridHeatmap(mapRef.current, "hexgrid-heatmap", "waterway-label");
heatmap.setIntensity(9); // dunno yet
heatmap.setSpread(0.05); // dunno yet
heatmap.setCellDensity(0.5); // small value == bigger hexagons
const heatmap = heatmapRef.current;
heatmap.setPropertyName(filter.mode);
if (filter.mode === Metric.qmprice) {
// if we visualize sqm based data, remove properties where we have no data
qmDim.filter(function (d) { return d > 0; });
qmDim.filter((d) => (d as number) > 0);
}
// set filter
if (filter.city) {
cityDim.filterExact(filter.city);
} else if (filter.country) {
countryDim.filterExact(filter.country);
} else {
alert('nothing loadable');
}
filter.count = cityDim.top(Infinity).length;
var subset = { "type": "FeatureCollection", "features": [] };
indexDim.top(Infinity).forEach(function (i) {
const subset: GeoJSONFeatureCollection = { type: "FeatureCollection", features: [] };
indexDim.top(Infinity).forEach(function (i: CrossfilterRecord) {
subset.features.push(data.features[i.index]);
});
loadData(heatmap, subset);
}
function loadData(heatmap, subset) {
// Update heatmap data
heatmap.setData(subset);
var values = subset.features.map(function (d) { return d['properties'][filter.mode] });
values = values.sort(function (a, b) { return a - b; });
// setting the color stops, min is at 5th percentile, max at 95percentile
var min = values[Math.round(values.length * 0.05)];
var max = values[Math.round(values.length * 0.95)];
var colorStopsPerc = [
[0, "rgba(0,185,243,0)"],
[25, "rgba(0,185,243,0.24)"],
[60, "rgba(255,223,0,0.3)"],
[100, "rgba(255,105,0,0.3)"],
];
makeLegend(colorStopsPerc, min, max);
var colorStopsValue = colorStopsPerc.map(function (d) {
return [min + d[0] * (max - min) / 100, d[1]];
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();
//get bounding box and zoom to that area
// we use a 1% percentile since some data can be corrupt
var longitudes = subset.features.map(function (d) { return d.geometry.coordinates[0]; }).sort(function (a, b) { return a - b; });
var latitudes = subset.features.map(function (d) { return d.geometry.coordinates[1]; }).sort(function (a, b) { return a - b; });
var minlng = percentile(longitudes, 0.01);
var maxlng = percentile(longitudes, 0.99);
var minlat = percentile(latitudes, 0.01);
var maxlat = percentile(latitudes, 0.99);
mapRef.current.fitBounds([
[minlng, minlat],
[maxlng, maxlat]
]);
}
// 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; });
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);
const maxlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX);
function makeLegend(colorstops, minValue, maxValue) {
/**
* colorstops: [[0, 'green'], [100, 'red']]
* @type {number}
*/
var svg_height = 300, svg_width = 70;
// clear svg before starting
mapRef.current?.fitBounds([
[minlng, minlat],
[maxlng, maxlat]
]);
}
lastDataLengthRef.current = subset.features.length;
}, [data, filter.mode, filter.city, filter.country, colorScheme, buildCrossfilterData]);
// Initialize map
useEffect(() => {
if (!mapContainerRef.current) return;
mapboxgl.accessToken = MAP_CONFIG.MAPBOX_TOKEN;
mapRef.current = new mapboxgl.Map({
container: mapContainerRef.current,
style: MAP_CONFIG.STYLE,
center: MAP_CONFIG.DEFAULT_CENTER,
zoom: MAP_CONFIG.DEFAULT_ZOOM
});
mapRef.current.on('load', function () {
isMapLoadedRef.current = true;
lastDataLengthRef.current = 0;
updateHeatmap();
});
mapRef.current.on('click', function (e: mapboxgl.MapMouseEvent) {
openListingsDialog(e.lngLat.lng, e.lngLat.lat);
});
return () => {
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
}
heatmapRef.current = null;
isMapLoadedRef.current = false;
mapRef.current?.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Debounced update effect - only update after 200ms of no changes
useEffect(() => {
if (!isMapLoadedRef.current) return;
// Clear any pending update
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
}
// Schedule new update after 200ms of no changes
updateTimeoutRef.current = window.setTimeout(() => {
updateHeatmap();
}, 200);
return () => {
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current);
}
};
}, [data, updateHeatmap]);
function makeLegend(colorstops: [number, string][], minValue: number, maxValue: number) {
const svg_height = 280, svg_width = 80;
d3.select('#svg').selectAll('*').remove();
// create a new SVG element
const svg = d3.select('#svg');
var defs = svg
svg
.attr('height', svg_height)
.attr('width', svg_width);
var linearGradient = svg.append("defs")
// Add metric name at top
svg.append("text")
.attr("x", svg_width / 2)
.attr("y", 12)
.attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("font-weight", "600")
.attr("fill", "#374151")
.text(metricInfo.name);
const gradientTop = 30;
const gradientHeight = svg_height - 70;
const linearGradient = svg.append("defs")
.append("linearGradient")
.attr("id", "linear-gradient");
@ -174,150 +246,104 @@ export function Map(
.attr("y2", "0%");
svg.append("rect")
.attr("width", svg_width * 0.4)
.attr("height", svg_height)
.attr("x", 0)
.attr("y", gradientTop)
.attr("width", svg_width * 0.35)
.attr("height", gradientHeight)
.attr('rx', 4)
.style("fill", "url(#linear-gradient)");
colorstops.forEach(function (d) {
colorstops.forEach(function (d: [number, string]) {
linearGradient.append("stop")
.attr("offset", d[0] + "%")
.attr("stop-color", d[1]);
});
var xScale = d3.scaleLinear().range([svg_height - 20, 0]).domain([minValue, maxValue]);
var xAxis = d3.axisRight(xScale).ticks(5);
const xScale = d3.scaleLinear().range([gradientHeight - 10, 0]).domain([minValue, maxValue]);
const xAxis = d3.axisRight(xScale).ticks(5).tickFormat((d) => {
const num = d as number;
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}k`;
}
return String(Math.round(num));
});
svg.append("g")
.attr("class", "axis")
.attr("transform", "translate(" + svg_width / 2 + "," + (10) + ")")
.call(xAxis);
.attr("transform", "translate(" + (svg_width * 0.38) + "," + (gradientTop + 5) + ")")
.call(xAxis)
.selectAll("text")
.attr("font-size", "10px");
// Add interpretation labels at bottom
svg.append("text")
.attr("x", svg_width / 2)
.attr("y", svg_height - 25)
.attr("text-anchor", "middle")
.attr("font-size", "9px")
.attr("fill", "#22c55e")
.text(metricInfo.low);
svg.append("text")
.attr("x", svg_width / 2)
.attr("y", svg_height - 10)
.attr("text-anchor", "middle")
.attr("font-size", "9px")
.attr("fill", "#ef4444")
.text(metricInfo.high);
}
function openListingsDialog(longitude: number, latitude: number) {
const searchBuffer = 0.001 // ~100m
const properties = heatmap._tree.search({
if (!heatmapRef.current || !mapRef.current) return;
const searchBuffer = HEATMAP_CONFIG.SEARCH_BUFFER;
const properties = heatmapRef.current._tree.search({
minX: longitude - searchBuffer,
maxX: longitude + searchBuffer,
minY: latitude - searchBuffer,
maxY: latitude + searchBuffer
})
});
if (properties.length > 0) {
const listingDialogPopup = getListingDialog(properties);
new mapboxgl.Popup()
.setLngLat([longitude, latitude])
.setHTML(renderToString(listingDialogPopup))
.setMaxWidth("500px")
.setMaxWidth("450px")
.addTo(mapRef.current);
}
}
function getListingDialog(properties) {
let listingComponents = [];
for (let property of properties) {
listingComponents.push(getPropertyComponent(property));
}
return <ScrollArea className="rounded-md border">
<div className="overflow-y-auto h-[500px] w-[500px] scrollbar-thin scrollbar-thumb-rounded">
<div className="propertyListingPopupItem" style={{ width: '100%' }}>
Showing <strong>{properties.length}</strong> properties
function getListingDialog(properties: PropertyWithCoords[]) {
return (
<ScrollArea className="rounded-md">
<div className="overflow-y-auto max-h-[500px] w-[420px]">
<div className="px-3 py-2 text-sm font-medium border-b bg-muted/50">
<strong>{properties.length}</strong> properties in this area
</div>
<div className="p-2 space-y-2">
{properties.map((property) => (
<PropertyCard
key={property.properties.url}
property={property.properties}
variant="full"
avgPricePerSqm={avgPricePerSqm}
/>
))}
</div>
</div>
{listingComponents.map((item) => {
const scrollDiv = <div key={item.key}>
{item}
<Separator className="my-2" />
</div>;
return scrollDiv
})}
</div>
</ScrollArea>;
</ScrollArea>
);
}
function getPropertyComponent(property) {
const priceHistoryHTMLs = property.properties.price_history.map((d) => {
return <li key={d.id}>${d.last_seen.split('T')[0]}: £${d.price}</li>;
});
let priceHistoryHTML = <></>;
if (priceHistoryHTMLs.length > 1) {
priceHistoryHTML =
<div className="propertyListingPopupItem">
<strong>Price history:</strong>
<ul>
${priceHistoryHTMLs.join('')}
</ul>
<br />
</div>
}
const lastSeenStr = property.properties.last_seen.split('T')[0];
const lastSeenDays = Math.round((new Date() - new Date(lastSeenStr)) / (1000 * 60 * 60 * 24));
return <div key={property.properties.url} style={{ display: 'flex', flexWrap: 'wrap' }}>
<div className="propertyListingPopupItem" style={{ width: '100%' }}>
<a href={property.properties.url} target="_blank">
<img src={property.properties.photo_thumbnail} />
</a>
</div>
<div className="propertyListingPopupItem">
<strong>Available from:</strong>
</div>
<div className="propertyListingPopupItem">
{property.properties.available_from}
</div>
<div className="propertyListingPopupItem">
<strong>Price:</strong>
</div>
<div className="propertyListingPopupItem">
£{property.properties.total_price}
</div>
{priceHistoryHTML}
<div className="propertyListingPopupItem">
<strong>Rooms:</strong>
</div>
<div className="propertyListingPopupItem">
{property.properties.rooms}
</div>
<div className="propertyListingPopupItem">
<strong>Area:</strong>
</div>
<div className="propertyListingPopupItem">
{property.properties.qm} m²
</div>
<div className="propertyListingPopupItem">
<strong>Price per area:</strong>
</div>
<div className="propertyListingPopupItem">
£{property.properties.qmprice}/m²
</div>
<div className="propertyListingPopupItem">
<strong>Last seen:</strong>
</div>
<div className="propertyListingPopupItem">
{lastSeenDays} days ago
</div>
<div className="propertyListingPopupItem">
<strong>Agency:</strong>
</div>
<div className="propertyListingPopupItem">
{property.properties.agency}
</div>
<div className="propertyListingPopupItem" style={{ width: '100%' }}>
<Button asChild>
<a href={property.properties.url} target="_blank">View Listing</a>
</Button>
return (
<div className="relative w-full h-full">
<div id='map-container' ref={mapContainerRef}></div>
<div id="legend">
<svg id="svg"></svg>
</div>
</div>
}
return <>
<div id='map-container' ref={mapContainerRef}></div>
<div id="legend">
<svg id="svg">
</svg>
</div>
</>
);
}
// Re-export types for backwards compatibility
export { Metric, type ParameterValues } from "./Parameters";

View file

@ -0,0 +1,188 @@
import { ExternalLink, Bed, Maximize2, PoundSterling, Clock, Building } from 'lucide-react';
import { Button } from './ui/button';
import type { PropertyProperties } from '@/types';
interface PropertyCardProps {
property: PropertyProperties;
variant?: 'compact' | 'full';
isHighlighted?: boolean;
avgPricePerSqm?: number;
onClick?: () => void;
}
export function PropertyCard({
property,
variant = 'compact',
isHighlighted = false,
avgPricePerSqm,
onClick,
}: PropertyCardProps) {
const lastSeenDate = property.last_seen.split('T')[0];
const lastSeenDays = Math.round((Date.now() - new Date(lastSeenDate).getTime()) / (1000 * 60 * 60 * 24));
// Determine if this is a good deal
const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9;
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
const priceIndicator = isGoodDeal
? { color: 'text-green-600 bg-green-50', label: 'Good deal' }
: isExpensive
? { color: 'text-red-600 bg-red-50', label: 'Above avg' }
: null;
const handleClick = () => {
window.open(property.url, '_blank', 'noopener,noreferrer');
onClick?.();
};
if (variant === 'compact') {
return (
<div
className={`flex gap-3 p-3 rounded-lg border transition-colors cursor-pointer hover:bg-muted/50 ${
isHighlighted ? 'ring-2 ring-primary bg-primary/5' : ''
}`}
onClick={handleClick}
>
{/* Thumbnail */}
<div className="w-20 h-20 rounded-md overflow-hidden flex-shrink-0 bg-muted">
{property.photo_thumbnail && (
<img
src={property.photo_thumbnail}
alt="Property"
className="w-full h-full object-cover"
/>
)}
</div>
{/* Details */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="font-semibold text-base truncate">
£{property.total_price.toLocaleString()}
<span className="text-muted-foreground font-normal text-sm">/mo</span>
</div>
{priceIndicator && (
<span className={`text-xs px-1.5 py-0.5 rounded ${priceIndicator.color}`}>
{priceIndicator.label}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Bed className="h-3.5 w-3.5" />
{property.rooms}
</span>
<span className="flex items-center gap-1">
<Maximize2 className="h-3.5 w-3.5" />
{property.qm} m²
</span>
<span className="flex items-center gap-1">
£{property.qmprice}/m²
</span>
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{lastSeenDays}d ago
</span>
<span className="truncate">{property.agency}</span>
</div>
</div>
</div>
);
}
// Full variant (for popup/detail view)
return (
<div className={`p-4 border rounded-lg ${isHighlighted ? 'ring-2 ring-primary' : ''}`}>
{/* Header with image and price */}
<div className="flex gap-4">
<a
href={property.url}
target="_blank"
rel="noopener noreferrer"
className="block w-32 h-24 rounded-md overflow-hidden flex-shrink-0 bg-muted hover:opacity-90 transition-opacity"
>
{property.photo_thumbnail && (
<img
src={property.photo_thumbnail}
alt="Property"
className="w-full h-full object-cover"
/>
)}
</a>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<div className="font-semibold text-xl">
£{property.total_price.toLocaleString()}
<span className="text-muted-foreground font-normal text-sm">/mo</span>
</div>
{priceIndicator && (
<span className={`inline-block mt-1 text-xs px-2 py-0.5 rounded ${priceIndicator.color}`}>
{priceIndicator.label}
</span>
)}
</div>
</div>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3 mt-4">
<div className="flex items-center gap-2 text-sm">
<Bed className="h-4 w-4 text-muted-foreground" />
<span><strong>{property.rooms}</strong> bedrooms</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Maximize2 className="h-4 w-4 text-muted-foreground" />
<span><strong>{property.qm}</strong> m²</span>
</div>
<div className="flex items-center gap-2 text-sm">
<PoundSterling className="h-4 w-4 text-muted-foreground" />
<span><strong>£{property.qmprice}</strong>/m²</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span>Available <strong>{property.available_from}</strong></span>
</div>
</div>
{/* Agency and last seen */}
<div className="flex items-center gap-2 mt-3 text-sm text-muted-foreground">
<Building className="h-4 w-4" />
<span>{property.agency}</span>
<span className="mx-1"></span>
<span>Seen {lastSeenDays} days ago</span>
</div>
{/* Price history */}
{property.price_history.length > 1 && (
<div className="mt-3 pt-3 border-t">
<div className="text-xs font-medium text-muted-foreground mb-1">Price history</div>
<div className="space-y-0.5">
{property.price_history.slice(0, 5).map((entry) => (
<div key={entry.id} className="text-sm flex justify-between">
<span className="text-muted-foreground">{entry.last_seen.split('T')[0]}</span>
<span>£{entry.price.toLocaleString()}</span>
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="mt-4">
<Button asChild className="w-full">
<a href={property.url} target="_blank" rel="noopener noreferrer">
View Listing
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,166 @@
import { getUser } from '@/auth/authService';
import { POLLING_INTERVALS } from '@/constants';
import { fetchTaskStatus, cancelTask } from '@/services';
import { TaskStatus, type TaskResult } from '@/types';
import type { User } from 'oidc-client-ts';
import { useEffect, useState } from 'react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Button } from './ui/button';
import { Loader2, CheckCircle2, XCircle, X } from 'lucide-react';
interface TaskIndicatorProps {
taskID: string | null;
onTaskCancelled?: () => void;
}
export function TaskIndicator({ taskID, onTaskCancelled }: TaskIndicatorProps) {
const [user, setUser] = useState<User | null>(null);
const [progressPercentage, setProgressPercentage] = useState<number>(0);
const [taskStatus, setTaskStatus] = useState<TaskStatus | null>(null);
const [isCancelling, setIsCancelling] = useState(false);
useEffect(() => {
getUser().then(setUser);
}, []);
useEffect(() => {
if (!user || !taskID) {
setTaskStatus(null);
return;
}
// Reset state for new task
setTaskStatus(TaskStatus.PENDING);
setProgressPercentage(0);
const pollTaskStatus = async () => {
try {
const data = await fetchTaskStatus(user, taskID);
const status = data.status as TaskStatus;
setTaskStatus(status);
if (status === TaskStatus.SUCCESS) {
setProgressPercentage(100);
return true; // Stop polling
}
if (status === TaskStatus.FAILURE || status === TaskStatus.REVOKED) {
return true; // Stop polling
}
// Parse progress for in-progress tasks
if (data.result) {
try {
const parsedResult: TaskResult = JSON.parse(data.result);
setProgressPercentage(parsedResult.progress * 100);
} catch {
// Ignore parsing errors
}
}
return false; // Continue polling
} catch {
setTaskStatus(TaskStatus.FAILURE);
return true; // Stop polling on error
}
};
// Initial poll
pollTaskStatus();
const interval = setInterval(async () => {
const shouldStop = await pollTaskStatus();
if (shouldStop) {
clearInterval(interval);
}
}, POLLING_INTERVALS.TASK_STATUS_MS);
return () => clearInterval(interval);
}, [taskID, user]);
const handleCancel = async () => {
if (!user || !taskID || isCancelling) return;
setIsCancelling(true);
try {
const result = await cancelTask(user, taskID);
if (result.success) {
setTaskStatus(TaskStatus.REVOKED);
onTaskCancelled?.();
}
} catch {
// Ignore cancel errors
} finally {
setIsCancelling(false);
}
};
if (!taskID || !taskStatus) {
return null;
}
const isInProgress = taskStatus !== TaskStatus.SUCCESS &&
taskStatus !== TaskStatus.FAILURE &&
taskStatus !== TaskStatus.REVOKED;
const getStatusIcon = () => {
if (isInProgress) {
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500" />;
}
if (taskStatus === TaskStatus.SUCCESS) {
return <CheckCircle2 className="h-3.5 w-3.5 text-green-500" />;
}
return <XCircle className="h-3.5 w-3.5 text-red-500" />;
};
const getTooltipContent = () => {
if (isInProgress) {
return `Task running: ${Math.round(progressPercentage)}%`;
}
if (taskStatus === TaskStatus.SUCCESS) {
return 'Task completed successfully';
}
if (taskStatus === TaskStatus.REVOKED) {
return 'Task was cancelled';
}
return 'Task failed';
};
return (
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
{getStatusIcon()}
<span className="text-xs text-muted-foreground hidden sm:inline">
{isInProgress ? `${Math.round(progressPercentage)}%` : taskStatus}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{getTooltipContent()}</p>
<p className="text-xs text-muted-foreground mt-1">ID: {taskID.slice(0, 8)}...</p>
</TooltipContent>
</Tooltip>
{isInProgress && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleCancel}
disabled={isCancelling}
className="h-6 w-6 text-muted-foreground hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Cancel task</p>
</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
);
}