From ca771a7edf651c56e4e72538b1fb537e9364d8be Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 12 Jul 2026 20:37:39 +0100 Subject: [PATCH] fine 2 --- frontend/src/components/map/AreaPane.tsx | 107 +++- .../src/components/map/FeatureBrowser.tsx | 8 +- .../src/components/map/ListingPopups.test.tsx | 97 ++++ frontend/src/components/map/ListingPopups.tsx | 82 ++- frontend/src/components/map/MapPage.tsx | 23 +- .../src/components/map/PriceHistoryChart.tsx | 23 +- .../src/components/map/PropertiesPane.tsx | 36 +- .../map/filters/ActiveFilterList.tsx | 4 +- .../filters/ElectionVoteShareFilterCard.tsx | 4 +- .../map/filters/NumericFeatureFilterCard.tsx | 2 +- .../map/filters/PoiDistanceFilterCard.tsx | 4 +- .../map/filters/SchoolFilterCard.tsx | 4 +- .../components/map/filters/SliderLabels.tsx | 2 +- .../map/filters/VariantFilterCard.tsx | 6 +- .../map/map-page/DesktopMapPage.tsx | 2 +- frontend/src/components/ui/Header.tsx | 67 +-- .../src/components/ui/icons/EyeOffIcon.tsx | 22 + frontend/src/components/ui/icons/index.ts | 1 + frontend/src/hooks/useFilters.ts | 32 +- frontend/src/hooks/useSavedSearches.ts | 12 +- frontend/src/lib/consts.ts | 33 +- frontend/src/lib/poi-distance-filter.ts | 1 + frontend/src/lib/url-state.test.ts | 20 +- frontend/src/lib/url-state.ts | 19 +- frontend/src/types.ts | 21 + pipeline/download/test_transit_network.py | 259 ++++++++- pipeline/download/transit_network.py | 490 +++++++++++++++++- server-rs/src/data.rs | 6 +- server-rs/src/data/actual_listings.rs | 102 +++- server-rs/src/data/property/mod.rs | 5 +- server-rs/src/pocketbase.rs | 81 +++ server-rs/src/state.rs | 1 + 32 files changed, 1467 insertions(+), 109 deletions(-) create mode 100644 frontend/src/components/map/ListingPopups.test.tsx create mode 100644 frontend/src/components/ui/icons/EyeOffIcon.tsx diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index d990940..4321384 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -8,7 +8,10 @@ import type { FeatureMeta, FilterExclusion, HexagonStatsResponse, + PriceMetric, + PricePoint, } from '../../types'; +import { postcodeOutcode, postcodeSector } from '../../lib/postcode'; import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime'; import type { HexagonLocation } from '../../lib/external-search'; import { @@ -289,6 +292,7 @@ export default function AreaPane({ return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups]; }, [paneFeatureGroups, hexagonLocation]); const [infoFeature, setInfoFeature] = useState(null); + const [priceMetric, setPriceMetric] = useState('price'); const { scrollRef, onScroll } = useRetainedScrollTop({ restoreKey: scrollRestoreKey ?? hexagonId, scrollTopRef, @@ -548,14 +552,105 @@ export default function AreaPane({ {stats.price_history && (() => { const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year))); - return uniqueYears.size > 1 ? ( + if (uniqueYears.size <= 1) return null; + + const charts: { + key: string; + label: string; + dot: string; + points: PricePoint[]; + }[] = [ + { + key: 'area', + label: t('areaPane.priceHistoryThisArea'), + dot: 'bg-teal-500 dark:bg-teal-400', + points: stats.price_history, + }, + ]; + // Sector/outcode context is postcode-only: a hexagon cell + // straddles arbitrary postcodes, so its sector/outcode is not a + // meaningful aggregation unit. The Total/Per-m² toggle still + // applies to the area chart in both cases. + if (isPostcode && hexagonId) { + const sector = postcodeSector(hexagonId); + const outcode = postcodeOutcode(hexagonId); + if (sector && stats.sector_price_history?.length) { + charts.push({ + key: 'sector', + label: sector, + dot: 'bg-indigo-500 dark:bg-indigo-400', + points: stats.sector_price_history, + }); + } + if (outcode && stats.outcode_price_history?.length) { + charts.push({ + key: 'outcode', + label: outcode, + dot: 'bg-amber-500 dark:bg-amber-400', + points: stats.outcode_price_history, + }); + } + } + + const hasPerSqm = charts.some((c) => + c.points.some((p) => p.price_per_sqm != null) + ); + const metric: PriceMetric = hasPerSqm ? priceMetric : 'price'; + const optionClass = (active: boolean) => + `px-2 py-0.5 text-[11px] font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${ + active + ? 'bg-teal-600 text-white dark:bg-teal-500' + : 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700' + }`; + + return (
- - {t('areaPane.priceHistory')} - - +
+ + {t('areaPane.priceHistory')} + + {hasPerSqm && ( +
+ + +
+ )} +
+ {charts.map((chart) => ( +
+ {charts.length > 1 && ( + + + {chart.label} + + )} + +
+ ))}
- ) : null; + ); })()} {displayFeatureGroups.map((group) => { const showNearbyStations = diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index 66e59dc..5804d77 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -105,7 +105,7 @@ export default function FeatureBrowser({ return ( <> -
+
{group.features.length + @@ -141,7 +141,7 @@ export default function FeatureBrowser({ return (
diff --git a/frontend/src/components/map/ListingPopups.test.tsx b/frontend/src/components/map/ListingPopups.test.tsx new file mode 100644 index 0000000..25c38a8 --- /dev/null +++ b/frontend/src/components/map/ListingPopups.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; + +import { ListingPopupSingleContent } from './ListingPopups'; +import type { ActualListing } from '../../types'; + +// No global RTL setup file registers auto-cleanup, so unmount between cases to +// keep each render isolated in the shared jsdom document. +afterEach(cleanup); + +// Minimal react-i18next stub: t() echoes a readable label per key; i18n.language +// is fixed so date formatting is deterministic. +vi.mock('react-i18next', () => { + const labels: Record = { + 'listing.priceHistory': 'Price history', + 'listing.priceListed': 'Listed', + 'listing.priceReduced': 'Reduced', + 'listing.priceIncreased': 'Increased', + 'listing.openListing': 'Open listing', + 'listing.viewed': 'Viewed', + }; + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => + labels[key] ?? (opts ? `${key} ${JSON.stringify(opts)}` : key), + i18n: { language: 'en-GB' }, + }), + }; +}); + +function baseListing(overrides: Partial = {}): ActualListing { + return { + lat: 51.5, + lon: -0.1, + postcode: 'SW9 0HD', + listing_url: 'https://www.rightmove.co.uk/properties/1', + asking_price: 490000, + features: [], + ...overrides, + }; +} + +describe('ListingPopupSingleContent price history', () => { + it('renders points newest-first with signed deltas and reason labels', () => { + const listing = baseListing({ + price_history: [ + { date: '2026-07-01', price: 500000, reason: 'listed' }, + { date: '2026-07-19', price: 480000, reason: 'reduced' }, + { date: '2026-07-26', price: 490000, reason: 'increased' }, + ], + }); + const { getByText, container } = render( + {}} /> + ); + + getByText('Price history'); + // Newest first: increased row shows +£10,000 vs the £480k point before it. + const items = Array.from(container.querySelectorAll('ol li')); + expect(items).toHaveLength(3); + expect(items[0].textContent).toContain('£490,000'); + expect(items[0].textContent).toContain('+£10,000'); + expect(items[0].textContent).toContain('Increased'); + // Middle: the reduction from £500k -> £480k. + expect(items[1].textContent).toContain('£480,000'); + expect(items[1].textContent).toContain('−£20,000'); + expect(items[1].textContent).toContain('Reduced'); + // Oldest: the initial listing, no delta. + expect(items[2].textContent).toContain('£500,000'); + expect(items[2].textContent).toContain('Listed'); + expect(items[2].textContent).not.toContain('£0'); + // UTC-stable date: "2026-07-01" must read as 1 Jul regardless of runner TZ. + expect(items[2].textContent).toContain('1 Jul 2026'); + }); + + it('renders a single listed point with no delta', () => { + const listing = baseListing({ + price_history: [{ date: '2026-06-15', price: 325000, reason: 'listed' }], + }); + const { getByText, container } = render( + {}} /> + ); + getByText('Price history'); + expect(container.querySelectorAll('ol li')).toHaveLength(1); + }); + + it('omits the section entirely when there is no history', () => { + const { queryByText } = render( + {}} + /> + ); + expect(queryByText('Price history')).toBeNull(); + }); +}); diff --git a/frontend/src/components/map/ListingPopups.tsx b/frontend/src/components/map/ListingPopups.tsx index dda406b..6115255 100644 --- a/frontend/src/components/map/ListingPopups.tsx +++ b/frontend/src/components/map/ListingPopups.tsx @@ -2,12 +2,89 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; -import type { ActualListing } from '../../types'; +import type { ActualListing, PriceHistoryPoint } from '../../types'; function formatListingPrice(price: number): string { return `£${price.toLocaleString()}`; } +function formatHistoryDate(iso: string, locale: string): string { + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return iso; + // `iso` is a UTC calendar date ("YYYY-MM-DD"), parsed as UTC midnight. Format + // in UTC too, or a viewer west of UTC would see every date shifted a day back. + return parsed.toLocaleDateString(locale, { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); +} + +function reasonLabel(reason: string, t: TFunction): string { + switch (reason) { + case 'reduced': + return t('listing.priceReduced'); + case 'increased': + return t('listing.priceIncreased'); + default: + return t('listing.priceListed'); + } +} + +/** Compact asking-price timeline for the hover card. Renders most-recent first, + * with the £ change from the prior point on each reduction/increase. Accrues from + * the scraper's forward-only store, so a fresh listing shows a single point. */ +function ListingPriceHistory({ history }: { history: PriceHistoryPoint[] }) { + const { t, i18n } = useTranslation(); + if (history.length === 0) return null; + // history is oldest -> newest; show up to the 5 most recent, newest first. + const shown = history.slice(-5); + const baseIndex = history.length - shown.length; + const rows = shown + .map((point, idx) => { + const globalIdx = baseIndex + idx; + const prev = globalIdx > 0 ? history[globalIdx - 1] : null; + const delta = prev ? point.price - prev.price : 0; + return { point, delta }; + }) + .reverse(); + + return ( +
+
+ {t('listing.priceHistory')} +
+
    + {rows.map(({ point, delta }, idx) => { + const isReduction = point.reason === 'reduced'; + const isIncrease = point.reason === 'increased'; + const accent = isReduction + ? 'text-emerald-600 dark:text-emerald-400' + : isIncrease + ? 'text-red-600 dark:text-red-400' + : 'text-warm-600 dark:text-warm-300'; + return ( +
  1. + + {formatListingPrice(point.price)} + {delta !== 0 && ( + + {delta < 0 ? '−' : '+'}£{Math.abs(delta).toLocaleString()} + + )} + + + {reasonLabel(point.reason, t)} · {formatHistoryDate(point.date, i18n.language)} + +
  2. + ); + })} +
+
+ ); +} + function formatListingHeadline(listing: ActualListing, t: TFunction): string | null { const parts: string[] = []; if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms })); @@ -78,6 +155,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent ))} )} + {listing.price_history && listing.price_history.length > 0 && ( + + )}
{visited && ( ✓ {t('listing.viewed')} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 543d5c0..e82c5cc 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -710,6 +710,11 @@ export default function MapPage({ const shareAndSaveView = isMobile ? (mapData.currentVisibleView ?? mapData.currentView) : mapData.currentView; + // Params for share/save/checkout-return/last-session, deliberately WITHOUT the + // selected postcode: the lat/lon/zoom already convey the location, so focusing + // a postcode adds nothing to a shared link and shouldn't be baked into a saved + // search. The live URL (useUrlSync above) still carries `pc` so a reload + // re-opens the selection. const dashboardParams = useMemo( () => stateToParams( @@ -723,7 +728,7 @@ export default function MapPage({ activeOverlays, basemap, crimeTypes, - selectedPostcodeParam, + undefined, colorOpacity, listingsMode ).toString(), @@ -738,7 +743,6 @@ export default function MapPage({ listingsMode, rightPaneTab, selectedPOICategories, - selectedPostcodeParam, shareCode, shareAndSaveView, ] @@ -894,6 +898,9 @@ export default function MapPage({ total={propertiesTotal} loading={loadingProperties} hexagonId={selectedHexagon?.id || null} + statsUseFilters={areaStatsUseFilters} + onStatsUseFiltersChange={setAreaStatsUseFilters} + filtersActive={Object.keys(filters).length + activeEntries.length > 0} onLoadMore={handleLoadMoreProperties} scrollTopRef={propertiesPaneScrollTopRef} scrollRestoreKey={ @@ -903,7 +910,17 @@ export default function MapPage({ /> ), - [handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon] + [ + activeEntries, + areaStatsUseFilters, + filters, + handleLoadMoreProperties, + loadingProperties, + properties, + propertiesTotal, + selectedHexagon, + setAreaStatsUseFilters, + ] ); const poiPane = useMemo( diff --git a/frontend/src/components/map/PriceHistoryChart.tsx b/frontend/src/components/map/PriceHistoryChart.tsx index 846de2a..9eacfd5 100644 --- a/frontend/src/components/map/PriceHistoryChart.tsx +++ b/frontend/src/components/map/PriceHistoryChart.tsx @@ -1,9 +1,11 @@ import { useMemo, useRef, useState, useEffect } from 'react'; -import type { PricePoint } from '../../types'; +import type { PriceMetric, PricePoint } from '../../types'; import { formatValue } from '../../lib/format'; interface PriceHistoryChartProps { points: PricePoint[]; + /** Which value to plot. Defaults to the absolute sale price. */ + metric?: PriceMetric; } const PADDING = { top: 8, right: 24, bottom: 20, left: 48 }; @@ -22,7 +24,7 @@ interface PriceScale { ticks: number[]; } -export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { +export default function PriceHistoryChart({ points, metric = 'price' }: PriceHistoryChartProps) { const containerRef = useRef(null); const [width, setWidth] = useState(0); @@ -37,7 +39,18 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { return () => observer.disconnect(); }, []); + // Project each point onto the chosen metric as a plain {year, price} so the + // rest of the chart is metric-agnostic. In per-m² mode, points without a + // recorded floor area drop out. + const plotPoints = useMemo(() => { + if (metric === 'price') return points; + return points + .filter((p) => Number.isFinite(p.price_per_sqm)) + .map((p) => ({ year: p.year, price: p.price_per_sqm as number })); + }, [points, metric]); + const { yearMin, yearMax, priceScale, medians } = useMemo(() => { + const points = plotPoints; let yMin = Infinity, yMax = -Infinity; for (const p of points) { @@ -83,7 +96,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { priceScale: getPriceScale(points), medians: meds, }; - }, [points]); + }, [plotPoints]); const plotW = width - PADDING.left - PADDING.right; const plotH = HEIGHT - PADDING.top - PADDING.bottom; @@ -104,7 +117,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { return (
- {width > 0 && ( + {width > 0 && plotPoints.length > 0 && ( {/* Grid lines */} {priceScale.ticks.map((tick) => ( @@ -120,7 +133,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { ))} {/* Dots */} - {points.map((p, i) => ( + {plotPoints.map((p, i) => ( void; + filtersActive: boolean; onLoadMore: () => void; onNavigateToSource?: (slug: string) => void; scrollTopRef?: MutableRefObject; @@ -35,6 +38,9 @@ export function PropertiesPane({ total, loading, hexagonId, + statsUseFilters, + onStatsUseFiltersChange, + filtersActive, onLoadMore, onNavigateToSource, scrollTopRef, @@ -106,13 +112,41 @@ export function PropertiesPane({ )} -
+
+ {filtersActive && ( +
+ + +
+ )}
diff --git a/frontend/src/components/map/filters/ActiveFilterList.tsx b/frontend/src/components/map/filters/ActiveFilterList.tsx index ef9882b..403b792 100644 --- a/frontend/src/components/map/filters/ActiveFilterList.tsx +++ b/frontend/src/components/map/filters/ActiveFilterList.tsx @@ -448,12 +448,12 @@ export function ActiveFilterList({ onToggleGroup(group.name); onGroupToggle?.(group.name, !expanded); }} - className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800" + className="sticky top-0 z-30 px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800" > {count} {expanded && ( -
+
{group.name === TRANSPORT_GROUP && travelCards} {group.features.map((feature) => renderFeatureCard(feature))}
diff --git a/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx b/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx index 739ea39..2516f4b 100644 --- a/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx +++ b/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx @@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({ return (
-