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

@ -6,3 +6,12 @@ export ROUTING_API_KEY="<CHANGE ME>" # fetch from https://console.cloud.google.c
export DB_CONNECTION_STRING="sqlite:///data/wrongmove.db" # by default use SQLite locally export DB_CONNECTION_STRING="sqlite:///data/wrongmove.db" # by default use SQLite locally
export CELERY_BROKER_URL="redis://localhost:6379/0" # processing background tasks export CELERY_BROKER_URL="redis://localhost:6379/0" # processing background tasks
export CELERY_RESULT_BACKEND="redis://localhost:6379/1" export CELERY_RESULT_BACKEND="redis://localhost:6379/1"
# Periodic scraping schedules (JSON array)
# Each schedule has: name, enabled, hour, minute, day_of_week, listing_type, min/max_bedrooms, min/max_price, district_names, furnish_types
# Cron fields: minute (0-59), hour (0-23), day_of_week (0-6, 0=Sunday)
# Example:
# SCRAPE_SCHEDULES='[{"name":"Daily RENT","listing_type":"RENT","hour":"2","min_bedrooms":2,"max_bedrooms":3,"min_price":2000,"max_price":4000}]'
# Multiple schedules:
# SCRAPE_SCHEDULES='[{"name":"RENT 2am","listing_type":"RENT","hour":"2"},{"name":"BUY 4am","listing_type":"BUY","hour":"4"}]'
SCRAPE_SCHEDULES=

View file

@ -1,30 +1,25 @@
"""FastAPI application for the Real Estate Crawler API."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
import json import json
import logging import logging
import logging.config import logging.config
from typing import Annotated from typing import Annotated, Optional
from api.auth import get_current_user from api.auth import get_current_user
from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS from api.config import DEV_TIER_ORIGINS, PROD_TIER_ORIGINS
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import Depends, FastAPI, Query from fastapi import Depends, FastAPI, Query
from fastapi.responses import StreamingResponse
from api.auth import User from api.auth import User
from models.listing import QueryParameters from models.listing import QueryParameters, ListingType, FurnishType
from notifications import send_notification from notifications import send_notification
from rec import districts
from redis_repository import RedisRepository
from repositories.listing_repository import ListingRepository from repositories.listing_repository import ListingRepository
from database import engine from database import engine
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from ui_exporter import convert_to_geojson_feature, convert_row_to_geojson
from tasks import listing_tasks from services import listing_service, export_service, district_service, task_service
from ui_exporter import export_immoweb
from alembic import command
from alembic.config import Config
from contextlib import asynccontextmanager
from celery.exceptions import TaskRevokedError
from celery_app import app as celery_app
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from api.metrics import metrics_app # Import the Prometheus ASGI app from api.metrics import metrics_app
from opentelemetry.metrics import get_meter from opentelemetry.metrics import get_meter
@ -32,17 +27,35 @@ load_dotenv()
logger = logging.getLogger("uvicorn") logger = logging.getLogger("uvicorn")
# @asynccontextmanager def get_query_parameters(
# async def lifespan(app: FastAPI): listing_type: ListingType,
# alembic_cfg = Config("./alembic.ini") min_bedrooms: int = 1,
# logger.info("Running alembic migrations") max_bedrooms: int = 999,
# command.upgrade(alembic_cfg, "head") min_price: int = 0,
# logger.info("Finished running alembic migrations") max_price: int = 10_000_000,
# yield min_sqm: Optional[int] = None,
# logger.warning("Shutting down") last_seen_days: Optional[int] = None,
let_date_available_from: Optional[datetime] = None,
furnish_types: Optional[str] = None, # comma-separated list
) -> QueryParameters:
"""Parse query parameters into QueryParameters model."""
parsed_furnish_types = None
if furnish_types:
parsed_furnish_types = [FurnishType(f.strip()) for f in furnish_types.split(",")]
return QueryParameters(
listing_type=listing_type,
min_bedrooms=min_bedrooms,
max_bedrooms=max_bedrooms,
min_price=min_price,
max_price=max_price,
min_sqm=min_sqm,
last_seen_days=last_seen_days,
let_date_available_from=let_date_available_from,
furnish_types=parsed_furnish_types,
)
# app = FastAPI(lifespan=lifespan)
app = FastAPI() app = FastAPI()
app.mount("/metrics", metrics_app) app.mount("/metrics", metrics_app)
meter = get_meter(__name__) meter = get_meter(__name__)
@ -66,52 +79,121 @@ app.add_middleware(
@app.get("/api/status") @app.get("/api/status")
async def get_status(): async def get_status() -> dict[str, str]:
request_counter.add(1, {"method": "GET", "path": "/status"}) request_counter.add(1, {"method": "GET", "path": "/status"})
hist.record(1.5, {"method": "GET", "path": "/status"}) hist.record(1.5, {"method": "GET", "path": "/status"})
return {"status": "OK"} return {"status": "OK"}
@app.get("/api/listing") @app.get("/api/listing")
async def get_listing(user: Annotated[User, Depends(get_current_user)]): async def get_listing(
user: Annotated[User, Depends(get_current_user)],
limit: int = 5,
) -> dict[str, list]:
"""Get listings from the database."""
repository = ListingRepository(engine) repository = ListingRepository(engine)
listings = await repository.get_listings(limit=5) result = await listing_service.get_listings(repository, limit=limit)
logger.info(f"Fetched {len(listings)} listings") logger.info(f"Fetched {result.total_count} listings for {user.email}")
return {"listings": listings} return {"listings": result.listings}
@app.get("/api/listing_geojson") @app.get("/api/listing_geojson")
async def get_listing_geojson( async def get_listing_geojson(
user: Annotated[User, Depends(get_current_user)], user: Annotated[User, Depends(get_current_user)],
query_parameters: Annotated[QueryParameters, Query()], query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
): limit: int = 1000, # Default limit to prevent timeout
) -> dict:
"""Get listings as GeoJSON for map display."""
repository = ListingRepository(engine) repository = ListingRepository(engine)
geojson_data = await export_immoweb( result = await export_service.export_to_geojson(
repository, query_parameters=query_parameters, limit=None repository,
query_parameters=query_parameters,
limit=limit,
)
return result.data
@app.get("/api/listing_geojson/stream")
async def stream_listing_geojson(
user: Annotated[User, Depends(get_current_user)],
query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
batch_size: int = 50,
limit: int = 1000,
) -> StreamingResponse:
"""Stream listings as NDJSON for progressive map loading.
Returns newline-delimited JSON with three message types:
- metadata: Initial message with batch_size and total_expected count
- batch: Array of GeoJSON features
- complete: Final message with total count
"""
async def generate():
repository = ListingRepository(engine)
# Phase 1: Fast count for progress estimation
total = repository.count_listings(query_parameters)
effective_total = min(limit, total) if limit else total
yield json.dumps({
"type": "metadata",
"batch_size": batch_size,
"total_expected": effective_total,
}) + "\n"
# Phase 2: Stream with column projection and keyset pagination
count = 0
batch = []
for row in repository.stream_listings_optimized(
query_parameters, limit=limit, page_size=batch_size
):
feature = convert_row_to_geojson(row)
batch.append(feature)
count += 1
if len(batch) >= batch_size:
yield json.dumps({"type": "batch", "features": batch}) + "\n"
batch = []
# Send remaining
if batch:
yield json.dumps({"type": "batch", "features": batch}) + "\n"
# Final message
yield json.dumps({"type": "complete", "total": count}) + "\n"
return StreamingResponse(
generate(),
media_type="application/x-ndjson",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # Disable nginx buffering
}
) )
return geojson_data
@app.post("/api/refresh_listings") @app.post("/api/refresh_listings")
async def refresh_listings( async def refresh_listings(
user: Annotated[User, Depends(get_current_user)], user: Annotated[User, Depends(get_current_user)],
query_parameters: Annotated[QueryParameters, Query()], query_parameters: Annotated[QueryParameters, Depends(get_query_parameters)],
) -> dict[str, str]: ) -> dict[str, str]:
"""Trigger a background task to refresh listings."""
await send_notification( await send_notification(
f"{user.email} refreshing listings with query parameters {query_parameters.model_dump_json()}" f"{user.email} refreshing listings with query parameters {query_parameters.model_dump_json()}"
) )
# await listing_tasks.async_dump_listings_task(query_parameters.model_dump_json()) # Use this for local debugging - run task in sync
# return {} repository = ListingRepository(engine)
# TODO: rate limit result = await listing_service.refresh_listings(
expiry_time = datetime.now() + timedelta(minutes=10) repository,
task = listing_tasks.dump_listings_task.apply_async( query_parameters,
args=(query_parameters.model_dump_json(),), async_mode=True,
expires=expiry_time, user_email=user.email,
) )
redis_repository = RedisRepository.instance() # Track task for user
redis_repository.add_task_for_user(user, task.id) if result.task_id:
return {"task_id": task.id} task_service.add_task_for_user(user.email, result.task_id)
return {"task_id": result.task_id or "", "message": result.message}
@app.get("/api/task_status") @app.get("/api/task_status")
@ -119,16 +201,12 @@ async def get_task_status(
user: Annotated[User, Depends(get_current_user)], user: Annotated[User, Depends(get_current_user)],
task_id: str, task_id: str,
) -> dict[str, str]: ) -> dict[str, str]:
task_result = listing_tasks.dump_listings_task.AsyncResult(task_id) """Get the status of a background task."""
try: status = task_service.get_task_status(task_id)
result = json.dumps(task_result.result)
except Exception:
result = str(task_result.result)
return { return {
"task_id": task_id, "task_id": status.task_id,
"status": task_result.status, "status": status.status,
"result": result, "result": json.dumps(status.result) if status.result else "",
} }
@ -136,16 +214,36 @@ async def get_task_status(
async def get_tasks_for_user( async def get_tasks_for_user(
user: Annotated[User, Depends(get_current_user)], user: Annotated[User, Depends(get_current_user)],
) -> list[str]: ) -> list[str]:
redis_repository = RedisRepository.instance() """Get all task IDs for the current user."""
user_tasks = redis_repository.get_tasks_for_user(user) return task_service.get_user_tasks(user.email)
return user_tasks
@app.post("/api/cancel_task")
async def cancel_task(
user: Annotated[User, Depends(get_current_user)],
task_id: str = Query(..., description="The task ID to cancel"),
) -> dict[str, str | bool]:
"""Cancel a running task."""
# Verify user owns this task
user_tasks = task_service.get_user_tasks(user.email)
if task_id not in user_tasks:
return {"success": False, "message": "Task not found or not owned by user"}
try:
task_service.cancel_task(task_id)
logger.info(f"Task {task_id} cancelled by {user.email}")
return {"success": True, "message": "Task cancelled"}
except Exception as e:
logger.error(f"Failed to cancel task {task_id}: {e}")
return {"success": False, "message": str(e)}
@app.get("/api/get_districts") @app.get("/api/get_districts")
async def get_districts( async def get_districts(
user: Annotated[User, Depends(get_current_user)], user: Annotated[User, Depends(get_current_user)],
) -> dict[str, str]: ) -> dict[str, str]:
return districts.get_districts() """Get all available districts."""
return district_service.get_all_districts()
FastAPIInstrumentor.instrument_app(app) FastAPIInstrumentor.instrument_app(app)

View file

@ -0,0 +1,4 @@
"""Configuration modules."""
from config.schedule_config import ScheduleConfig, SchedulesConfig
__all__ = ["ScheduleConfig", "SchedulesConfig"]

View file

@ -0,0 +1,122 @@
"""Schedule configuration for periodic scraping tasks."""
from __future__ import annotations
import json
import logging
import os
import re
from typing import Self
from pydantic import BaseModel, field_validator
from models.listing import FurnishType, ListingType, QueryParameters
logger = logging.getLogger("uvicorn.error")
# Cron field validation patterns
CRON_MINUTE_PATTERN = re.compile(r"^(\*|([0-5]?\d)(,[0-5]?\d)*|\*/[1-9]\d*)$")
CRON_HOUR_PATTERN = re.compile(r"^(\*|(1?\d|2[0-3])(,(1?\d|2[0-3]))*|\*/[1-9]\d*)$")
CRON_DAY_OF_WEEK_PATTERN = re.compile(r"^(\*|[0-6](,[0-6])*|\*/[1-6])$")
class ScheduleConfig(BaseModel):
"""Configuration for a single periodic scrape schedule."""
name: str
enabled: bool = True
minute: str = "0"
hour: str = "2"
day_of_week: str = "*"
listing_type: ListingType
min_bedrooms: int = 1
max_bedrooms: int = 999
min_price: int = 0
max_price: int = 10_000_000
district_names: list[str] = []
furnish_types: list[str] | None = None
@field_validator("minute")
@classmethod
def validate_minute(cls, v: str) -> str:
"""Validate cron minute field (0-59, *, or */N)."""
if not CRON_MINUTE_PATTERN.match(v):
raise ValueError(
f"Invalid cron minute '{v}'. Must be 0-59, *, */N, or comma-separated values."
)
return v
@field_validator("hour")
@classmethod
def validate_hour(cls, v: str) -> str:
"""Validate cron hour field (0-23, *, or */N)."""
if not CRON_HOUR_PATTERN.match(v):
raise ValueError(
f"Invalid cron hour '{v}'. Must be 0-23, *, */N, or comma-separated values."
)
return v
@field_validator("day_of_week")
@classmethod
def validate_day_of_week(cls, v: str) -> str:
"""Validate cron day_of_week field (0-6, *, or */N)."""
if not CRON_DAY_OF_WEEK_PATTERN.match(v):
raise ValueError(
f"Invalid cron day_of_week '{v}'. Must be 0-6, *, */N, or comma-separated values."
)
return v
def to_query_parameters(self) -> QueryParameters:
"""Convert schedule config to QueryParameters for the scrape task."""
furnish_types_enum: list[FurnishType] | None = None
if self.furnish_types:
furnish_types_enum = [FurnishType(ft) for ft in self.furnish_types]
return QueryParameters(
listing_type=self.listing_type,
min_bedrooms=self.min_bedrooms,
max_bedrooms=self.max_bedrooms,
min_price=self.min_price,
max_price=self.max_price,
district_names=set(self.district_names),
furnish_types=furnish_types_enum,
)
class SchedulesConfig(BaseModel):
"""Container for multiple schedule configurations."""
schedules: list[ScheduleConfig] = []
@classmethod
def from_env(cls, env_var: str = "SCRAPE_SCHEDULES") -> Self:
"""Load schedules from environment variable.
Args:
env_var: Name of the environment variable containing JSON config.
Returns:
SchedulesConfig instance with parsed schedules.
Raises:
ValueError: If the JSON is invalid or schedule validation fails.
"""
raw_value = os.environ.get(env_var, "").strip()
if not raw_value:
logger.info(f"No {env_var} configured, no periodic scrapes will be scheduled")
return cls(schedules=[])
try:
parsed = json.loads(raw_value)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in {env_var}: {e}") from e
if not isinstance(parsed, list):
raise ValueError(f"{env_var} must be a JSON array")
schedules = [ScheduleConfig.model_validate(item) for item in parsed]
return cls(schedules=schedules)
def get_enabled_schedules(self) -> list[ScheduleConfig]:
"""Return only enabled schedules."""
return [s for s in self.schedules if s.enabled]

105
crawler/docker-compose.yml Normal file
View file

@ -0,0 +1,105 @@
version: "3.8"
services:
redis:
image: redis:8
container_name: rec-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: ["redis-server", "--appendonly", "yes"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
mysql:
image: mysql:9
container_name: rec-mysql
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: wrongmove
MYSQL_USER: wrongmove
MYSQL_PASSWORD: wrongmove
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
container_name: rec-app
ports:
- "5001:5001"
volumes:
# Bind mount source code for development
- .:/app
# Preserve virtual environment in container
- app_venv:/app/.venv
environment:
- ENV=dev
- DB_CONNECTION_STRING=mysql://wrongmove:wrongmove@mysql:3306/wrongmove
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
- ROUTING_API_KEY=${ROUTING_API_KEY:-}
depends_on:
redis:
condition: service_healthy
mysql:
condition: service_healthy
command: ["uvicorn", "api.app:app", "--host", "0.0.0.0", "--port", "5001", "--reload", "--reload-dir", "api", "--reload-dir", "services", "--reload-dir", "repositories", "--reload-dir", "models"]
celery:
build:
context: .
dockerfile: Dockerfile
container_name: rec-celery
volumes:
- .:/app
- app_venv:/app/.venv
environment:
- ENV=dev
- DB_CONNECTION_STRING=mysql://wrongmove:wrongmove@mysql:3306/wrongmove
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
- ROUTING_API_KEY=${ROUTING_API_KEY:-}
- SCRAPE_SCHEDULES=${SCRAPE_SCHEDULES:-}
depends_on:
redis:
condition: service_healthy
mysql:
condition: service_healthy
command: ["celery", "-A", "celery_app", "worker", "--loglevel=info"]
celery-beat:
build:
context: .
dockerfile: Dockerfile
container_name: rec-celery-beat
volumes:
- .:/app
- app_venv:/app/.venv
environment:
- ENV=dev
- DB_CONNECTION_STRING=mysql://wrongmove:wrongmove@mysql:3306/wrongmove
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
- SCRAPE_SCHEDULES=${SCRAPE_SCHEDULES:-}
depends_on:
- redis
- celery
command: ["celery", "-A", "celery_app", "beat", "--loglevel=info"]
volumes:
redis_data:
mysql_data:
app_venv:

View file

@ -1,50 +1,14 @@
#root { #root {
margin: 0 auto; margin: 0;
padding: 2rem; padding: 0;
text-align: center; height: 100%;
} overflow: hidden;
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
} }
html, html,
body { body {
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
margin: 0;
padding: 0;
} }

View file

@ -1,94 +1,38 @@
import type { User } from 'oidc-client-ts'; import type { User } from 'oidc-client-ts';
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef, useCallback } from 'react';
import './App.css'; import './App.css';
import { AppSidebar } from './AppSidebar'; import { getUser, handleCallback } from './auth/authService';
import { getUser, handleCallback, logout } from './auth/authService';
import ActiveQuery from './components/ActiveQuery';
import AlertError from './components/AlertError'; import AlertError from './components/AlertError';
import LoginModal from './components/LoginModal'; import LoginModal from './components/LoginModal';
import { Map } from './components/Map'; import { Map } from './components/Map';
import { Parameters, type ParameterValues } from './components/Parameters'; import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES } from './components/FilterPanel';
import { Spinner } from './components/Spinner'; import { Header } from './components/Header';
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from './components/ui/breadcrumb'; import { StatsBar, type ViewMode } from './components/StatsBar';
import { ListView } from './components/ListView';
import { StreamingProgressBar } from './components/StreamingProgressBar';
import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet';
import { Button } from './components/ui/button'; import { Button } from './components/ui/button';
import { Separator } from './components/ui/separator'; import { Filter } from 'lucide-react';
import { SidebarInset, SidebarProvider, SidebarTrigger } from './components/ui/sidebar'; import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types';
import { refreshListings, fetchTasksForUser, streamListingGeoJSON, type StreamingProgress } from '@/services';
const fetchData = async (user: User, baseQueyrUri: string, parameters: ParameterValues, method: string = 'GET') => {
const accessToken = user.access_token;
const queryString = new URLSearchParams();
queryString.append('listing_type', parameters.listing_type)
if (parameters.min_bedrooms) {
queryString.append('min_bedrooms', parameters.min_bedrooms.toString());
}
if (parameters.max_bedrooms) {
queryString.append('max_bedrooms', parameters.max_bedrooms.toString())
}
if (parameters.max_price) {
queryString.append("max_price", parameters.max_price.toString());
}
if (parameters.min_price) {
queryString.append("min_price", parameters.min_price.toString());
}
if (parameters.min_sqm) {
queryString.append("min_sqm", parameters.min_sqm.toString());
}
if (parameters.last_seen_days) {
queryString.append("last_seen_days", parameters.last_seen_days.toString());
}
if (parameters.available_from) {
queryString.append("let_date_available_from", parameters.available_from.toISOString());
}
if (parameters.district) {
queryString.append("district_names", parameters.district);
}
const response = await fetch(baseQueyrUri + '?' + queryString,
{
method: method,
headers: {
'Authorization': `Bearer ${accessToken}`, // Pass the token
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error('Error: ' + response.status);
}
const data: Response = await response.json();
return data;
};
const fetchActiveTasksForUser = async (user: User) => {
const accessToken = user?.access_token;
const response = await fetch(`/api/tasks_for_user`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`, // Pass the token
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch active tasks for user: ${response.status}`);
}
const data =
await response.json();
return data;
};
function App() { function App() {
const [listingData, setListingData] = useState({}); const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
const [taskID, setTaskID] = useState<string | null>(null); const [taskID, setTaskID] = useState<string | null>(null);
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [isParametersModalOpen, setIsParametersModalOpen] = useState(true);
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null); const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false); const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
const [spinnerText, setSpinnerText] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('map');
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
// Ref to track accumulated features during streaming
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
// Ref to track if initial load has been triggered
const initialLoadTriggeredRef = useRef(false);
useEffect(() => { useEffect(() => {
// Check if this is a callback from Authentik (after login) // Check if this is a callback from Authentik (after login)
@ -107,101 +51,233 @@ function App() {
if (!user) { if (!user) {
return; return;
} }
fetchActiveTasksForUser(user).then((tasks) => { fetchTasksForUser(user).then((tasks) => {
if (tasks) { if (tasks && tasks.length > 0) {
setTaskID(tasks[0]) setTaskID(tasks[0]);
} }
}) });
}, [user, taskID]) }, [user, taskID]);
// Load listings function - used by both auto-load and manual submit
const loadListings = useCallback(async (parameters: ParameterValues) => {
if (!user) return;
setQueryParameters(parameters);
setMobileFilterOpen(false);
setIsLoading(true);
accumulatedFeaturesRef.current = [];
setStreamingProgress({ count: 0 });
setListingData(null);
try {
for await (const batch of streamListingGeoJSON(user, parameters, (progress) => {
setStreamingProgress(progress);
})) {
accumulatedFeaturesRef.current.push(...batch);
setListingData({
type: 'FeatureCollection',
features: [...accumulatedFeaturesRef.current]
});
}
} catch (error) {
if (error instanceof Error) {
setSubmitError(error.message);
} else {
setSubmitError(String(error));
}
setAlertDialogIsOpen(true);
} finally {
setIsLoading(false);
setStreamingProgress(null);
}
}, [user]);
// Auto-load data with default filters when user is authenticated
useEffect(() => {
if (!user || initialLoadTriggeredRef.current) {
return;
}
initialLoadTriggeredRef.current = true;
const defaultParams: ParameterValues = {
...DEFAULT_FILTER_VALUES,
available_from: new Date(),
};
loadListings(defaultParams);
}, [user, loadListings]);
if (!user) { if (!user) {
return <LoginModal isOpen={user === null} /> return <LoginModal isOpen={user === null} />;
} }
const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => { const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => {
// Fetch listing data
setQueryParameters(parameters)
setIsParametersModalOpen(false)
let data = null;
if (action === 'visualize') { if (action === 'visualize') {
setSpinnerText("Loading data for visualization...") loadListings(parameters);
try {
data = await fetchData(user, "/api/listing_geojson", parameters);
} catch (error) {
// @ts-expect-error
setSubmitError(error.message)
setAlertDialogIsOpen(true)
} finally {
setSpinnerText(null)
}
if (data) {
setListingData(data);
}
} else if (action === 'fetch-data') { } else if (action === 'fetch-data') {
setSpinnerText("Submitting query to refresh listings...") setQueryParameters(parameters);
setMobileFilterOpen(false);
setIsLoading(true);
try { try {
data = await fetchData(user, "/api/refresh_listings", parameters, 'POST'); const data = await refreshListings(user!, parameters);
// @ts-expect-error setTaskID(data.task_id);
setTaskID(data.task_id)
} catch (error) { } catch (error) {
// @ts-expect-error if (error instanceof Error) {
setSubmitError(error.message) setSubmitError(error.message);
setAlertDialogIsOpen(true) } else {
setSubmitError(String(error));
}
setAlertDialogIsOpen(true);
} finally { } finally {
setSpinnerText(null) setIsLoading(false);
} }
} }
console.log(data) };
}
const handlePropertyClick = (property: PropertyProperties, _coordinates: [number, number]) => {
setHighlightedProperty(property.url);
// Optionally: pan map to coordinates
};
const renderMainContent = () => {
if (!listingData) {
return (
<div className="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center p-8 max-w-md">
{isLoading ? (
<>
<div className="text-6xl mb-4 animate-pulse">🏠</div>
<h2 className="text-xl font-semibold mb-2">Loading Properties...</h2>
<p className="text-muted-foreground mb-4">
Fetching listings with default filters. You can adjust filters on the left.
</p>
</>
) : (
<>
<div className="text-6xl mb-4">🏠</div>
<h2 className="text-xl font-semibold mb-2">Welcome to Property Explorer</h2>
<p className="text-muted-foreground mb-4">
Use the filters on the left to find properties. Apply filters to visualize existing data or refresh to fetch new listings.
</p>
</>
)}
</div>
</div>
);
}
if (listingData.features.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center p-8">
<div className="text-6xl mb-4">🔍</div>
<h2 className="text-xl font-semibold mb-2">No listings found</h2>
<p className="text-muted-foreground">
Try adjusting the filters or run a data refresh to fetch new listings.
</p>
</div>
</div>
);
}
return (
<>
{/* Map View */}
{(viewMode === 'map' || viewMode === 'split') && (
<div className={`relative ${viewMode === 'split' ? 'w-1/2' : 'flex-1'}`} style={{ minHeight: 0 }}>
<Map
listingData={listingData}
queryParameters={queryParameters}
onPropertyClick={handlePropertyClick}
/>
</div>
)}
{/* List View */}
{(viewMode === 'list' || viewMode === 'split') && (
<div className={`${viewMode === 'split' ? 'w-1/2 border-l' : 'flex-1'}`}>
<ListView
listingData={listingData}
onPropertyClick={handlePropertyClick}
highlightedPropertyUrl={highlightedProperty}
/>
</div>
)}
</>
);
};
const handleTaskCancelled = () => {
setTaskID(null);
};
return ( return (
<> <div className="h-screen flex flex-col overflow-hidden">
<SidebarProvider defaultOpen={false}> {/* Header */}
<AppSidebar /> <Header
<SidebarInset> user={user}
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4"> taskID={taskID}
<SidebarTrigger className="-ml-1" /> onTaskCancelled={handleTaskCancelled}
<Separator />
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">
Building Your Application
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<div className="flex flex-col h-screen w-full">
<div className="flex gap-2 p-2 bg-gray-100">
<h1>Welcome, {user.profile.email}!</h1>
<Button onClick={logout}>Logout</Button>
<Parameters onSubmit={onSubmit} isOpen={isParametersModalOpen} setIsOpen={setIsParametersModalOpen} />
<ActiveQuery taskID={taskID} />
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
<Spinner show={spinnerText !== null} > {/* Main content area */}
<span >{spinnerText}</span> <div className="flex-1 flex overflow-hidden min-h-0">
</Spinner> {/* Filter Panel - Desktop (fixed sidebar) */}
<div className="hidden md:block w-64 shrink-0 h-full overflow-hidden">
<FilterPanel
onSubmit={onSubmit}
isLoading={isLoading}
listingCount={listingData?.features.length}
/>
</div>
</div> {/* Filter Panel - Mobile (sheet) */}
{Object.keys(listingData).length > 0 && <div className="md:hidden fixed bottom-4 right-4 z-50">
<div className="flex-1 w-full relative" style={{ minHeight: 0, marginBottom: '8rem' }}> <Sheet open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
<Map listingData={listingData} queryParameters={queryParameters} /> <SheetTrigger asChild>
</div> <Button size="lg" className="rounded-full shadow-lg h-14 w-14">
} <Filter className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-80 p-0">
<FilterPanel
onSubmit={onSubmit}
isLoading={isLoading}
listingCount={listingData?.features.length}
/>
</SheetContent>
</Sheet>
</div>
{/* Main View Area */}
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
{/* Streaming Progress Bar */}
<div className="relative shrink-0">
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} />
</div> </div>
</SidebarInset>
</SidebarProvider> {/* Map/List Container */}
</> <div className="flex-1 flex overflow-hidden min-h-0">
) {renderMainContent()}
</div>
{/* Stats Bar */}
{listingData && listingData.features.length > 0 && (
<div className="shrink-0">
<StatsBar
listingData={listingData}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</div>
)}
</div>
</div>
{/* Error Dialog */}
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
</div>
);
} }
export default App export default App;

View file

@ -1,55 +1,80 @@
#map-container { #map-container {
/* position: absolute; */ position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
flex: 1;
/* position: 'relative'; */
/* overflow: 'visible'; */
/* isolation: 'isolate'; */
} }
.sidebar {
background-color: rgb(35 55 75 / 90%);
color: #fff;
padding: 6px 12px;
font-family: monospace;
z-index: 1;
position: absolute;
top: 0;
left: 0;
margin: 12px;
border-radius: 4px;
}
#legend { #legend {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
line-height: 18px; line-height: 18px;
height: 310px; height: auto;
width: 60px; min-height: 300px;
padding: 10px; width: 90px;
padding: 12px;
position: absolute; position: absolute;
top: 0; top: 10px;
right: 0; right: 10px;
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.95);
margin-top: 40px; border-radius: 8px;
margin-right: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-family: Arial, sans-serif; }
#legend .axis path,
#legend .axis line {
stroke: #e5e7eb;
}
#legend .axis text {
fill: #6b7280;
} }
.propertyListingPopupItem { .propertyListingPopupItem {
display: 'flex'; display: flex;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid #aaa;
justify-content: center; justify-content: center;
font-family: sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 8px; padding: 6px;
width: 50%; width: 50%;
/* 2 columns */ }
/* Mapbox popup styling improvements */
.mapboxgl-popup-content {
padding: 0 !important;
border-radius: 8px !important;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15) !important;
}
.mapboxgl-popup-close-button {
font-size: 20px;
padding: 4px 8px;
color: #6b7280;
z-index: 10;
}
.mapboxgl-popup-close-button:hover {
background-color: #f3f4f6;
color: #111827;
}
/* Improve marker visibility */
.mapboxgl-marker {
cursor: pointer;
}
/* Mobile adjustments */
@media (max-width: 768px) {
#legend {
width: 75px;
padding: 8px;
min-height: 250px;
}
.mapboxgl-popup-content {
max-width: 90vw !important;
}
} }

View file

@ -1,141 +1,128 @@
import { getUser } from '@/auth/authService'; 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 type { User } from 'oidc-client-ts';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import AlertError from './AlertError'; import AlertError from './AlertError';
import { Spinner } from './Spinner'; import { Spinner } from './Spinner';
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card'; import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
import { Progress } from './ui/progress'; import { Progress } from './ui/progress';
import { Button } from './ui/button';
import { X } from 'lucide-react';
interface ModalProps { interface ActiveQueryProps {
taskID: string | null; taskID: string | null;
onTaskCancelled?: () => void;
} }
const fetchTaskStatusData = async (user: User, taskID: string) => { const ActiveQuery: React.FC<ActiveQueryProps> = ({ taskID, onTaskCancelled }) => {
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 [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
useEffect(() => { useEffect(() => {
getUser().then(setUser); getUser().then(setUser);
}, []); }, []);
const [progressPercentage, setProgressPercentage] = useState<number>(0); 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 [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
const [fetchStatusError, setFetchStatusError] = useState<string | null>(null); const [fetchStatusError, setFetchStatusError] = useState<string | null>(null);
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false); const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const fetchTaskStatus = async (interval: NodeJS.Timeout) => { const handleCancelTask = async () => {
if (!user || !taskID) { if (!user || !taskID || isCancelling) return;
return;
} setIsCancelling(true);
let data = null
try { try {
data = await fetchTaskStatusData(user, taskID); const result = await cancelTask(user, taskID);
} catch (error: any) { if (result.success) {
clearInterval(interval); setTaskStatus(TaskStatus.REVOKED);
setTaskStatus("FAILURE") onTaskCancelled?.();
setAlertDialogIsOpen(true)
if (error instanceof Error) {
setFetchStatusError(error.message)
} else { } 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(() => { useEffect(() => {
const interval = setInterval const interval = setInterval(
(() => fetchTaskStatus(interval), 5000); // every 5 seconds () => pollTaskStatus(interval),
POLLING_INTERVALS.TASK_STATUS_MS
);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [taskID]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskID, user]);
if (!taskID) { if (!taskID) {
return null; return null;
} }
const isInProgress = taskStatus &&
taskStatus !== TaskStatus.SUCCESS &&
taskStatus !== TaskStatus.FAILURE &&
taskStatus !== TaskStatus.REVOKED;
return ( return (
<> <>
<div> <div className="flex items-center gap-2 p-2 border-t bg-muted/50">
<HoverCard> <HoverCard>
<HoverCardTrigger> <HoverCardTrigger className="flex-1">
{taskStatus && <>Task status: {taskStatus} </>} <div className="flex items-center gap-2">
<Progress value={progressPercentage} /> {taskStatus && <span className="text-sm">Task: {taskStatus}</span>}
{taskStatus && taskStatus !== 'SUCCESS' && taskStatus !== 'FAILURE' && taskStatus !== 'REVOKED' && <Spinner />} {isInProgress && <Spinner />}
</div>
<Progress value={progressPercentage} className="mt-1" />
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent> <HoverCardContent>
Task ID: {taskID} Task ID: {taskID}
@ -143,10 +130,22 @@ const ActiveQuery: React.FC<ModalProps> = ({
Last updated: {lastUpdateTime.toLocaleString()} Last updated: {lastUpdateTime.toLocaleString()}
</HoverCardContent> </HoverCardContent>
</HoverCard> </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> </div>
<AlertError message={fetchStatusError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} /> <AlertError message={fetchStatusError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
</> </>
) );
}; };
export default ActiveQuery; 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 crossfilter from "crossfilter2";
import * as d3 from "d3"; import * as d3 from "d3";
import mapboxgl from "mapbox-gl"; import mapboxgl from "mapbox-gl";
import 'mapbox-gl/dist/mapbox-gl.css'; // this hides the map for some reason import 'mapbox-gl/dist/mapbox-gl.css';
import { useEffect, useRef } from "react"; import { useEffect, useRef, useMemo, useCallback } from "react";
import { renderToString } from 'react-dom/server'; import { renderToString } from 'react-dom/server';
import "../assets/Map.css"; import "../assets/Map.css";
import { Metric, type ParameterValues } from "./Parameters"; import { Metric, type ParameterValues } from "./Parameters";
import { Button } from "./ui/button"; import { PropertyCard } from "./PropertyCard";
import { ScrollArea } from "./ui/scroll-area"; 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( // Type declaration for the external HexgridHeatmap library
props: { declare class HexgridHeatmap {
listingData: any; _tree: {
queryParameters: ParameterValues | null; 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; 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 const mapRef = useRef<mapboxgl.Map | null>(null);
var filter = { city: 'London', country: null, mode: Metric.qmprice }; const mapContainerRef = useRef<HTMLDivElement | null>(null);
// filter['countries'] = Array.from(new Set(data.features.map(function (d) { return d['properties']['country'] }))); 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) { 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) // Get appropriate color scheme based on metric
const mapContainerRef = useRef('map-container') const colorScheme = useMemo(() => {
useEffect(() => { return getColorSchemeForMetric(filter.mode);
mapboxgl.accessToken = 'pk.eyJ1IjoiZGktdG8iLCJhIjoiY2o0bnBoYXcxMW1mNzJ3bDhmc2xiNWttaiJ9.ZccatVk_4shzoAsEUXXecA'; }, [filter.mode]);
mapRef.current = new mapboxgl.Map({
container: mapContainerRef.current, const metricInfo = useMemo(() => {
style: 'mapbox://styles/mapbox/light-v9', return getMetricInterpretation(filter.mode);
center: [13.38032, 49.994210], }, [filter.mode]);
zoom: 5
// 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 () { }, [data]);
update()
}) const updateHeatmap = useCallback(() => {
mapRef.current.on('click', function (e) { if (!mapRef.current || !isMapLoadedRef.current) return;
openListingsDialog(e.lngLat.lng, e.lngLat.lat);
}) const crossData = buildCrossfilterData();
return () => { const cf = crossfilter(crossData);
mapRef.current.remove() 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])
const heatmap = heatmapRef.current;
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
heatmap.setPropertyName(filter.mode); heatmap.setPropertyName(filter.mode);
if (filter.mode === Metric.qmprice) { if (filter.mode === Metric.qmprice) {
// if we visualize sqm based data, remove properties where we have no data qmDim.filter((d) => (d as number) > 0);
qmDim.filter(function (d) { return d > 0; });
} }
// set filter
if (filter.city) { if (filter.city) {
cityDim.filterExact(filter.city); cityDim.filterExact(filter.city);
} else if (filter.country) { } else if (filter.country) {
countryDim.filterExact(filter.country); countryDim.filterExact(filter.country);
} else {
alert('nothing loadable');
} }
filter.count = cityDim.top(Infinity).length;
var subset = { "type": "FeatureCollection", "features": [] }; const subset: GeoJSONFeatureCollection = { type: "FeatureCollection", features: [] };
indexDim.top(Infinity).forEach(function (i) { indexDim.top(Infinity).forEach(function (i: CrossfilterRecord) {
subset.features.push(data.features[i.index]); subset.features.push(data.features[i.index]);
}); });
loadData(heatmap, subset); // Update heatmap data
}
function loadData(heatmap, subset) {
heatmap.setData(subset); heatmap.setData(subset);
var values = subset.features.map(function (d) { return d['properties'][filter.mode] }); let values = subset.features.map(function (d: PropertyFeature) {
values = values.sort(function (a, b) { return a - b; }); return d.properties[filter.mode as keyof PropertyProperties] as number;
// 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]];
}); });
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.setColorStops(colorStopsValue);
heatmap.update(); heatmap.update();
//get bounding box and zoom to that area // Fit bounds only on first load or significant data change
// we use a 1% percentile since some data can be corrupt if (lastDataLengthRef.current === 0 && subset.features.length > 0) {
var longitudes = subset.features.map(function (d) { return d.geometry.coordinates[0]; }).sort(function (a, b) { return a - b; }); const longitudes = subset.features.map(function (d: PropertyFeature) { return d.geometry.coordinates[0]; }).sort(function (a: number, b: number) { return a - b; });
var latitudes = subset.features.map(function (d) { return d.geometry.coordinates[1]; }).sort(function (a, b) { 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; });
var minlng = percentile(longitudes, 0.01); const minlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN);
var maxlng = percentile(longitudes, 0.99); const maxlng = percentile(longitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX);
var minlat = percentile(latitudes, 0.01); const minlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MIN);
var maxlat = percentile(latitudes, 0.99); const maxlat = percentile(latitudes, PERCENTILE_CONFIG.BOUNDS_CLIP_MAX);
mapRef.current.fitBounds([
[minlng, minlat],
[maxlng, maxlat]
]);
}
function makeLegend(colorstops, minValue, maxValue) { mapRef.current?.fitBounds([
/** [minlng, minlat],
* colorstops: [[0, 'green'], [100, 'red']] [maxlng, maxlat]
* @type {number} ]);
*/ }
var svg_height = 300, svg_width = 70;
// clear svg before starting 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(); d3.select('#svg').selectAll('*').remove();
// create a new SVG element
const svg = d3.select('#svg'); const svg = d3.select('#svg');
var defs = svg svg
.attr('height', svg_height) .attr('height', svg_height)
.attr('width', svg_width); .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") .append("linearGradient")
.attr("id", "linear-gradient"); .attr("id", "linear-gradient");
@ -174,150 +246,104 @@ export function Map(
.attr("y2", "0%"); .attr("y2", "0%");
svg.append("rect") svg.append("rect")
.attr("width", svg_width * 0.4) .attr("x", 0)
.attr("height", svg_height) .attr("y", gradientTop)
.attr("width", svg_width * 0.35)
.attr("height", gradientHeight)
.attr('rx', 4) .attr('rx', 4)
.style("fill", "url(#linear-gradient)"); .style("fill", "url(#linear-gradient)");
colorstops.forEach(function (d) { colorstops.forEach(function (d: [number, string]) {
linearGradient.append("stop") linearGradient.append("stop")
.attr("offset", d[0] + "%") .attr("offset", d[0] + "%")
.attr("stop-color", d[1]); .attr("stop-color", d[1]);
}); });
const xScale = d3.scaleLinear().range([gradientHeight - 10, 0]).domain([minValue, maxValue]);
var xScale = d3.scaleLinear().range([svg_height - 20, 0]).domain([minValue, maxValue]); const xAxis = d3.axisRight(xScale).ticks(5).tickFormat((d) => {
var xAxis = d3.axisRight(xScale).ticks(5); const num = d as number;
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}k`;
}
return String(Math.round(num));
});
svg.append("g") svg.append("g")
.attr("class", "axis") .attr("class", "axis")
.attr("transform", "translate(" + svg_width / 2 + "," + (10) + ")") .attr("transform", "translate(" + (svg_width * 0.38) + "," + (gradientTop + 5) + ")")
.call(xAxis); .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) { function openListingsDialog(longitude: number, latitude: number) {
const searchBuffer = 0.001 // ~100m if (!heatmapRef.current || !mapRef.current) return;
const properties = heatmap._tree.search({
const searchBuffer = HEATMAP_CONFIG.SEARCH_BUFFER;
const properties = heatmapRef.current._tree.search({
minX: longitude - searchBuffer, minX: longitude - searchBuffer,
maxX: longitude + searchBuffer, maxX: longitude + searchBuffer,
minY: latitude - searchBuffer, minY: latitude - searchBuffer,
maxY: latitude + searchBuffer maxY: latitude + searchBuffer
}) });
if (properties.length > 0) { if (properties.length > 0) {
const listingDialogPopup = getListingDialog(properties); const listingDialogPopup = getListingDialog(properties);
new mapboxgl.Popup() new mapboxgl.Popup()
.setLngLat([longitude, latitude]) .setLngLat([longitude, latitude])
.setHTML(renderToString(listingDialogPopup)) .setHTML(renderToString(listingDialogPopup))
.setMaxWidth("500px") .setMaxWidth("450px")
.addTo(mapRef.current); .addTo(mapRef.current);
} }
} }
function getListingDialog(properties) { function getListingDialog(properties: PropertyWithCoords[]) {
let listingComponents = []; return (
for (let property of properties) { <ScrollArea className="rounded-md">
listingComponents.push(getPropertyComponent(property)); <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">
return <ScrollArea className="rounded-md border"> <strong>{properties.length}</strong> properties in this area
<div className="overflow-y-auto h-[500px] w-[500px] scrollbar-thin scrollbar-thumb-rounded"> </div>
<div className="p-2 space-y-2">
<div className="propertyListingPopupItem" style={{ width: '100%' }}> {properties.map((property) => (
Showing <strong>{properties.length}</strong> properties <PropertyCard
key={property.properties.url}
property={property.properties}
variant="full"
avgPricePerSqm={avgPricePerSqm}
/>
))}
</div>
</div> </div>
{listingComponents.map((item) => { </ScrollArea>
const scrollDiv = <div key={item.key}> );
{item}
<Separator className="my-2" />
</div>;
return scrollDiv
})}
</div>
</ScrollArea>;
} }
function getPropertyComponent(property) { return (
const priceHistoryHTMLs = property.properties.price_history.map((d) => { <div className="relative w-full h-full">
return <li key={d.id}>${d.last_seen.split('T')[0]}: £${d.price}</li>; <div id='map-container' ref={mapContainerRef}></div>
}); <div id="legend">
<svg id="svg"></svg>
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>
</div> </div>
</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>
);
}

View file

@ -0,0 +1,74 @@
// Health check service for backend connectivity
export type HealthStatus = 'checking' | 'healthy' | 'unhealthy';
export interface HealthCheckResult {
status: HealthStatus;
latencyMs?: number;
error?: string;
}
/**
* Check backend health by calling the /api/status endpoint
*/
export async function checkBackendHealth(): Promise<HealthCheckResult> {
const startTime = performance.now();
try {
const response = await fetch('/api/status', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
// Short timeout for health checks
signal: AbortSignal.timeout(5000),
});
const latencyMs = Math.round(performance.now() - startTime);
if (!response.ok) {
return {
status: 'unhealthy',
latencyMs,
error: `HTTP ${response.status}`,
};
}
const data = await response.json();
if (data.status === 'OK') {
return {
status: 'healthy',
latencyMs,
};
}
return {
status: 'unhealthy',
latencyMs,
error: 'Unexpected response',
};
} catch (error) {
const latencyMs = Math.round(performance.now() - startTime);
if (error instanceof Error) {
if (error.name === 'TimeoutError' || error.name === 'AbortError') {
return {
status: 'unhealthy',
latencyMs,
error: 'Request timeout',
};
}
return {
status: 'unhealthy',
latencyMs,
error: error.message,
};
}
return {
status: 'unhealthy',
latencyMs,
error: 'Connection failed',
};
}
}

View file

@ -0,0 +1,43 @@
// Task service for fetching task status
import type { User } from 'oidc-client-ts';
import type { TaskStatusResponse } from '@/types';
import { apiRequest } from './apiClient';
import { API_ENDPOINTS } from '@/constants';
export interface CancelTaskResponse {
success: boolean;
message: string;
}
/**
* Fetch all active tasks for the current user
*/
export async function fetchTasksForUser(user: User): Promise<string[]> {
return apiRequest<string[]>(user, API_ENDPOINTS.TASKS_FOR_USER);
}
/**
* Fetch the status of a specific task
*/
export async function fetchTaskStatus(
user: User,
taskId: string
): Promise<TaskStatusResponse> {
return apiRequest<TaskStatusResponse>(user, API_ENDPOINTS.TASK_STATUS, {
params: { task_id: taskId },
});
}
/**
* Cancel a running task
*/
export async function cancelTask(
user: User,
taskId: string
): Promise<CancelTaskResponse> {
return apiRequest<CancelTaskResponse>(user, API_ENDPOINTS.CANCEL_TASK, {
method: 'POST',
params: { task_id: taskId },
});
}

View file

@ -1,35 +1,50 @@
import asyncio import asyncio
import importlib
import itertools import itertools
import logging import logging
from typing import Any from typing import Any
from celery import Task from celery import Task
from celery.schedules import crontab
from celery_app import app from celery_app import app
from config.schedule_config import SchedulesConfig
from listing_processor import ListingProcessor from listing_processor import ListingProcessor
from models.listing import Listing, ListingType, QueryParameters from models.listing import Listing, QueryParameters
from rec.districts import get_districts from rec.districts import get_districts
from rec.query import listing_query from rec.query import listing_query
from repositories.listing_repository import ListingRepository from repositories.listing_repository import ListingRepository
from database import engine from database import engine
from services import image_fetcher, floorplan_detector
dump_images_module = importlib.import_module("3_dump_images") from utils.redis_lock import redis_lock
detect_floorplan_module = importlib.import_module("4_detect_floorplan")
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
SCRAPE_LOCK_NAME = "scrape_listings"
@app.task(bind=True, pydantic=True) @app.task(bind=True, pydantic=True)
def dump_listings_task(self: Task, parameters_json: str) -> dict[str, Any]: def dump_listings_task(self: Task, parameters_json: str) -> dict[str, Any]:
parsed_parameters = QueryParameters.model_validate_json(parameters_json) with redis_lock(SCRAPE_LOCK_NAME) as acquired:
self.update_state(state="Starting...", meta={"progress": 0}) if not acquired:
asyncio.run(dump_listings_full(task=self, parameters=parsed_parameters)) logger.warning("Another scrape job is already running, skipping this execution")
return {"progress": 0} self.update_state(state="SKIPPED", meta={"reason": "Another scrape job is running"})
return {"status": "skipped", "reason": "another_job_running"}
logger.info(f"Acquired lock: {SCRAPE_LOCK_NAME}")
parsed_parameters = QueryParameters.model_validate_json(parameters_json)
self.update_state(state="Starting...", meta={"progress": 0})
asyncio.run(dump_listings_full(task=self, parameters=parsed_parameters))
return {"progress": 0}
async def async_dump_listings_task(parameters_json: str) -> dict[str, Any]: async def async_dump_listings_task(parameters_json: str) -> dict[str, Any]:
parsed_parameters = QueryParameters.model_validate_json(parameters_json) with redis_lock(SCRAPE_LOCK_NAME) as acquired:
await dump_listings_full(task=Task(), parameters=parsed_parameters) if not acquired:
return {"progress": 0} logger.warning("Another scrape job is already running, skipping this execution")
return {"status": "skipped", "reason": "another_job_running"}
logger.info(f"Acquired lock: {SCRAPE_LOCK_NAME}")
parsed_parameters = QueryParameters.model_validate_json(parameters_json)
await dump_listings_full(task=Task(), parameters=parsed_parameters)
return {"progress": 0}
async def dump_listings_full( async def dump_listings_full(
@ -83,19 +98,27 @@ async def dump_listings_and_monitor(
@app.on_after_finalize.connect @app.on_after_finalize.connect
def setup_periodic_tasks(sender, **kwargs): def setup_periodic_tasks(sender, **kwargs):
sender.add_periodic_task( """Register periodic tasks from environment configuration."""
3600 * 24, # Daily updates try:
dump_listings_task.s( config = SchedulesConfig.from_env()
QueryParameters( except ValueError as e:
listing_type=ListingType.RENT, logger.error(f"Failed to load schedule configuration: {e}")
min_bedrooms=2, return
max_bedrooms=3,
min_price=2000, for schedule in config.get_enabled_schedules():
max_price=4000, logger.info(
).model_dump_json() f"Registering periodic task: {schedule.name} at {schedule.hour}:{schedule.minute}"
), )
name="Daily dump of interesting rent listings",
) sender.add_periodic_task(
crontab(
minute=schedule.minute,
hour=schedule.hour,
day_of_week=schedule.day_of_week,
),
dump_listings_task.s(schedule.to_query_parameters().model_dump_json()),
name=schedule.name,
)
async def get_ids_to_process( async def get_ids_to_process(