From 791bc6976b2f7f18563314487547f4d3c6c95715 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 11 Mar 2026 20:44:34 +0000 Subject: [PATCH] Good changes --- CLAUDE.md | 19 +- finder/homecouk.py | 2 +- frontend/src/components/map/AreaPane.tsx | 37 +-- .../components/map/ExternalSearchLinks.tsx | 35 +-- frontend/src/components/map/Filters.tsx | 23 +- .../src/components/map/HistogramLegend.tsx | 2 +- .../components/map/JourneyInstructions.tsx | 262 ++++++++++++++++++ frontend/src/components/map/Map.tsx | 1 + frontend/src/components/map/MapLegend.tsx | 74 +++-- frontend/src/components/map/MapPage.tsx | 21 +- .../src/components/map/TravelTimeCard.tsx | 47 +--- .../src/components/ui/DestinationDropdown.tsx | 222 +++++++++++++++ frontend/src/components/ui/FeatureIcons.tsx | 2 +- frontend/src/index.html | 12 +- pipeline/transform/join_epc_pp.py | 2 +- pipeline/transform/merge.py | 11 +- r5-java/run.sh | 4 +- server-rs/src/data/travel_time.rs | 15 +- server-rs/src/routes/hexagon_stats.rs | 86 ++++-- server-rs/src/routes/invites.rs | 17 +- server-rs/src/routes/journey.rs | 58 ++++ server-rs/src/routes/rightmove_typeahead.rs | 83 ------ server-rs/src/routes/subscription.rs | 78 ------ server-rs/src/routes/travel_destinations.rs | 89 ++++++ 24 files changed, 890 insertions(+), 312 deletions(-) create mode 100644 frontend/src/components/map/JourneyInstructions.tsx create mode 100644 frontend/src/components/ui/DestinationDropdown.tsx create mode 100644 server-rs/src/routes/journey.rs delete mode 100644 server-rs/src/routes/rightmove_typeahead.rs delete mode 100644 server-rs/src/routes/subscription.rs create mode 100644 server-rs/src/routes/travel_destinations.rs diff --git a/CLAUDE.md b/CLAUDE.md index 622c023..835947c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,9 @@ All commands use [Task](https://taskfile.dev) runner. Python uses `uv run`. Fron task dev:server # Rust backend on :8001 (cargo run --release) task dev:frontend # Webpack dev server on :3001 (proxies /api to :8001) -# Data pipeline -task prepare # Build wide.parquet from all pre-downloaded sources +# Data pipeline (uses Make, not Task — see Makefile.data) +make -f Makefile.data prepare # Build properties.parquet (merge + price estimation) +make -f Makefile.data merge # Just the merge step (no price estimation) # Assets task download:map-assets # Download font glyphs + twemoji PNGs into frontend/public/assets/ @@ -55,28 +56,30 @@ uv run pytest pipeline/utils/test_haversine.py -k "test_name" # Single test ``` Raw sources → [Download scripts] → data/*.parquet → [Fuzzy join EPC ↔ Price-Paid] → epc_pp.parquet - → [Merge all datasets] → wide.parquet + → [Merge all datasets] → properties.parquet + → [Price estimation] → properties.parquet (augmented with estimated prices) → [Rust server loads into memory + precomputes H3 + spatial grid] → [Frontend renders deck.gl H3HexagonLayer over MapLibre GL] ``` ### Data Pipeline (`pipeline/`) -Python + Polars. Two phases: +Python + Polars. Orchestrated by `Makefile.data` (Make DAG with sentinel files like `.merge_done`, `.prices_done`). Two phases: 1. **Download** (`pipeline/download/`) — Each script fetches one raw dataset into `data/` 2. **Transform** (`pipeline/transform/`) — Joins and derives features: - `join_epc_pp.py` — Fuzzy-joins EPC ↔ price-paid by address within postcode buckets - - `merge.py` — **Main pipeline**: joins all datasets → `wide.parquet` with human-readable column names + - `merge.py` — **Main pipeline**: joins all datasets → `properties.parquet` with human-readable column names + - `price_estimation/` — Post-merge step: adds "Estimated current price" and "Est. price per sqm" columns to `properties.parquet`. Uses repeat-sales price index + kNN spatial blending. Requires `price_index.parquet` (built by `price_estimation/index.py`). Run via `make -f Makefile.data prepare` (the `merge` target alone skips this). - `transform_poi.py` — Filters POIs, maps to friendly names + emoji (exhaustive category validation) - `poi_proximity.py` — Counts POIs within 2km per postcode using 0.05° spatial grid - `crime.py` — Aggregates crime CSVs into yearly averages by LSOA -**Critical: column renaming in `merge.py`** — The pipeline renames columns from snake_case to human-readable names before writing `wide.parquet`. The Rust server and frontend use **only** these human-readable names — there are no fallbacks to snake_case. Key renames: +**Critical: column renaming in `merge.py`** — The pipeline renames columns from snake_case to human-readable names before writing `properties.parquet`. The Rust server and frontend use **only** these human-readable names — there are no fallbacks to snake_case. Key renames: - `pp_address` → `Address per Property Register` - `postcode` → `Postcode` - `latest_price` → `Last known price` -- `duration` → `Leashold/Freehold` +- `duration` → `Leasehold/Freehold` - `total_floor_area` → `Total floor area (sqm)` - `current_energy_rating` → `Current energy rating` @@ -321,7 +324,7 @@ Follow these conventions in all Rust code: - **POI transform validation**: Fails if any OSM category is unmapped — guarantees exhaustive coverage - **Fuzzy join**: Groups by postcode, uses `thefuzz.token_sort_ratio` with numeric token compatibility, greedy assignment from highest score - **Filter parsing is strict**: `parse_filters()` returns `Result` — malformed entries, unknown feature names, and unparseable numbers all return 400 Bad Request. No silent skipping of invalid filters. -- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. Required parquet columns must exist at startup. +- **Data loading is strict**: `extract_string_col` and `lookup_enum_value` take a single column name (no fallback names). H3 precomputation panics on invalid coordinates. All configured features (defined in `features.rs`) must exist in at least one data source — the server panics at startup if any are missing (no NaN placeholders). This means all pipeline steps must be complete before starting the server. Polars `diagonal: true` concat fills nulls for features that exist in some but not all sources (e.g. "Listing date" from listings only). - **Travel time is strict**: `mode` param is required (400) when `destination` is set — no silent default to "car". R5 failures return 502 Bad Gateway, not silent omission. `r5_url` is `Option` — returns 503 if travel time requested without R5 configured. - **Filter bounds format**: `south,west,north,east` (not standard bbox order) - **Server-side AABB filtering**: Both `/api/hexagons` and `/api/postcodes` filter results by bounding-box intersection with query bounds. Hexagons use `h3_cell_bounds()` (h3o returns degrees, not radians). Postcodes compute polygon AABB from vertices. See `bounds_intersect()` in `parsing/bounds.rs`. diff --git a/finder/homecouk.py b/finder/homecouk.py index c46e79d..4b1bac7 100644 --- a/finder/homecouk.py +++ b/finder/homecouk.py @@ -271,7 +271,7 @@ def transform_property( "lat": lat, "Postcode": postcode, "Address per Property Register": address, - "Leashold/Freehold": None, # not available from home.co.uk + "Leasehold/Freehold": None, # not available from home.co.uk "Property type": map_property_type(listing_type), "Property sub-type": listing_type or "Unknown", "price": int(price), diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index e413ed5..159d73a 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -6,6 +6,7 @@ import type { HexagonStatsResponse, PostcodeFeature, } from '../../types'; +import type { TravelTimeEntry } from '../../hooks/useTravelTime'; import type { HexagonLocation } from '../../lib/external-search'; import { formatValue, formatFilterValue, calculateHistogramMean } from '../../lib/format'; import { groupFeaturesByCategory } from '../../lib/features'; @@ -16,14 +17,14 @@ import StackedBarChart from './StackedBarChart'; import StackedEnumChart from './StackedEnumChart'; import PriceHistoryChart from './PriceHistoryChart'; import ExternalSearchLinks from './ExternalSearchLinks'; -import { InfoIcon, CloseIcon } from '../ui/icons'; +import { InfoIcon } from '../ui/icons'; import { CollapsibleGroupHeader } from '../ui/CollapsibleGroupHeader'; -import { IconButton } from '../ui/IconButton'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { EmptyState } from '../ui/EmptyState'; import { FeatureLabel } from '../ui/FeatureLabel'; import StreetViewEmbed from './StreetViewEmbed'; import HistogramLegend from './HistogramLegend'; +import JourneyInstructions from './JourneyInstructions'; interface AreaPaneProps { stats: HexagonStatsResponse | null; @@ -33,10 +34,10 @@ interface AreaPaneProps { isPostcode?: boolean; postcodeData?: PostcodeFeature | null; onViewProperties: () => void; - onClose: () => void; hexagonLocation: HexagonLocation | null; filters: FeatureFilters; onNavigateToSource?: (slug: string, featureName: string) => void; + travelTimeEntries?: TravelTimeEntry[]; } export default function AreaPane({ @@ -47,10 +48,10 @@ export default function AreaPane({ isPostcode = false, postcodeData, onViewProperties, - onClose, hexagonLocation, filters, onNavigateToSource, + travelTimeEntries, }: AreaPaneProps) { const propertyCount = isPostcode && postcodeData ? postcodeData.properties.count : stats?.count; const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]); @@ -84,16 +85,10 @@ export default function AreaPane({ } return ( -
-
- - - -
- -
+ <> +
-
+

{isPostcode ? hexagonId : 'Area Statistics'} @@ -129,6 +124,16 @@ export default function AreaPane({ {hexagonLocation && stats && ( )} + {(() => { + const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode; + return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? ( + + ) : null; + })()} {loading && !stats ? ( ) : stats ? ( @@ -260,7 +265,7 @@ export default function AreaPane({ p1={numericStats.histogram.p1} p99={numericStats.histogram.p99} globalMean={globalMean} - formatLabel={formatFilterValue} + formatLabel={(v) => formatFilterValue(v, feature.raw)} /> ) : ( formatFilterValue(v, feature.raw)} /> ))}

@@ -386,6 +391,6 @@ export default function AreaPane({ onNavigateToSource={onNavigateToSource} /> )} -
+ ); } diff --git a/frontend/src/components/map/ExternalSearchLinks.tsx b/frontend/src/components/map/ExternalSearchLinks.tsx index 3b742c8..7ce9066 100644 --- a/frontend/src/components/map/ExternalSearchLinks.tsx +++ b/frontend/src/components/map/ExternalSearchLinks.tsx @@ -1,34 +1,19 @@ -import { useMemo, useState, useEffect } from 'react'; +import { useMemo } from 'react'; import type { FeatureFilters } from '../../types'; import { buildPropertySearchUrls, H3_RADIUS_MILES, type HexagonLocation, } from '../../lib/external-search'; -import { apiUrl, logNonAbortError } from '../../lib/api'; +import outcodeIds from '../../lib/rightmove-outcodes.json'; -function useRightmoveLocationId(postcode: string | undefined): string | undefined { - const [locationId, setLocationId] = useState(); +const rightmoveOutcodes = outcodeIds as Record; - useEffect(() => { - if (!postcode) { - setLocationId(undefined); - return; - } - setLocationId(undefined); - const controller = new AbortController(); - fetch(apiUrl('rightmove-location', new URLSearchParams({ postcode })), { - signal: controller.signal, - }) - .then((res) => (res.ok ? res.json() : null)) - .then((data) => { - if (data?.location_identifier) setLocationId(data.location_identifier); - }) - .catch((err) => logNonAbortError('rightmove-location', err)); - return () => controller.abort(); - }, [postcode]); - - return locationId; +function getRightmoveLocationId(postcode: string | undefined): string | undefined { + if (!postcode) return undefined; + const outcode = postcode.trim().split(/\s+/)[0].toUpperCase(); + const id = rightmoveOutcodes[outcode]; + return id ? `OUTCODE^${id}` : undefined; } export default function ExternalSearchLinks({ @@ -38,7 +23,7 @@ export default function ExternalSearchLinks({ location: HexagonLocation; filters: FeatureFilters; }) { - const rightmoveLocationId = useRightmoveLocationId(location.postcode); + const rightmoveLocationId = getRightmoveLocationId(location.postcode); const urls = useMemo( () => buildPropertySearchUrls({ location, filters, rightmoveLocationId }), [location, filters, rightmoveLocationId] @@ -69,7 +54,7 @@ export default function ExternalSearchLinks({ Rightmove ) : ( - + Rightmove )} diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 05f23f1..eba3794 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -32,6 +32,7 @@ function SliderLabels({ displayValues, isAtMin, isAtMax, + raw, }: { min: number; max: number; @@ -39,6 +40,7 @@ function SliderLabels({ displayValues?: [number, number]; isAtMin?: boolean; isAtMax?: boolean; + raw?: boolean; }) { const range = max - min || 1; const leftPct = ((value[0] - min) / range) * 100; @@ -50,13 +52,13 @@ function SliderLabels({ className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }} > - {isAtMin ? 'min' : formatFilterValue(labels[0])} + {isAtMin ? 'min' : formatFilterValue(labels[0], raw)} - {isAtMax ? 'max' : formatFilterValue(labels[1])} + {isAtMax ? 'max' : formatFilterValue(labels[1], raw)}
); @@ -224,6 +226,15 @@ export default memo(function Filters({ [onAddFilter, features, expandGroup] ); + const handleAddTravelTimeAndScroll = useCallback( + (mode: TransportMode) => { + expandGroup('Travel Time'); + pendingScrollRef.current = `tt_${travelTimeEntries.length}`; + onTravelTimeAddEntry(mode); + }, + [onTravelTimeAddEntry, travelTimeEntries.length, expandGroup] + ); + useEffect(() => { const name = pendingScrollRef.current; if (!name) return; @@ -232,7 +243,7 @@ export default memo(function Filters({ if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } - }, [enabledFeatureList]); + }, [enabledFeatureList, travelTimeEntries]); const enabledGroups = useMemo( () => groupFeaturesByCategory(enabledFeatureList), [enabledFeatureList] @@ -311,8 +322,8 @@ export default memo(function Filters({ {!collapsedGroups.has('Travel Time') && (
{travelTimeEntries.map((entry, index) => ( +
onTravelTimeToggleBest(index)} onRemove={() => onTravelTimeRemoveEntry(index)} /> +
))}
)} @@ -460,6 +472,7 @@ export default memo(function Filters({ displayValues={scale ? displayValue : undefined} isAtMin={isAtMin} isAtMax={isAtMax} + raw={feature.raw} />
@@ -488,7 +501,7 @@ export default memo(function Filters({ openInfoFeature={openInfoFeature} onClearOpenInfoFeature={onClearOpenInfoFeature} travelTimeEntries={travelTimeEntries} - onAddTravelTimeEntry={onTravelTimeAddEntry} + onAddTravelTimeEntry={handleAddTravelTimeAndScroll} isLicensed={isLicensed} onUpgradeClick={onUpgradeClick} /> diff --git a/frontend/src/components/map/HistogramLegend.tsx b/frontend/src/components/map/HistogramLegend.tsx index 612c6a1..6e59922 100644 --- a/frontend/src/components/map/HistogramLegend.tsx +++ b/frontend/src/components/map/HistogramLegend.tsx @@ -12,7 +12,7 @@ export default function HistogramLegend() {
- Gray bars show the + Grey bars show the overall distribution across all areas
diff --git a/frontend/src/components/map/JourneyInstructions.tsx b/frontend/src/components/map/JourneyInstructions.tsx new file mode 100644 index 0000000..2b6934d --- /dev/null +++ b/frontend/src/components/map/JourneyInstructions.tsx @@ -0,0 +1,262 @@ +import { useState, useEffect } from 'react'; +import type { JourneyLeg } from '../../types'; +import type { TravelTimeEntry } from '../../hooks/useTravelTime'; +import { apiUrl, logNonAbortError } from '../../lib/api'; +import { WalkingIcon } from '../ui/icons/WalkingIcon'; +import { BicycleIcon } from '../ui/icons/BicycleIcon'; + +interface JourneyInstructionsProps { + postcode: string; + entries: TravelTimeEntry[]; + /** When set, shown as a subtitle (e.g. the central postcode for a hexagon) */ + label?: string; +} + +interface JourneyData { + slug: string; + label: string; + legs: JourneyLeg[] | null; + /** Median (50th percentile) total travel time from R5, including waiting. */ + minutes: number | null; + /** Best-case (5th percentile) total travel time from R5. */ + bestMinutes: number | null; + loading: boolean; +} + +// Official TfL line colors + other known London transit +const ROUTE_COLORS: Record = { + Bakerloo: { color: '#B36305' }, + Central: { color: '#E32017' }, + Circle: { color: '#FFD300', darkText: true }, + District: { color: '#00782A' }, + 'Elizabeth line': { color: '#6950A1' }, + Elizabeth: { color: '#6950A1' }, + 'Hammersmith & City': { color: '#F3A9BB', darkText: true }, + 'Hammersmith and City': { color: '#F3A9BB', darkText: true }, + Jubilee: { color: '#A0A5A9', darkText: true }, + Metropolitan: { color: '#9B0056' }, + Northern: { color: '#333333' }, + Piccadilly: { color: '#003688' }, + Victoria: { color: '#0098D4' }, + 'Waterloo & City': { color: '#95CDBA', darkText: true }, + 'Waterloo and City': { color: '#95CDBA', darkText: true }, + DLR: { color: '#00A4A7' }, + 'London Overground': { color: '#EE7C0E' }, +}; + +const NON_TUBE_NAMES = new Set(['DLR', 'London Overground', 'Elizabeth line']); + +function getRouteDisplay(mode: string): { label: string; color: string; darkText: boolean } { + const known = ROUTE_COLORS[mode]; + if (known) { + const label = NON_TUBE_NAMES.has(mode) || mode.includes('line') ? mode : `${mode} line`; + return { label, color: known.color, darkText: !!known.darkText }; + } + if (/^\d+[A-Za-z]?$/.test(mode.trim())) { + return { label: `Bus ${mode}`, color: '#0d9488', darkText: false }; + } + return { label: mode, color: '#6b7280', darkText: false }; +} + +function invertLegs(legs: JourneyLeg[]): JourneyLeg[] { + return [...legs] + .reverse() + .map((leg) => (leg.from && leg.to ? { ...leg, from: leg.to, to: leg.from } : leg)); +} + +function RouteBadge({ mode }: { mode: string }) { + const { label, color, darkText } = getRouteDisplay(mode); + return ( + + {label} + + ); +} + +function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) { + const isAccess = leg.mode === 'walk' || leg.mode === 'bicycle'; + + if (isAccess) { + return ( +
+
+
+ {!isLast && ( +
+ )} +
+
+ {leg.mode === 'walk' ? ( + + ) : ( + + )} + + {leg.mode === 'walk' ? 'Walk' : 'Cycle'} · {leg.minutes} min + +
+
+ ); + } + + const { color } = getRouteDisplay(leg.mode); + return ( +
+
+
+ {!isLast && ( +
+ )} +
+
+
+ + {leg.minutes} min +
+ {leg.from && leg.to && ( +
+ {leg.from} → {leg.to} +
+ )} +
+
+ ); +} + +export default function JourneyInstructions({ postcode, entries, label }: JourneyInstructionsProps) { + const [journeys, setJourneys] = useState([]); + + // Only transit entries with a destination set + const transitEntries = entries.filter((e) => e.mode === 'transit' && e.slug !== ''); + + useEffect(() => { + if (transitEntries.length === 0) { + setJourneys([]); + return; + } + + const controller = new AbortController(); + const results: JourneyData[] = transitEntries.map((e) => ({ + slug: e.slug, + label: e.label, + legs: null, + minutes: null, + bestMinutes: null, + loading: true, + })); + setJourneys([...results]); + + transitEntries.forEach((entry, idx) => { + const params = new URLSearchParams({ + postcode, + mode: 'transit', + slug: entry.slug, + }); + fetch(apiUrl('journey', params), { signal: controller.signal }) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then( + (data: { + journey: JourneyLeg[] | null; + minutes: number | null; + best_minutes: number | null; + }) => { + setJourneys((prev) => + prev.map((j, i) => + i === idx + ? { + ...j, + legs: data.journey, + minutes: data.minutes, + bestMinutes: data.best_minutes, + loading: false, + } + : j + ) + ); + } + ) + .catch((err) => { + logNonAbortError('journey', err); + setJourneys((prev) => + prev.map((j, i) => (i === idx ? { ...j, loading: false } : j)) + ); + }); + }); + + return () => controller.abort(); + }, [postcode, transitEntries.map((e) => e.slug).join(',')]); // eslint-disable-line react-hooks/exhaustive-deps + + if (transitEntries.length === 0) return null; + + return ( +
+ {label && ( +
Journeys from {label}
+ )} + {journeys.map((j) => { + const displayLegs = j.legs ? invertLegs(j.legs) : null; + const legSum = j.legs ? j.legs.reduce((sum, l) => sum + l.minutes, 0) : 0; + const totalMin = j.minutes ?? legSum; + const waitingMin = j.minutes != null ? Math.max(0, j.minutes - legSum) : null; + const bestWaitingMin = + j.bestMinutes != null ? Math.max(0, j.bestMinutes - legSum) : null; + + return ( +
+
+ + To {j.label || j.slug} + + {displayLegs && displayLegs.length > 0 && ( + + {totalMin} min + + )} +
+ {j.loading ? ( +
+
+ Loading... +
+ ) : displayLegs && displayLegs.length > 0 ? ( +
+ {displayLegs.map((leg, i) => ( + + ))} + {waitingMin != null && waitingMin > 0 && ( +
+ + Waiting & transfers + + + {waitingMin} min + {bestWaitingMin != null && ( + + {' '} + (best: {bestWaitingMin === 0 ? '~0' : bestWaitingMin} min) + + )} + +
+ )} +
+ ) : ( + + No journey data available + + )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 34790ea..4b110fc 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -244,6 +244,7 @@ export default memo(function Map({ mode="feature" enumValues={colorFeatureMeta.type === 'enum' ? colorFeatureMeta.values : undefined} theme={theme} + raw={colorFeatureMeta.raw} /> ) : null ) : ( diff --git a/frontend/src/components/map/MapLegend.tsx b/frontend/src/components/map/MapLegend.tsx index 63951a6..fb81fd5 100644 --- a/frontend/src/components/map/MapLegend.tsx +++ b/frontend/src/components/map/MapLegend.tsx @@ -14,6 +14,7 @@ export default function MapLegend({ theme = 'light', inline = false, suffix, + raw, }: { featureLabel: string; range: [number, number]; @@ -24,26 +25,65 @@ export default function MapLegend({ theme?: 'light' | 'dark'; inline?: boolean; suffix?: string; + raw?: boolean; }) { const densityGradient = theme === 'dark' ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; const gradientStyle = mode === 'density' ? gradientToCss(densityGradient) : gradientToCss(FEATURE_GRADIENT); + const fmt = raw ? { raw: true } : undefined; + + const rangeMin = + mode === 'density' ? ( + + ) : enumValues && enumValues.length > 0 ? ( + {enumValues[0]} + ) : ( + + ); + + const rangeMax = + mode === 'density' ? ( + + ) : enumValues && enumValues.length > 0 ? ( + {enumValues[enumValues.length - 1]} + ) : ( + + ); + + if (inline) { + return ( +
+ + {featureLabel} + + {showCancel && ( + + )} +
+ {rangeMin} +
+ {rangeMax} +
+
+ ); + } + return ( -
+
{featureLabel} {showCancel && ( @@ -51,22 +91,8 @@ export default function MapLegend({
- {mode === 'density' ? ( - <> - - - - ) : enumValues && enumValues.length > 0 ? ( - <> - {enumValues[0]} - {enumValues[enumValues.length - 1]} - - ) : ( - <> - - - - )} + {rangeMin} + {rangeMax}
); diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 60a51ee..a34864a 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -161,10 +161,17 @@ export default function MapPage({ travelTimeEntries: travelTime.entries, }); + // First transit destination — used to pick the best central_postcode for journey display + const journeyDest = useMemo(() => { + const entry = travelTime.entries.find((e) => e.mode === 'transit' && e.slug); + return entry ? { mode: entry.mode, slug: entry.slug } : null; + }, [travelTime.entries]); + const selection = useHexagonSelection({ filters, features, resolution: mapData.resolution, + journeyDest, }); const handleLocationSearchResult = useCallback( @@ -196,6 +203,17 @@ export default function MapPage({ selection.setRightPaneTab(initialTab); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Prevent browser back/forward navigation from horizontal trackpad swipes + useEffect(() => { + const handleWheel = (e: WheelEvent) => { + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + e.preventDefault(); + } + }; + document.addEventListener('wheel', handleWheel, { passive: false }); + return () => document.removeEventListener('wheel', handleWheel); + }, []); + const { handleHexagonClick } = selection; const handleMobileHexagonClick = useCallback( (id: string, isPostcode?: boolean, geometry?: PostcodeGeometry) => { @@ -351,9 +369,9 @@ export default function MapPage({ : null } onViewProperties={selection.handleViewPropertiesFromArea} - onClose={selection.handleCloseSelection} hexagonLocation={hexagonLocation} filters={filters} + travelTimeEntries={travelTime.activeEntries} /> ); @@ -501,6 +519,7 @@ export default function MapPage({ enumValues={mobileLegendMeta.type === 'enum' ? mobileLegendMeta.values : undefined} theme={theme} inline + raw={mobileLegendMeta.raw} /> ) : null ) : ( diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 8bdc20a..c5fe163 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -1,8 +1,8 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import { Slider } from '../ui/Slider'; import { IconButton } from '../ui/IconButton'; import { PillToggle } from '../ui/PillToggle'; -import { PlaceSearchInput } from '../ui/PlaceSearchInput'; +import { DestinationDropdown } from '../ui/DestinationDropdown'; import InfoPopup from '../ui/InfoPopup'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { EyeIcon } from '../ui/icons/EyeIcon'; @@ -13,7 +13,7 @@ import { BicycleIcon } from '../ui/icons/BicycleIcon'; import { WalkingIcon } from '../ui/icons/WalkingIcon'; import { TransitIcon } from '../ui/icons/TransitIcon'; import { formatFilterValue } from '../../lib/format'; -import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch'; +import { useTravelDestinations } from '../../hooks/useTravelDestinations'; import { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime'; import type { ComponentType } from 'react'; @@ -51,29 +51,14 @@ export function TravelTimeCard({ onToggleBest, onRemove, }: TravelTimeCardProps) { - const search = useLocationSearch(mode); - const containerRef = useRef(null); + const { destinations, loading: destinationsLoading } = useTravelDestinations(mode); const [showBestInfo, setShowBestInfo] = useState(false); - // Close dropdown on outside click - useEffect(() => { - const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - search.close(); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [search.close]); - - const selectResult = useCallback( - (result: SearchResult) => { - if (result.type === 'place') { - onSetDestination(result.slug, result.name); - search.clear(); - } + const handleDestinationSelect = useCallback( + (selectedSlug: string, selectedLabel: string) => { + onSetDestination(selectedSlug, selectedLabel); }, - [onSetDestination, search.clear], + [onSetDestination], ); const sliderMin = 0; @@ -120,16 +105,12 @@ export function TravelTimeCard({
) : ( -
- -
+ )} {/* Best-case toggle — transit only, shown when destination is set */} diff --git a/frontend/src/components/ui/DestinationDropdown.tsx b/frontend/src/components/ui/DestinationDropdown.tsx new file mode 100644 index 0000000..d237acf --- /dev/null +++ b/frontend/src/components/ui/DestinationDropdown.tsx @@ -0,0 +1,222 @@ +import { + useState, + useRef, + useEffect, + useCallback, + useLayoutEffect, + useMemo, +} from 'react'; +import { createPortal } from 'react-dom'; +import type { Destination } from '../../hooks/useTravelDestinations'; +import { MapPinIcon } from './icons/MapPinIcon'; +import { ChevronIcon } from './icons/ChevronIcon'; + +interface DestinationDropdownProps { + destinations: Destination[]; + loading: boolean; + onSelect: (slug: string, label: string) => void; + placeholder?: string; +} + +export function DestinationDropdown({ + destinations, + loading, + onSelect, + placeholder = 'Select destination...', +}: DestinationDropdownProps) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(''); + const [activeIndex, setActiveIndex] = useState(-1); + const containerRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + const [pos, setPos] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); + + const filtered = useMemo(() => { + if (!filter) return destinations; + const lower = filter.toLowerCase(); + return destinations.filter( + (d) => + d.name.toLowerCase().includes(lower) || + d.city?.toLowerCase().includes(lower), + ); + }, [destinations, filter]); + + // Position the dropdown portal + const updatePos = useCallback(() => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); + }, []); + + useLayoutEffect(() => { + if (!open) return; + updatePos(); + window.addEventListener('scroll', updatePos, true); + window.addEventListener('resize', updatePos); + return () => { + window.removeEventListener('scroll', updatePos, true); + window.removeEventListener('resize', updatePos); + }; + }, [open, updatePos]); + + // Close on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + setFilter(''); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + // Scroll active item into view + useEffect(() => { + if (activeIndex < 0 || !listRef.current) return; + const item = listRef.current.children[activeIndex] as HTMLElement; + item?.scrollIntoView({ block: 'nearest' }); + }, [activeIndex]); + + const handleSelect = useCallback( + (dest: Destination) => { + onSelect(dest.slug, dest.name); + setOpen(false); + setFilter(''); + setActiveIndex(-1); + }, + [onSelect], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((prev) => + prev < filtered.length - 1 ? prev + 1 : prev, + ); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < filtered.length) { + handleSelect(filtered[activeIndex]); + } + } else if (e.key === 'Escape') { + setOpen(false); + setFilter(''); + } + }, + [filtered, activeIndex, handleSelect], + ); + + const handleOpen = useCallback(() => { + setOpen(true); + setActiveIndex(-1); + // Focus input after opening + requestAnimationFrame(() => inputRef.current?.focus()); + }, []); + + const dropdown = open && ( +
+ {/* Filter input */} +
+ { + setFilter(e.target.value); + setActiveIndex(-1); + }} + onKeyDown={handleKeyDown} + placeholder="Type to filter..." + className="w-full px-2 py-1 text-xs rounded border border-warm-200 dark:border-warm-600 bg-warm-50 dark:bg-warm-900 text-navy-950 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-1 focus:ring-teal-400" + /> +
+ + {/* Results list */} +
+ {filtered.length === 0 ? ( +
+ {loading ? 'Loading...' : 'No destinations found'} +
+ ) : ( + filtered.map((dest, idx) => ( + + )) + )} +
+
+ ); + + return ( +
+ + + {open && createPortal(dropdown, document.body)} +
+ ); +} diff --git a/frontend/src/components/ui/FeatureIcons.tsx b/frontend/src/components/ui/FeatureIcons.tsx index 564294e..a7e8dd2 100644 --- a/frontend/src/components/ui/FeatureIcons.tsx +++ b/frontend/src/components/ui/FeatureIcons.tsx @@ -28,7 +28,7 @@ export function FeatureActions({ )} onTogglePin(feature.name)} - title={isPinned ? 'Unpin color view' : 'Color map by this feature'} + title={isPinned ? 'Unpin colour view' : 'Colour map by this feature'} active={isPinned} size="md" > diff --git a/frontend/src/index.html b/frontend/src/index.html index 6554cd6..6e75ff7 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -3,15 +3,21 @@ - + + Perfect Postcode — Every neighbourhood in England diff --git a/pipeline/transform/join_epc_pp.py b/pipeline/transform/join_epc_pp.py index a861179..0859ebc 100644 --- a/pipeline/transform/join_epc_pp.py +++ b/pipeline/transform/join_epc_pp.py @@ -84,7 +84,7 @@ def main(): & pl.col("_prev_rooms").is_not_null() & (pl.col("NUMBER_HABITABLE_ROOMS") != pl.col("_prev_rooms")) ) - .then(pl.lit("Remodeling")) + .then(pl.lit("Remodelling")) .when( pl.col("TOTAL_FLOOR_AREA").is_not_null() & pl.col("_prev_area").is_not_null() diff --git a/pipeline/transform/merge.py b/pipeline/transform/merge.py index 3ff9c81..95900a4 100644 --- a/pipeline/transform/merge.py +++ b/pipeline/transform/merge.py @@ -21,7 +21,8 @@ _AREA_COLUMNS = [ "Indoors Sub-domain Score", "Outdoors Sub-domain Score", # Ethnicity - "% Asian", + "% South Asian", + "% East Asian", "% Black", "% Mixed", "% White", @@ -49,7 +50,8 @@ _AREA_COLUMNS = [ "Number of restaurants within 2km", "Number of grocery shops and supermarkets within 2km", "Number of parks within 2km", - "Number of public transport stations within 2km", + "Train or tube stations within 1km", + "Distance to nearest train or tube station (km)", # Environment "Noise (dB)", "Max available download speed (Mbps)", @@ -298,7 +300,7 @@ def _build( "pp_address": "Address per Property Register", "epc_address": "Address per EPC", "postcode": "Postcode", - "duration": "Leashold/Freehold", + "duration": "Leasehold/Freehold", "current_energy_rating": "Current energy rating", "potential_energy_rating": "Potential energy rating", "total_floor_area": "Total floor area (sqm)", @@ -306,7 +308,8 @@ def _build( "restaurants_2km": "Number of restaurants within 2km", "groceries_2km": "Number of grocery shops and supermarkets within 2km", "parks_2km": "Number of parks within 2km", - "public_transport_2km": "Number of public transport stations within 2km", + "train_tube_1km": "Train or tube stations within 1km", + "train_tube_nearest_km": "Distance to nearest train or tube station (km)", "latest_price": "Last known price", "number_habitable_rooms": "Number of bedrooms & living rooms", "noise_lden_db": "Noise (dB)", diff --git a/r5-java/run.sh b/r5-java/run.sh index a22ae9c..ca89cee 100755 --- a/r5-java/run.sh +++ b/r5-java/run.sh @@ -22,8 +22,8 @@ set -euo pipefail # --demo only compute Bank + TCR, transit only (quick test) # --- Defaults --- -THREADS=4 -HEAP=12g +THREADS=8 +HEAP=16g NETWORK_DIR=property-data/r5-network OUTPUT_BASE=property-data/travel-times R5_DIR=r5-java diff --git a/server-rs/src/data/travel_time.rs b/server-rs/src/data/travel_time.rs index e7ba243..b0d43a1 100644 --- a/server-rs/src/data/travel_time.rs +++ b/server-rs/src/data/travel_time.rs @@ -8,11 +8,13 @@ use polars::lazy::frame::LazyFrame; use rustc_hash::{FxHashMap, FxHashSet}; use tracing::info; -/// Per-postcode travel time data: median and optional best-case (transit only). -#[derive(Clone, Copy)] +/// Per-postcode travel time data: median, optional best-case (transit only), +/// and optional journey instructions (JSON leg array, transit only with --paths). +#[derive(Clone)] pub struct TravelDataRow { pub minutes: i16, pub best_minutes: Option, + pub journey: Option>, } /// Cached postcode → travel time data for a single destination file. @@ -198,17 +200,26 @@ impl TravelTimeStore { .column("best_minutes") .ok() .map(|col| col.i16().expect("'best_minutes' is not i16")); + let journeys = df + .column("journey") + .ok() + .map(|col| col.str().expect("'journey' is not string")); let mut map = FxHashMap::default(); map.reserve(df.height()); for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() { if let (Some(pc), Some(min)) = (pc, min) { let best_min = best.as_ref().and_then(|b| b.get(i)); + let journey = journeys + .as_ref() + .and_then(|j| j.get(i)) + .map(Arc::from); map.insert( pc.to_string(), TravelDataRow { minutes: min, best_minutes: best_min, + journey, }, ); } diff --git a/server-rs/src/routes/hexagon_stats.rs b/server-rs/src/routes/hexagon_stats.rs index c36785b..7b043b1 100644 --- a/server-rs/src/routes/hexagon_stats.rs +++ b/server-rs/src/routes/hexagon_stats.rs @@ -71,6 +71,10 @@ pub struct HexagonStatsParams { /// Comma-separated feature names to include in stats response. /// Only listed features are computed; if absent or empty, no features are returned. pub fields: Option, + /// When set (with journey_slug), pick central_postcode as the postcode with the + /// shortest travel time for this mode+slug (so it has journey data). + pub journey_mode: Option, + pub journey_slug: Option, } pub async fn get_hexagon_stats( @@ -107,6 +111,17 @@ pub async fn get_hexagon_stats( let (fields_specified, field_set) = parse_field_set(params.fields.as_deref()); + // Load travel time data for central_postcode selection (if requested) + let journey_travel_data = match (¶ms.journey_mode, ¶ms.journey_slug) { + (Some(mode), Some(slug)) if state.travel_time_store.has_destination(mode, slug) => { + state + .travel_time_store + .get(mode, slug) + .ok() + } + _ => None, + }; + let response = tokio::task::spawn_blocking(move || { let start_time = std::time::Instant::now(); let precomputed = &state.h3_cells; @@ -138,27 +153,58 @@ pub async fn get_hexagon_stats( let total_count = matching_rows.len(); - // Find the postcode of the property closest to the hexagon center + // Pick central_postcode: prefer the postcode with the shortest travel time + // for the requested journey destination (so it has journey data). Fall back + // to geographic proximity to the hexagon center. let central_postcode = if !matching_rows.is_empty() { - let center: h3o::LatLng = cell.into(); - let center_lat = center.lat() as f32; - let center_lon = center.lng() as f32; - let closest_row = matching_rows - .iter() - .copied() - .min_by(|&a, &b| { - let da_lat = state.data.lat[a] - center_lat; - let da_lon = state.data.lon[a] - center_lon; - let db_lat = state.data.lat[b] - center_lat; - let db_lon = state.data.lon[b] - center_lon; - let dist_a = da_lat * da_lat + da_lon * da_lon; - let dist_b = db_lat * db_lat + db_lon * db_lon; - dist_a - .partial_cmp(&dist_b) - .unwrap_or(std::cmp::Ordering::Equal) - }) - .expect("matching_rows is non-empty"); - Some(state.data.postcode(closest_row).to_string()) + if let Some(ref travel_data) = journey_travel_data { + // Find the row with the shortest travel time in the travel data + let best_row = matching_rows + .iter() + .copied() + .filter_map(|row| { + let pc = state.data.postcode(row); + travel_data.get(pc).map(|td| (row, td.minutes)) + }) + .min_by_key(|&(_, mins)| mins) + .map(|(row, _)| row); + + // Fall back to geographic center if no row has travel data + let row = best_row.unwrap_or_else(|| { + let center: h3o::LatLng = cell.into(); + let center_lat = center.lat() as f32; + let center_lon = center.lng() as f32; + matching_rows + .iter() + .copied() + .min_by(|&a, &b| { + let da = (state.data.lat[a] - center_lat).powi(2) + + (state.data.lon[a] - center_lon).powi(2); + let db = (state.data.lat[b] - center_lat).powi(2) + + (state.data.lon[b] - center_lon).powi(2); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + .expect("matching_rows is non-empty") + }); + Some(state.data.postcode(row).to_string()) + } else { + // No journey destination requested — use geographic center + let center: h3o::LatLng = cell.into(); + let center_lat = center.lat() as f32; + let center_lon = center.lng() as f32; + let closest_row = matching_rows + .iter() + .copied() + .min_by(|&a, &b| { + let da = (state.data.lat[a] - center_lat).powi(2) + + (state.data.lon[a] - center_lon).powi(2); + let db = (state.data.lat[b] - center_lat).powi(2) + + (state.data.lon[b] - center_lon).powi(2); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + .expect("matching_rows is non-empty"); + Some(state.data.postcode(closest_row).to_string()) + } } else { None }; diff --git a/server-rs/src/routes/invites.rs b/server-rs/src/routes/invites.rs index 8e32e2d..6fd42e1 100644 --- a/server-rs/src/routes/invites.rs +++ b/server-rs/src/routes/invites.rs @@ -25,6 +25,12 @@ struct InviteValidation { used: bool, } +#[derive(Deserialize)] +pub struct CreateInviteRequest { + /// Admins can explicitly choose "admin" or "referral". Ignored for non-admins. + invite_type: Option, +} + #[derive(Deserialize)] pub struct RedeemRequest { code: String, @@ -66,12 +72,12 @@ fn generate_invite_code() -> String { chars.into_iter().collect() } -/// Create an invite. Admins create "admin" invites (free license). -/// Licensed non-admin users create "referral" invites (30% off). +/// Create an invite. Admins create "admin" invites (free license) by default, +/// but can explicitly request "referral" type. Licensed non-admin users always create "referral" invites (30% off). pub async fn post_invites( state: Arc, Extension(user): Extension, - _body: Json, + Json(body): Json, ) -> Response { let user = match user.0 { Some(u) => u, @@ -79,7 +85,10 @@ pub async fn post_invites( }; let invite_type = if user.is_admin { - "admin" + match body.invite_type.as_deref() { + Some("referral") => "referral", + _ => "admin", + } } else if user.subscription == "licensed" { "referral" } else { diff --git a/server-rs/src/routes/journey.rs b/server-rs/src/routes/journey.rs new file mode 100644 index 0000000..835e9ff --- /dev/null +++ b/server-rs/src/routes/journey.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use axum::http::StatusCode; +use axum::response::Json; +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; + +#[derive(Deserialize)] +pub struct JourneyQuery { + postcode: String, + mode: String, + slug: String, +} + +#[derive(Serialize)] +pub struct JourneyResponse { + /// Raw JSON array of journey legs, or null if no journey data available. + journey: Option, + /// Median (50th percentile) total travel time in minutes. + minutes: Option, + /// Best-case (5th percentile) total travel time in minutes (transit only). + best_minutes: Option, +} + +pub async fn get_journey( + state: Arc, + query: axum::extract::Query, +) -> Result, (StatusCode, String)> { + let store = &state.travel_time_store; + + if !store.has_destination(&query.mode, &query.slug) { + return Err(( + StatusCode::NOT_FOUND, + format!("No travel data for mode={} slug={}", query.mode, query.slug), + )); + } + + let travel_data = store.get(&query.mode, &query.slug).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to load travel data: {e}"), + ) + })?; + + let row = travel_data.get(&query.postcode); + let journey = row + .and_then(|r| r.journey.as_ref()) + .and_then(|j| serde_json::from_str::(j).ok()); + let minutes = row.map(|r| r.minutes); + let best_minutes = row.and_then(|r| r.best_minutes); + + Ok(Json(JourneyResponse { + journey, + minutes, + best_minutes, + })) +} diff --git a/server-rs/src/routes/rightmove_typeahead.rs b/server-rs/src/routes/rightmove_typeahead.rs deleted file mode 100644 index a241290..0000000 --- a/server-rs/src/routes/rightmove_typeahead.rs +++ /dev/null @@ -1,83 +0,0 @@ -use std::sync::Arc; - -use axum::extract::Query; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Json}; -use serde::{Deserialize, Serialize}; -use tracing::warn; - -use crate::state::AppState; - -const TYPEAHEAD_URL: &str = "https://los.rightmove.co.uk/typeahead"; - -#[derive(Deserialize)] -pub struct TypeaheadParams { - pub postcode: String, -} - -#[derive(Serialize)] -pub struct TypeaheadResponse { - pub location_identifier: String, -} - -#[derive(Deserialize)] -struct RightmoveMatch { - #[serde(rename = "type")] - match_type: String, - #[serde(rename = "displayName")] - display_name: String, - id: serde_json::Value, -} - -#[derive(Deserialize)] -struct RightmoveTypeaheadResponse { - matches: Vec, -} - -pub async fn get_rightmove_typeahead( - state: Arc, - Query(params): Query, -) -> Result, axum::response::Response> { - let postcode = params.postcode.trim().to_uppercase(); - - let resp = state - .http_client - .get(TYPEAHEAD_URL) - .query(&[("query", &postcode), ("limit", &"10".to_string())]) - .send() - .await - .map_err(|err| { - warn!(error = %err, "Rightmove typeahead request failed"); - (StatusCode::BAD_GATEWAY, "Rightmove typeahead unavailable").into_response() - })?; - - let data: RightmoveTypeaheadResponse = resp.json().await.map_err(|err| { - warn!(error = %err, "Failed to parse Rightmove typeahead response"); - (StatusCode::BAD_GATEWAY, "Invalid typeahead response").into_response() - })?; - - // Look for POSTCODE match first, then OUTCODE - for match_type in &["POSTCODE", "OUTCODE"] { - for m in &data.matches { - if m.match_type == *match_type - && m.display_name.to_uppercase().replace(' ', "") - == postcode.replace(' ', "") - { - let id = match &m.id { - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::String(s) => s.clone(), - other => other.to_string(), - }; - return Ok(Json(TypeaheadResponse { - location_identifier: format!("{}^{}", match_type, id), - })); - } - } - } - - Err(( - StatusCode::NOT_FOUND, - format!("No Rightmove location found for: {}", postcode), - ) - .into_response()) -} diff --git a/server-rs/src/routes/subscription.rs b/server-rs/src/routes/subscription.rs deleted file mode 100644 index 3a0db51..0000000 --- a/server-rs/src/routes/subscription.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::sync::Arc; - -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use axum::{Extension, Json}; -use serde::Deserialize; -use tracing::warn; - -use crate::auth::OptionalUser; -use crate::pocketbase::auth_superuser; -use crate::state::AppState; - -const VALID_SUBSCRIPTIONS: &[&str] = &["free", "licensed"]; - -#[derive(Deserialize)] -pub struct UpdateSubscriptionRequest { - subscription: String, -} - -pub async fn patch_subscription( - state: Arc, - Extension(user): Extension, - Json(req): Json, -) -> Response { - let user = match user.0 { - Some(u) => u, - None => return StatusCode::UNAUTHORIZED.into_response(), - }; - - if !user.is_admin { - return StatusCode::FORBIDDEN.into_response(); - } - - if !VALID_SUBSCRIPTIONS.contains(&req.subscription.as_str()) { - return ( - StatusCode::BAD_REQUEST, - format!("Invalid subscription: {}", req.subscription), - ) - .into_response(); - } - - let pb_url = state.pocketbase_url.trim_end_matches('/'); - - let token = match auth_superuser(&state.http_client, pb_url, &state.pocketbase_admin_email, &state.pocketbase_admin_password).await - { - Ok(t) => t, - Err(err) => { - warn!("Failed to authenticate as PocketBase superuser: {err}"); - return StatusCode::BAD_GATEWAY.into_response(); - } - }; - - let url = format!("{pb_url}/api/collections/users/records/{}", user.id); - let res = state - .http_client - .patch(&url) - .header("Authorization", format!("Bearer {token}")) - .json(&serde_json::json!({ "subscription": req.subscription })) - .send() - .await; - - match res { - Ok(resp) if resp.status().is_success() => { - state.token_cache.invalidate_by_user_id(&user.id); - StatusCode::OK.into_response() - } - Ok(resp) => { - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - warn!("PocketBase user update failed ({status}): {text}"); - StatusCode::BAD_GATEWAY.into_response() - } - Err(err) => { - warn!("PocketBase request error: {err}"); - StatusCode::BAD_GATEWAY.into_response() - } - } -} diff --git a/server-rs/src/routes/travel_destinations.rs b/server-rs/src/routes/travel_destinations.rs new file mode 100644 index 0000000..0796055 --- /dev/null +++ b/server-rs/src/routes/travel_destinations.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use axum::extract::Query; +use axum::http::StatusCode; +use axum::response::Json; +use serde::{Deserialize, Serialize}; +use tracing::info; + +use crate::data::slugify; +use crate::state::AppState; + +#[derive(Serialize)] +pub struct DestinationResult { + name: String, + slug: String, + place_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + city: Option, +} + +#[derive(Serialize)] +pub struct DestinationsResponse { + destinations: Vec, +} + +#[derive(Deserialize)] +pub struct DestinationsParams { + mode: String, +} + +pub async fn get_travel_destinations( + state: Arc, + Query(params): Query, +) -> Result, (StatusCode, String)> { + let mode = params.mode; + + let destinations = tokio::task::spawn_blocking(move || { + let t0 = std::time::Instant::now(); + let pd = &state.place_data; + let tt_store = &state.travel_time_store; + + let slug_set = match tt_store.destinations.get(&mode) { + Some(slugs) => slugs, + None => return Vec::new(), + }; + + // Find places that have travel time data for this mode + let mut matches: Vec<(usize, String, u8, u32, usize)> = pd + .name + .iter() + .enumerate() + .filter_map(|(idx, name)| { + let slug = slugify(name); + if slug_set.contains(&slug) { + Some((idx, slug, pd.type_rank[idx], pd.population[idx], name.len())) + } else { + None + } + }) + .collect(); + + // Sort: type rank asc, population desc, name length asc + matches.sort_unstable_by(|a, b| a.2.cmp(&b.2).then(b.3.cmp(&a.3)).then(a.4.cmp(&b.4))); + + let results: Vec = matches + .into_iter() + .map(|(idx, slug, ..)| DestinationResult { + name: pd.name[idx].clone(), + slug, + place_type: pd.place_type.get(idx).to_string(), + city: pd.city[idx].clone(), + }) + .collect(); + + let elapsed = t0.elapsed(); + info!( + mode = mode.as_str(), + results = results.len(), + ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0), + "GET /api/travel-destinations" + ); + + results + }) + .await + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; + + Ok(Json(DestinationsResponse { destinations })) +}