diff --git a/api/app.py b/api/app.py index 449e7b2..b13bd59 100644 --- a/api/app.py +++ b/api/app.py @@ -50,15 +50,23 @@ def get_query_parameters( min_price: int = 0, max_price: int = 10_000_000, min_sqm: Optional[int] = None, + max_sqm: Optional[int] = None, + min_price_per_sqm: Optional[float] = None, + max_price_per_sqm: Optional[float] = None, last_seen_days: Optional[int] = None, let_date_available_from: Optional[datetime] = None, furnish_types: Optional[str] = None, # comma-separated list + district_names: 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(",")] + parsed_district_names: set[str] = set() + if district_names: + parsed_district_names = {d.strip() for d in district_names.split(",") if d.strip()} + return QueryParameters( listing_type=listing_type, min_bedrooms=min_bedrooms, @@ -66,9 +74,13 @@ def get_query_parameters( min_price=min_price, max_price=max_price, min_sqm=min_sqm, + max_sqm=max_sqm, + min_price_per_sqm=min_price_per_sqm, + max_price_per_sqm=max_price_per_sqm, last_seen_days=last_seen_days, let_date_available_from=let_date_available_from, furnish_types=parsed_furnish_types, + district_names=parsed_district_names, ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7332054..1aec067 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import LoginModal from './components/LoginModal'; import AuthCallback from './components/AuthCallback'; import { Map } from './components/Map'; import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel'; +import { VisualizationCard } from './components/VisualizationCard'; import { Header } from './components/Header'; import { StatsBar, type ViewMode } from './components/StatsBar'; import { ListView } from './components/ListView'; @@ -40,6 +41,7 @@ function App() { travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT'; } | null>(null); const [poiTravelFilters, setPoiTravelFilters] = useState>({}); + const [currentMetric, setCurrentMetric] = useState(DEFAULT_FILTER_VALUES.metric); // Ref to track accumulated features during streaming const accumulatedFeaturesRef = useRef([]); @@ -229,6 +231,7 @@ function App() { }; const handleMetricChange = (metric: Metric) => { + setCurrentMetric(metric); setQueryParameters(prev => prev ? { ...prev, metric } : null); }; @@ -351,20 +354,31 @@ function App() {
{/* Filter Panel - Desktop (fixed sidebar) */}
- +
+
+ +
+
+ +
+
{/* Filter Panel - Mobile (sheet) */} @@ -376,20 +390,31 @@ function App() { - +
+
+ +
+
+ +
+
diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx index af82db2..fe9f22d 100644 --- a/frontend/src/components/FilterPanel.tsx +++ b/frontend/src/components/FilterPanel.tsx @@ -4,12 +4,14 @@ 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 { Form, FormControl, FormDescription, FormField, FormItem, FormLabel } 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 { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; import { Loader2, Filter, RefreshCw, MapPin } from "lucide-react"; import { ScrollArea } from "./ui/scroll-area"; +import { RangeSliderField } from "./ui/range-slider-field"; import { POIManager } from "./POIManager"; import type { AuthUser } from "@/auth/types"; import type { POI, POITravelFilter } from "@/types"; @@ -70,7 +72,7 @@ export interface ParameterValues { interface FilterPanelProps { onSubmit: (action: 'fetch-data' | 'visualize', fromValues: ParameterValues) => void; - onMetricChange?: (metric: Metric) => void; + currentMetric: Metric; isLoading?: boolean; listingCount?: number; user?: AuthUser; @@ -78,13 +80,11 @@ interface FilterPanelProps { onStartPoiPicking?: () => void; pickedPoiLocation?: { lat: number; lng: number } | null; userPOIs?: POI[]; - onPoiMetricChange?: (selection: { poiId: number; poiName: string; travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT' } | null) => void; poiTravelFilters?: Record; onPoiTravelFiltersChange?: (filters: Record) => void; } 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(), @@ -102,22 +102,39 @@ const formSchema = z.object({ type FormValues = z.infer; -export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, user, onTaskCreated, onStartPoiPicking, pickedPoiLocation, userPOIs, onPoiMetricChange, poiTravelFilters, onPoiTravelFiltersChange }: FilterPanelProps) { +// Price bounds per listing type +const PRICE_BOUNDS = { + [ListingType.RENT]: { min: 0, max: 10000, step: 50 }, + [ListingType.BUY]: { min: 0, max: 2000000, step: 10000 }, +} as const; + +const BEDROOM_BOUNDS = { min: 0, max: 10, step: 1 } as const; + +export function FilterPanel({ onSubmit, currentMetric, isLoading, listingCount, user, onTaskCreated, onStartPoiPicking, pickedPoiLocation, userPOIs, poiTravelFilters, onPoiTravelFiltersChange }: FilterPanelProps) { const [availableFromRawInput, setAvailableFromRawInput] = useState("now"); const [selectedFurnishTypes, setSelectedFurnishTypes] = useState([]); - const [selectedPoiId, setSelectedPoiId] = useState(''); - const [selectedTravelMode, setSelectedTravelMode] = useState(''); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { - ...DEFAULT_FILTER_VALUES, - available_from: new Date(), // Fresh date on each render + listing_type: DEFAULT_FILTER_VALUES.listing_type, + min_bedrooms: DEFAULT_FILTER_VALUES.min_bedrooms, + max_bedrooms: DEFAULT_FILTER_VALUES.max_bedrooms, + min_price: DEFAULT_FILTER_VALUES.min_price, + max_price: DEFAULT_FILTER_VALUES.max_price, + min_sqm: DEFAULT_FILTER_VALUES.min_sqm, + max_sqm: undefined, + min_price_per_sqm: undefined, + max_price_per_sqm: undefined, + last_seen_days: DEFAULT_FILTER_VALUES.last_seen_days, + available_from: new Date(), + district: '', }, }); // Watch listing_type to make filters type-aware const watchedListingType = form.watch('listing_type'); + const priceBounds = PRICE_BOUNDS[watchedListingType]; // Update price defaults when listing type changes useEffect(() => { @@ -138,6 +155,7 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, return form.handleSubmit((values) => { const params: ParameterValues = { ...values, + metric: currentMetric, furnish_types: selectedFurnishTypes.length > 0 ? selectedFurnishTypes : undefined, }; onSubmit(action, params); @@ -179,6 +197,12 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, return () => subscription.unsubscribe(); }, [form, selectedFurnishTypes]); + const formatPrice = (v: number) => { + if (v >= 1000000) return `£${(v / 1000000).toFixed(1)}M`; + if (v >= 1000) return `£${(v / 1000).toFixed(0)}k`; + return `£${v}`; + }; + return (
{/* Header */} @@ -202,201 +226,107 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount, {/* Filters */}
- - - {/* Visualization Options */} - - - Visualization - - -
- ( - - Color by - - - - )} - /> - {form.watch('metric') === Metric.poi_travel && userPOIs && userPOIs.length > 0 && ( - <> -
- POI - -
-
- Travel Mode - -
- - )} - ( - - Type - - - - )} - /> -
-
-
+ + {/* ── Essential Filters (always visible) ── */} - {/* Price & Size */} - + {/* Listing Type Tabs */} + ( + + + + Rent + Buy + + + + )} + /> + + {/* Price Range Slider */} + { + form.setValue('min_price', lo); + form.setValue('max_price', hi); + }} + formatValue={formatPrice} + /> + + {/* Bedrooms Range Slider */} + { + form.setValue('min_bedrooms', lo); + form.setValue('max_bedrooms', hi); + }} + /> + + {/* Min Size */} + ( + + Min Size (m²) + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + + {/* ── Advanced Filters (collapsed) ── */} + + - Price & Size + Advanced Filters
-
- ( - - Min Price (£) - - field.onChange(e.target.value ? Number(e.target.value) : undefined)} - /> - - - )} - /> - ( - - Max Price (£) - - field.onChange(e.target.value ? Number(e.target.value) : undefined)} - /> - - - )} - /> -
-
- ( - - Min Size (m²) - - field.onChange(e.target.value ? Number(e.target.value) : undefined)} - /> - - - )} - /> - ( - - Max Size (m²) - - field.onChange(e.target.value ? Number(e.target.value) : undefined)} - /> - - - )} - /> -
+ {/* Max Size */} + ( + + Max Size (m²) + + field.onChange(e.target.value ? Number(e.target.value) : undefined)} + /> + + + )} + /> + + {/* Price per m² */}
-
-
-
- {/* Features */} - - - Features - - -
-
- ( - - Min Beds - - field.onChange(e.target.value ? Number(e.target.value) : undefined)} - /> - - - )} - /> - ( - - Max Beds - - field.onChange(e.target.value ? Number(e.target.value) : undefined)} - /> - - - )} - /> -
+ {/* Furnishing (rent only) */} {watchedListingType === ListingType.RENT && (
Furnishing @@ -513,46 +392,30 @@ export function FilterPanel({ onSubmit, onMetricChange, isLoading, listingCount,
)} -
- - - {/* Location */} - - - Location - - - ( - - District - - - - - Comma-separated list of districts - - - )} - /> - - + {/* District */} + ( + + District + + + + + Comma-separated list of districts + + + )} + /> - {/* Availability / Recency */} - - - {watchedListingType === ListingType.RENT ? 'Availability' : 'Recency'} - - -
+ {/* Available From (rent only) */} {watchedListingType === ListingType.RENT && ( )} + + {/* Last Seen Days */} - {/* Points of Interest */} + {/* Points of Interest (auth-only) */} {user && ( diff --git a/frontend/src/components/Map.tsx b/frontend/src/components/Map.tsx index c4f9e66..7c7858d 100644 --- a/frontend/src/components/Map.tsx +++ b/frontend/src/components/Map.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useMemo, useCallback } from "react"; import { Crosshair } from "lucide-react"; import { renderToString } from 'react-dom/server'; import "../assets/Map.css"; -import { Metric, type ParameterValues } from "./Parameters"; +import { Metric, type ParameterValues } from "./FilterPanel"; import { PropertyCard } from "./PropertyCard"; import { ScrollArea } from "./ui/scroll-area"; import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POI } from "@/types"; @@ -97,7 +97,7 @@ export function Map(props: MapProps) { // Compute color scale from valid metric values only const values = data.features .map(function (d: PropertyFeature) { - return (d.properties as Record)[metricMode] as number; + return (d.properties as unknown as Record)[metricMode] as number; }) .filter(function (v: number) { return typeof v === 'number' && isFinite(v) && v > 0; }) .sort(function (a: number, b: number) { return a - b; }); @@ -405,6 +405,3 @@ export function Map(props: MapProps) {
); } - -// Re-export types for backwards compatibility -export { Metric, type ParameterValues } from "./Parameters"; diff --git a/frontend/src/components/Parameters.tsx b/frontend/src/components/Parameters.tsx deleted file mode 100644 index 9693bc1..0000000 --- a/frontend/src/components/Parameters.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { DialogTitle } from "@radix-ui/react-dialog"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { Button } from "./ui/button"; -import { Calendar29 } from "./ui/DatePicker"; -import { Dialog, DialogContent, DialogTrigger } from "./ui/dialog"; -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"; - - -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', -} - - -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[] -} - -export function Parameters( - props: { - isOpen: boolean, - onSubmit: (action: 'fetch-data' | 'visualize', fromValues: ParameterValues) => void, - setIsOpen: (isOpen: boolean) => void - } -) { - const { - register, - } = useForm() - const [action, setAction] = useState<'fetch-data' | 'visualize' | null>(null) - const [availableFromRawInput, setAvailableFromRawInput] = useState("now"); - - 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(1).max(10).optional(), - max_bedrooms: z.number().min(1).max(10).optional(), - max_price: z.number().optional(), - min_price: z.number().min(0).optional(), - min_sqm: z.number().optional(), - last_seen_days: z.number().min(0).optional(), - available_from: z.date(), - district: z.string() - }) - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - metric: Metric.qmprice, - listing_type: ListingType.RENT, - min_bedrooms: 1, - max_bedrooms: 3, - max_price: 3000, - min_price: 2000, - min_sqm: 50, - last_seen_days: 28, - available_from: new Date(), - district: '' - }, - }) - // 2. Define a submit handler. - function onSubmit(values: z.infer) { - // Do something with the form values. - // ✅ This will be type-safe and validated. - console.log(values) - if (action) { - props.onSubmit(action, values) - } - } - - - - return <> - - {/* */} - - - - - - Visualization Parameters - - - - - ( - - Metric to visualize - - - - )} - /> - ( - - Listing Type - - - - - - )} - /> - ( - - Min square meters - - field.onChange(Number(e.target.value))} /> - - - - )} - /> - ( - - Minimum number of bedrooms - - field.onChange(Number(e.target.value))} /> - - - - )} - /> - ( - - Maximum number of bedrooms - - field.onChange(Number(e.target.value))} /> - - - - )} - /> - ( - - Min price - - field.onChange(Number(e.target.value))} /> - - - - )} - /> - ( - - Max price - - field.onChange(Number(e.target.value))} /> - - - - )} - /> - ( - - Available from - - - - - Applicable for renting listings only - - - - )} - /> - ( - - Last seen days - - field.onChange(Number(e.target.value))} /> - - - - )} - /> - - - - - - - - -} - diff --git a/frontend/src/components/VisualizationCard.tsx b/frontend/src/components/VisualizationCard.tsx new file mode 100644 index 0000000..d2a0229 --- /dev/null +++ b/frontend/src/components/VisualizationCard.tsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { Metric } from "./FilterPanel"; +import type { POI } from "@/types"; + +interface VisualizationCardProps { + metric: Metric; + onMetricChange: (metric: Metric) => void; + userPOIs?: POI[]; + onPoiMetricChange?: (selection: { poiId: number; poiName: string; travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT' } | null) => void; +} + +export function VisualizationCard({ metric, onMetricChange, userPOIs, onPoiMetricChange }: VisualizationCardProps) { + const [selectedPoiId, setSelectedPoiId] = useState(''); + const [selectedTravelMode, setSelectedTravelMode] = useState(''); + + return ( +
+

Visualization

+
+ + +
+ {metric === Metric.poi_travel && userPOIs && userPOIs.length > 0 && ( + <> +
+ + +
+
+ + +
+ + )} +
+ ); +} diff --git a/frontend/src/components/ui/range-slider-field.tsx b/frontend/src/components/ui/range-slider-field.tsx new file mode 100644 index 0000000..1689a74 --- /dev/null +++ b/frontend/src/components/ui/range-slider-field.tsx @@ -0,0 +1,70 @@ +import { Slider } from "./slider"; +import { Input } from "./input"; + +interface RangeSliderFieldProps { + label: string; + min: number; + max: number; + step: number; + value: [number, number]; + onValueChange: (value: [number, number]) => void; + formatValue?: (v: number) => string; +} + +export function RangeSliderField({ + label, + min, + max, + step, + value, + onValueChange, + formatValue = (v) => String(v), +}: RangeSliderFieldProps) { + return ( +
+
+ {label} + + {formatValue(value[0])} – {formatValue(value[1])} + +
+ onValueChange(v as [number, number])} + /> +
+ { + const v = Number(e.target.value); + if (!isNaN(v)) { + onValueChange([Math.min(v, value[1]), value[1]]); + } + }} + /> + { + const v = Number(e.target.value); + if (!isNaN(v)) { + onValueChange([value[0], Math.max(v, value[0])]); + } + }} + /> +
+
+ ); +} diff --git a/frontend/src/components/ui/slider.tsx b/frontend/src/components/ui/slider.tsx index 3c20cab..919f48e 100644 --- a/frontend/src/components/ui/slider.tsx +++ b/frontend/src/components/ui/slider.tsx @@ -19,7 +19,7 @@ const Slider = React.forwardRef< - {props.defaultValue?.map((_, index) => ( + {(props.defaultValue ?? props.value)?.map((_, index) => ( QueryParameters: @@ -237,4 +240,12 @@ class QueryParameters(BaseModel): raise ValueError( f"min_bedrooms ({self.min_bedrooms}) must be <= max_bedrooms ({self.max_bedrooms})" ) + if ( + self.min_price_per_sqm is not None + and self.max_price_per_sqm is not None + and self.min_price_per_sqm > self.max_price_per_sqm + ): + raise ValueError( + f"min_price_per_sqm ({self.min_price_per_sqm}) must be <= max_price_per_sqm ({self.max_price_per_sqm})" + ) return self diff --git a/repositories/listing_repository.py b/repositories/listing_repository.py index d3300f1..3a8a2d8 100644 --- a/repositories/listing_repository.py +++ b/repositories/listing_repository.py @@ -191,6 +191,20 @@ class ListingRepository: ) if query_parameters.min_sqm is not None: query = query.where(model.square_meters >= query_parameters.min_sqm) + if query_parameters.max_sqm is not None: + query = query.where(model.square_meters <= query_parameters.max_sqm) + if query_parameters.min_price_per_sqm is not None: + query = query.where( + model.square_meters.is_not(None), + model.square_meters > 0, + (model.price / model.square_meters) >= query_parameters.min_price_per_sqm, + ) + if query_parameters.max_price_per_sqm is not None: + query = query.where( + model.square_meters.is_not(None), + model.square_meters > 0, + (model.price / model.square_meters) <= query_parameters.max_price_per_sqm, + ) if query_parameters.furnish_types and model == RentListing: query = query.where(model.furnish_type.in_(query_parameters.furnish_types)) if (