From 205302dbb8347dcfd373e8411388870305ec23e6 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 22 Feb 2026 11:13:39 +0000 Subject: [PATCH 1/7] better transit times --- Taskfile.yml | 9 --- .../src/components/map/FeatureBrowser.tsx | 17 ++++-- frontend/src/components/map/Filters.tsx | 4 +- .../src/components/map/TravelTimeCard.tsx | 17 +++++- .../src/components/ui/icons/BicycleIcon.tsx | 23 ++++++++ frontend/src/components/ui/icons/CarIcon.tsx | 22 +++++++ .../src/components/ui/icons/TransitIcon.tsx | 25 ++++++++ .../src/components/ui/icons/WalkingIcon.tsx | 23 ++++++++ frontend/src/components/ui/icons/index.ts | 4 ++ frontend/src/lib/api.ts | 2 +- r5-java/run.sh | 13 +++-- r5-java/src/main/java/propertymap/App.java | 31 ++++++---- .../src/main/java/propertymap/Parquet.java | 36 ++++++++++-- r5-java/src/main/java/propertymap/Router.java | 57 ++++++++++++------- server-rs/src/auth.rs | 3 + server-rs/src/consts.rs | 5 +- server-rs/src/main.rs | 3 +- server-rs/src/parsing/filters.rs | 10 ++-- server-rs/src/routes/export.rs | 4 +- server-rs/src/routes/hexagons.rs | 4 +- server-rs/src/routes/postcodes.rs | 2 +- server-rs/src/routes/pricing.rs | 2 +- 22 files changed, 247 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/ui/icons/BicycleIcon.tsx create mode 100644 frontend/src/components/ui/icons/CarIcon.tsx create mode 100644 frontend/src/components/ui/icons/TransitIcon.tsx create mode 100644 frontend/src/components/ui/icons/WalkingIcon.tsx diff --git a/Taskfile.yml b/Taskfile.yml index eb43540..834e34e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -16,11 +16,6 @@ tasks: cmds: - uv run python -m pipeline.download.map_assets --output frontend/public/assets - download:places: - desc: Extract place names from OSM PBF - cmds: - - uv run python -m pipeline.download.places --output ./property-data/places.parquet {{.CLI_ARGS}} - test: desc: Run all tests (Python and Rust) cmds: @@ -45,10 +40,6 @@ tasks: cmds: - docker compose up --build - - - - build:server: desc: Build server for production dir: server-rs diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index 8d2ca62..3cc449d 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -9,10 +9,18 @@ import { groupFeaturesByCategory } from '../../lib/features'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureLabel } from '../ui/FeatureLabel'; -import { RouteIcon, PlusIcon, EyeIcon } from '../ui/icons'; +import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon, EyeIcon } from '../ui/icons'; +import type { ComponentType } from 'react'; import { IconButton } from '../ui/IconButton'; import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; +const MODE_ICONS: Record> = { + car: CarIcon, + bicycle: BicycleIcon, + walking: WalkingIcon, + transit: TransitIcon, +}; + interface FeatureBrowserProps { availableFeatures: FeatureMeta[]; allFeatures: FeatureMeta[]; @@ -77,7 +85,7 @@ export default function FeatureBrowser({ name="Travel Time" expanded={isSearching || expandedGroups.has('Travel Time')} onToggle={() => toggleGroup('Travel Time')} - className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800" > {TRANSPORT_MODES.length} @@ -87,13 +95,14 @@ export default function FeatureBrowser({ const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug); const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null; const isPinned = fieldKey !== null && pinnedFeature === fieldKey; + const ModeIcon = MODE_ICONS[mode]; return (
onAddTravelTimeEntry(mode)}> - +
{MODE_LABELS[mode]} @@ -131,7 +140,7 @@ export default function FeatureBrowser({ name={group.name} expanded={isExpanded} onToggle={() => toggleGroup(group.name)} - className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800" > {group.features.length} diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 57c06db..730e8dd 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -254,7 +254,7 @@ export default memo(function Filters({ name="Travel Time" expanded={!collapsedGroups.has('Travel Time')} onToggle={() => toggleGroup('Travel Time')} - className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800" > {travelTimeEntries.length} @@ -296,7 +296,7 @@ export default memo(function Filters({ name={group.name} expanded={isExpanded} onToggle={() => toggleGroup(group.name)} - className="px-3 py-1.5 text-xs font-bold text-warm-500 bg-warm-50 dark:bg-navy-950 dark:text-warm-400 sticky top-0 hover:bg-warm-100 dark:hover:bg-warm-800" + className="px-3 py-1.5 text-xs font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 hover:bg-warm-200 dark:hover:bg-warm-800" > {group.features.length} diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 424dd98..ce99a62 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -5,10 +5,21 @@ import { PlaceSearchInput } from '../ui/PlaceSearchInput'; import { CloseIcon } from '../ui/icons/CloseIcon'; import { EyeIcon } from '../ui/icons/EyeIcon'; import { MapPinIcon } from '../ui/icons/MapPinIcon'; -import { RouteIcon } from '../ui/icons/RouteIcon'; +import { CarIcon } from '../ui/icons/CarIcon'; +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 { MODE_LABELS, type TransportMode } from '../../hooks/useTravelTime'; +import type { ComponentType } from 'react'; + +const MODE_ICONS: Record> = { + car: CarIcon, + bicycle: BicycleIcon, + walking: WalkingIcon, + transit: TransitIcon, +}; interface TravelTimeCardProps { mode: TransportMode; @@ -63,12 +74,14 @@ export function TravelTimeCard({ const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120; const displayRange = timeRange ?? [sliderMin, sliderMax]; + const ModeIcon = MODE_ICONS[mode]; + return (
{/* Header */}
- + Travel Time ({MODE_LABELS[mode]}) diff --git a/frontend/src/components/ui/icons/BicycleIcon.tsx b/frontend/src/components/ui/icons/BicycleIcon.tsx new file mode 100644 index 0000000..6bdd9f4 --- /dev/null +++ b/frontend/src/components/ui/icons/BicycleIcon.tsx @@ -0,0 +1,23 @@ +interface IconProps { + className?: string; +} + +export function BicycleIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + + + + ); +} diff --git a/frontend/src/components/ui/icons/CarIcon.tsx b/frontend/src/components/ui/icons/CarIcon.tsx new file mode 100644 index 0000000..8f93318 --- /dev/null +++ b/frontend/src/components/ui/icons/CarIcon.tsx @@ -0,0 +1,22 @@ +interface IconProps { + className?: string; +} + +export function CarIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + + + ); +} diff --git a/frontend/src/components/ui/icons/TransitIcon.tsx b/frontend/src/components/ui/icons/TransitIcon.tsx new file mode 100644 index 0000000..eddbfe0 --- /dev/null +++ b/frontend/src/components/ui/icons/TransitIcon.tsx @@ -0,0 +1,25 @@ +interface IconProps { + className?: string; +} + +export function TransitIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + + + + + + ); +} diff --git a/frontend/src/components/ui/icons/WalkingIcon.tsx b/frontend/src/components/ui/icons/WalkingIcon.tsx new file mode 100644 index 0000000..b2fcdea --- /dev/null +++ b/frontend/src/components/ui/icons/WalkingIcon.tsx @@ -0,0 +1,23 @@ +interface IconProps { + className?: string; +} + +export function WalkingIcon({ className = 'w-4 h-4' }: IconProps) { + return ( + + + + + + + + ); +} diff --git a/frontend/src/components/ui/icons/index.ts b/frontend/src/components/ui/icons/index.ts index ce62f26..bf79bbd 100644 --- a/frontend/src/components/ui/icons/index.ts +++ b/frontend/src/components/ui/icons/index.ts @@ -7,3 +7,7 @@ export { FilterIcon } from './FilterIcon'; export { LightbulbIcon } from './LightbulbIcon'; export { MenuIcon } from './MenuIcon'; export { RouteIcon } from './RouteIcon'; +export { CarIcon } from './CarIcon'; +export { BicycleIcon } from './BicycleIcon'; +export { WalkingIcon } from './WalkingIcon'; +export { TransitIcon } from './TransitIcon'; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 20956da..ca53331 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -84,5 +84,5 @@ export function buildFilterString(filters: FeatureFilters, features: FeatureMeta const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max); return `${name}:${min}:${maxStr}`; }) - .join(','); + .join(';;'); } diff --git a/r5-java/run.sh b/r5-java/run.sh index 3382934..968977a 100755 --- a/r5-java/run.sh +++ b/r5-java/run.sh @@ -15,7 +15,7 @@ set -euo pipefail # # Usage: # ./r5-java/run.sh -# ./r5-java/run.sh --threads 8 --heap 24g +# ./r5-java/run.sh --threads 8 --heap 24g --output-dir property-data/travel-times # --- Defaults --- THREADS=16 @@ -30,6 +30,7 @@ while [[ $# -gt 0 ]]; do --threads) THREADS="$2"; shift 2 ;; --heap) HEAP="$2"; shift 2 ;; --network-dir) NETWORK_DIR="$2"; shift 2 ;; + --output-dir) OUTPUT_BASE="$2"; shift 2 ;; *) echo "Unknown: $1"; exit 1 ;; esac done @@ -75,7 +76,7 @@ fi if [ ! -f "$DUCKDB_JAR" ]; then echo "--- Downloading DuckDB JDBC ---" - curl -fL -o "$DUCKDB_JAR" https://repo1.maven.org/maven2/org/duckdb/duckdb_jdbc/1.0.0/duckdb_jdbc-1.0.0.jar + curl -fL -o "$DUCKDB_JAR" https://repo1.maven.org/maven2/org/duckdb/duckdb_jdbc/1.4.4.0/duckdb_jdbc-1.4.4.0.jar fi # --- Step 3: Compile Java source --- @@ -107,8 +108,12 @@ if [ ! -f "$NETWORK_DIR/network.dat" ]; then BUILD_DIR="$NETWORK_DIR/build" echo "--- No cached network — copying transit data to build dir ---" mkdir -p "$BUILD_DIR" - cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null || true - cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null || true + if ! cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then + echo "Warning: no .osm.pbf files found in property-data/transit/raw/" + fi + if ! cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null; then + echo "Warning: no .zip files found in property-data/transit/" + fi DATA_DIR="$BUILD_DIR" fi diff --git a/r5-java/src/main/java/propertymap/App.java b/r5-java/src/main/java/propertymap/App.java index 1c765f6..f00935a 100644 --- a/r5-java/src/main/java/propertymap/App.java +++ b/r5-java/src/main/java/propertymap/App.java @@ -9,6 +9,7 @@ import java.nio.file.Paths; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -24,7 +25,8 @@ import java.util.concurrent.atomic.AtomicInteger; * postcodes are written (unreachable = absent from file). * * Output per mode: one parquet file per origin in {output-dir}/{mode}/{name}.parquet - * with columns (pcds VARCHAR, travel_minutes SMALLINT). + * with columns (pcds VARCHAR, travel_minutes SMALLINT). Transit mode additionally + * includes a best_minutes SMALLINT column (5th percentile = best-case departure timing). */ public class App { @@ -117,15 +119,14 @@ public class App { c, total, rate, etaH, failed.get()); }, 2, 2, TimeUnit.SECONDS); - // Submit all work, wait for completion via CountDownLatch-like pattern - java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(remaining.size()); + CountDownLatch latch = new CountDownLatch(remaining.size()); for (int idx : remaining) { pool.submit(() -> { try { processOrigin(network, postcodes, postcodeLats, postcodeLons, originLats[idx], originLons[idx], - modeDir, mode, date, originNames[idx], threadConn.get()); + modeDir, mode, date, idx, originNames[idx], threadConn.get()); completed.incrementAndGet(); } catch (Exception e) { failed.incrementAndGet(); @@ -138,6 +139,7 @@ public class App { latch.await(); reporter.shutdown(); + reporter.awaitTermination(5, TimeUnit.SECONDS); double elapsedH = (System.currentTimeMillis() - startMs) / 3_600_000.0; int n = completed.get(); @@ -150,10 +152,10 @@ public class App { TransportNetwork network, String[] postcodes, double[] postcodeLats, double[] postcodeLons, double originLat, double originLon, - Path modeDir, String mode, LocalDate date, String name, + Path modeDir, String mode, LocalDate date, int index, String name, DuckDBConnection conn) throws Exception { - Path outPath = modeDir.resolve(sanitizeFilename(name) + ".parquet"); + Path outPath = modeDir.resolve(originFilename(index, name)); Exception lastError = null; for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { @@ -168,16 +170,22 @@ public class App { String[] codes = new String[reachable]; short[] times = new short[reachable]; + short[] bestTimes = result.bestTimes() != null ? new short[reachable] : null; int j = 0; for (int i = 0; i < result.times().length; i++) { if (result.times()[i] >= 0) { codes[j] = postcodes[result.originalIndices()[i]]; times[j] = result.times()[i]; + if (bestTimes != null) bestTimes[j] = result.bestTimes()[i]; j++; } } - Parquet.writeTravelTimes(conn, outPath, codes, times); + if (bestTimes != null) { + Parquet.writeTransitTravelTimes(conn, outPath, codes, times, bestTimes); + } else { + Parquet.writeTravelTimes(conn, outPath, codes, times); + } return; } catch (Exception e) { lastError = e; @@ -194,7 +202,7 @@ public class App { private static List findRemaining(Path modeDir, String[] names) throws Exception { List remaining = new ArrayList<>(); for (int i = 0; i < names.length; i++) { - Path f = modeDir.resolve(sanitizeFilename(names[i]) + ".parquet"); + Path f = modeDir.resolve(originFilename(i, names[i])); if (!Files.exists(f) || Files.size(f) == 0) { remaining.add(i); } @@ -202,11 +210,12 @@ public class App { return remaining; } - /** Sanitize a place name into a safe filename (lowercase, spaces to hyphens, strip non-alphanumeric). */ - private static String sanitizeFilename(String name) { - return name.toLowerCase() + /** Build a filename from index + place name (index prefix prevents collisions after sanitization). */ + private static String originFilename(int index, String name) { + String safe = name.toLowerCase() .replaceAll("[^a-z0-9 -]", "") .replaceAll("\\s+", "-"); + return String.format("%04d-%s.parquet", index, safe); } private static String requiredArg(String[] args, String name) { diff --git a/r5-java/src/main/java/propertymap/Parquet.java b/r5-java/src/main/java/propertymap/Parquet.java index e8be1d7..615566c 100644 --- a/r5-java/src/main/java/propertymap/Parquet.java +++ b/r5-java/src/main/java/propertymap/Parquet.java @@ -23,11 +23,16 @@ public class Parquet { catch (ClassNotFoundException e) { throw new RuntimeException(e); } } + /** Escape a file path for safe interpolation into DuckDB SQL (double single quotes). */ + private static String escapePath(String path) { + return path.replace("'", "''"); + } + /** Load England postcodes, write reference parquet, return codes + flat lat/lon arrays. */ static Postcodes loadEnglandPostcodes(String parquetPath, Path refOut) throws Exception { try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) { stmt.execute("CREATE TABLE postcodes AS SELECT pcds, lat, \"long\" FROM read_parquet('" - + parquetPath + "') WHERE ctry = 'E92000001' AND doterm IS NULL"); + + escapePath(parquetPath) + "') WHERE ctry = 'E92000001' AND doterm IS NULL"); copyToParquet(stmt, "SELECT * FROM postcodes", refOut); try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM postcodes")) { @@ -56,7 +61,7 @@ public class Parquet { try (DuckDBConnection conn = connect(); Statement stmt = conn.createStatement()) { stmt.execute("CREATE TABLE places AS SELECT * EXCLUDE (rn) FROM (" + "SELECT *, ROW_NUMBER() OVER (PARTITION BY lat, lon) AS rn " - + "FROM read_parquet('" + parquetPath + "')) WHERE rn = 1"); + + "FROM read_parquet('" + escapePath(parquetPath) + "')) WHERE rn = 1"); copyToParquet(stmt, "SELECT * FROM places", refOut); try (ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM places")) { @@ -97,7 +102,30 @@ public class Parquet { } } try (Statement stmt = conn.createStatement()) { - stmt.execute("COPY t TO '" + tmp.toAbsolutePath() + "' (FORMAT PARQUET, COMPRESSION ZSTD)"); + stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD)"); + } + Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + + /** Write transit travel times with both median and best-case columns. */ + static void writeTransitTravelTimes(DuckDBConnection conn, Path outPath, + String[] postcodes, short[] times, short[] bestTimes) throws Exception { + Path tmp = outPath.resolveSibling(outPath.getFileName() + ".tmp"); + try (Statement stmt = conn.createStatement()) { + stmt.execute("DROP TABLE IF EXISTS t"); + stmt.execute("CREATE TABLE t (pcds VARCHAR, travel_minutes SMALLINT, best_minutes SMALLINT)"); + } + try (DuckDBAppender appender = conn.createAppender("main", "t")) { + for (int i = 0; i < postcodes.length; i++) { + appender.beginRow(); + appender.append(postcodes[i]); + appender.append(times[i]); + appender.append(bestTimes[i]); + appender.endRow(); + } + } + try (Statement stmt = conn.createStatement()) { + stmt.execute("COPY t TO '" + escapePath(tmp.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD)"); } Files.move(tmp, outPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } @@ -108,7 +136,7 @@ public class Parquet { } private static void copyToParquet(Statement stmt, String query, Path outPath) throws Exception { - stmt.execute("COPY (" + query + ") TO '" + outPath.toAbsolutePath() + stmt.execute("COPY (" + query + ") TO '" + escapePath(outPath.toAbsolutePath().toString()) + "' (FORMAT PARQUET, COMPRESSION ZSTD)"); } } diff --git a/r5-java/src/main/java/propertymap/Router.java b/r5-java/src/main/java/propertymap/Router.java index 2418005..108f4ae 100644 --- a/r5-java/src/main/java/propertymap/Router.java +++ b/r5-java/src/main/java/propertymap/Router.java @@ -25,11 +25,14 @@ public class Router { private static final int ZOOM = 9; // R5 enforces range 9-12 private static final int MAX_GRID_CELLS = 4_900_000; // under R5's 5M limit + private static final int DEPARTURE_FROM_TIME = 7 * 3600; // 07:00 + private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00 + private static final int MAX_TRIP_DURATION_MINUTES = 120; /** Result of computing travel times for a single origin with spatial pre-filtering. */ - record FilteredResult(int[] originalIndices, short[] times) {} + record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {} - /** Max plausible travel radius in km for 120-minute trips. */ + /** Max plausible travel radius in km for {@link #MAX_TRIP_DURATION_MINUTES}-minute trips. */ static double maxRadiusKm(String mode) { return switch (mode) { case "car" -> 150; @@ -40,7 +43,10 @@ public class Router { }; } - /** Load or build the transport network with Kryo caching. */ + /** + * Load or build the transport network with Kryo caching. + * The returned network is read-only after buildDistanceTables — safe for concurrent use. + */ static TransportNetwork loadNetwork(String dataDir, String cacheDir) throws Exception { System.err.println("Loading transport network..."); File cacheFile = new File(cacheDir, "network.dat"); @@ -78,7 +84,7 @@ public class Router { // 1. Filter destinations by bounding box int[] filtered = filterByDistance(allLats, allLons, originLat, originLon, maxRadius); if (filtered.length == 0) { - return new FilteredResult(new int[0], new short[0]); + return new FilteredResult(new int[0], new short[0], null); } // 2. Extract filtered coordinate arrays @@ -93,9 +99,14 @@ public class Router { List chunks = buildDestinationChunks(fLats, fLons); // 4. Compute travel times - short[] times = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date); + boolean isTransit = mode.equals("transit"); + short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date); - return new FilteredResult(filtered, times); + // For transit: allTimes[0]=best (5th percentile), allTimes[1]=median (50th) + // For others: allTimes[0]=median (50th), no best + short[] medianTimes = isTransit ? allTimes[1] : allTimes[0]; + short[] bestTimes = isTransit ? allTimes[0] : null; + return new FilteredResult(filtered, medianTimes, bestTimes); } /** @@ -175,13 +186,18 @@ public class Router { return chunks; } - /** Compute travel times from one origin to all destinations across all chunks. */ - private static short[] computeTravelTimes( + /** + * Compute travel times from one origin to all destinations across all chunks. + * Returns one short[] per requested percentile (transit gets 2: best + median, others get 1: median). + */ + private static short[][] computeTravelTimes( TransportNetwork network, List chunks, double originLat, double originLon, String mode, int nDest, LocalDate date) { - short[] times = new short[nDest]; - Arrays.fill(times, (short) -1); + boolean isTransit = mode.equals("transit"); + int nPercentiles = isTransit ? 2 : 1; + short[][] allTimes = new short[nPercentiles][nDest]; + for (short[] arr : allTimes) Arrays.fill(arr, (short) -1); for (DestinationChunk chunk : chunks) { RegionalTask task = buildTask(chunk, originLat, originLon, mode, date); @@ -191,14 +207,16 @@ public class Router { TravelTimeResult tt = result.travelTimes; if (tt != null) { int[][] values = tt.getValues(); - for (int i = 0; i < chunk.originalIndices.length && i < values[0].length; i++) { - if (values[0][i] != Integer.MAX_VALUE) { - times[chunk.originalIndices[i]] = (short) values[0][i]; + for (int p = 0; p < nPercentiles && p < values.length; p++) { + for (int i = 0; i < chunk.originalIndices.length && i < values[p].length; i++) { + if (values[p][i] != Integer.MAX_VALUE) { + allTimes[p][chunk.originalIndices[i]] = (short) values[p][i]; + } } } } } - return times; + return allTimes; } // --- Private helpers --- @@ -241,7 +259,7 @@ public class Router { task.fromLat = originLat; task.fromLon = originLon; task.date = date; - task.percentiles = new int[]{50}; + task.percentiles = mode.equals("transit") ? new int[]{5, 50} : new int[]{50}; task.recordTimes = true; task.destinationPointSets = new PointSet[]{chunk.pointSet}; task.zoom = chunk.extents.zoom; @@ -249,9 +267,9 @@ public class Router { task.north = chunk.extents.north; task.width = chunk.extents.width; task.height = chunk.extents.height; - task.fromTime = 8 * 3600; - task.toTime = 8 * 3600 + 60; - task.maxTripDurationMinutes = 120; + task.fromTime = DEPARTURE_FROM_TIME; + task.toTime = DEPARTURE_TO_TIME; + task.maxTripDurationMinutes = MAX_TRIP_DURATION_MINUTES; configureMode(task, mode); return task; @@ -267,13 +285,14 @@ public class Router { task.accessModes = EnumSet.of(LegMode.WALK); task.egressModes = EnumSet.of(LegMode.WALK); task.directModes = EnumSet.of(LegMode.WALK); - task.transitModes = EnumSet.of(TransitModes.TRANSIT); + task.transitModes = EnumSet.allOf(TransitModes.class); } default -> throw new IllegalArgumentException("Unknown mode: " + mode); } } private static void setDirectMode(RegionalTask task, LegMode legMode) { + task.maxRides = 0; task.accessModes = EnumSet.of(legMode); task.egressModes = EnumSet.of(legMode); task.directModes = EnumSet.of(legMode); diff --git a/server-rs/src/auth.rs b/server-rs/src/auth.rs index f84b7a2..e513f44 100644 --- a/server-rs/src/auth.rs +++ b/server-rs/src/auth.rs @@ -95,6 +95,9 @@ async fn validate_token( .ok()?; if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + warn!("PocketBase auth-refresh returned {status}: {body}"); return None; } diff --git a/server-rs/src/consts.rs b/server-rs/src/consts.rs index 57d6c4e..a2fb46c 100644 --- a/server-rs/src/consts.rs +++ b/server-rs/src/consts.rs @@ -17,6 +17,9 @@ pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02; pub const AI_FILTERS_MAX_TOKENS: usize = 2000; pub const AI_FILTERS_TEMPERATURE: f32 = 0.0; +/// Timeout for outbound HTTP service calls (seconds). +pub const SERVICE_CALL_TIMEOUT: u64 = 120; + /// Inner London free zone bounds (south, west, north, east) — roughly zones 1–2. /// Users without a license can only query data within these bounds. pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.42, -0.34, 51.60, 0.14); @@ -24,5 +27,5 @@ pub const FREE_ZONE_BOUNDS: (f64, f64, f64, f64) = (51.42, -0.34, 51.60, 0.14); /// Homepage demo center (lat, lng). Unlicensed hexagon requests are allowed /// when the center of the requested bounds is within DEMO_CENTER_TOLERANCE of this point. /// Must match DEMO_VIEW_START in ScrollStory.tsx. -pub const DEMO_CENTER: (f64, f64) = (52.2, -1.9); +pub const DEMO_CENTER: (f64, f64) = (51.51, -0.12); pub const DEMO_CENTER_TOLERANCE: f64 = 1.0; diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index 09b9ea6..997c81f 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{bail, Context}; +use consts::SERVICE_CALL_TIMEOUT; use axum::middleware; use axum::routing::{any, get, patch, post}; use axum::Router; @@ -286,7 +287,7 @@ async fn main() -> anyhow::Result<()> { }; let http_client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(SERVICE_CALL_TIMEOUT)) .connect_timeout(Duration::from_secs(5)) .build() .context("Failed to build HTTP client")?; diff --git a/server-rs/src/parsing/filters.rs b/server-rs/src/parsing/filters.rs index 11500d4..c7d6bcd 100644 --- a/server-rs/src/parsing/filters.rs +++ b/server-rs/src/parsing/filters.rs @@ -17,7 +17,7 @@ pub struct ParsedEnumFilter { pub allowed: FxHashSet, } -/// Parse comma-separated filter string into numeric and enum filters. +/// Parse `;;`-separated filter string into numeric and enum filters. /// Numeric format: `name:min:max` /// Enum format: `name:val1|val2|val3` (pipe-separated string values) /// @@ -35,7 +35,7 @@ pub fn parse_filters( None => return Ok((numeric, enums)), }; - for entry in input.split(',') { + for entry in input.split(";;") { let parts: Vec<&str> = entry.splitn(2, ':').collect(); if parts.len() != 2 { return Err(format!("Malformed filter entry (missing ':'): '{entry}'")); @@ -234,7 +234,7 @@ mod tests { #[test] fn parse_multiple_numeric_filters() { let (numeric, _enums) = parse_filters( - Some("Price:100000:500000,Area:50:200"), + Some("Price:100000:500000;;Area:50:200"), &extended_feature_map(), &extended_enum_values(), ) @@ -248,7 +248,7 @@ mod tests { #[test] fn parse_mixed_filters() { let (numeric, enums) = parse_filters( - Some("Price:100000:500000,Type:Semi|Terraced"), + Some("Price:100000:500000;;Type:Semi|Terraced"), &extended_feature_map(), &extended_enum_values(), ) @@ -288,7 +288,7 @@ mod tests { #[test] fn parse_filter_with_whitespace() { let (numeric, enums) = parse_filters( - Some("Price : 100000 : 500000 , Type : Detached | Flats/Maisonettes"), + Some("Price : 100000 : 500000 ;; Type : Detached | Flats/Maisonettes"), &extended_feature_map(), &extended_enum_values(), ) diff --git a/server-rs/src/routes/export.rs b/server-rs/src/routes/export.rs index 706e3ea..3eb50cf 100644 --- a/server-rs/src/routes/export.rs +++ b/server-rs/src/routes/export.rs @@ -84,7 +84,7 @@ fn extract_filter_feature_names(filters_str: Option<&str>) -> Vec { None => return Vec::new(), }; let mut names = Vec::new(); - for entry in input.split(',') { + for entry in input.split(";;") { let parts: Vec<&str> = entry.splitn(2, ':').collect(); if parts.len() == 2 { let name = parts[0].trim().to_string(); @@ -110,7 +110,7 @@ fn build_frontend_params( ]; if let Some(fs) = filters_str { if !fs.is_empty() { - for entry in fs.split(',') { + for entry in fs.split(";;") { if !entry.is_empty() { parts.push(format!("filter={}", urlencoding::encode(entry.trim()))); } diff --git a/server-rs/src/routes/hexagons.rs b/server-rs/src/routes/hexagons.rs index dbe035e..2ee26b0 100644 --- a/server-rs/src/routes/hexagons.rs +++ b/server-rs/src/routes/hexagons.rs @@ -30,7 +30,7 @@ pub struct HexagonsResponse { pub struct HexagonParams { resolution: u8, bounds: Option, - /// Comma-separated filters: `name:min:max,...` + /// `;;`-separated filters: `name:min:max;;...` filters: Option, /// Comma-separated feature names to include in min/max aggregation. fields: Option, @@ -191,7 +191,7 @@ pub async fn get_hexagons( require_bounds(params.bounds).map_err(IntoResponse::into_response)?; // Allow the homepage demo: check if the center of the requested bounds - // is near the demo view center (52.2, -1.9). + // is near the demo view center (51.51, -0.12). let center_lat = (south + north) / 2.0; let center_lng = (west + east) / 2.0; let is_demo_view = (center_lat - DEMO_CENTER.0).abs() <= DEMO_CENTER_TOLERANCE diff --git a/server-rs/src/routes/postcodes.rs b/server-rs/src/routes/postcodes.rs index 75057c4..bde73fd 100644 --- a/server-rs/src/routes/postcodes.rs +++ b/server-rs/src/routes/postcodes.rs @@ -27,7 +27,7 @@ pub struct PostcodesResponse { #[derive(Deserialize)] pub struct PostcodeParams { bounds: Option, - /// Comma-separated filters: `name:min:max,...` + /// `;;`-separated filters: `name:min:max;;...` filters: Option, /// Comma-separated feature names to include in min/max aggregation. fields: Option, diff --git a/server-rs/src/routes/pricing.rs b/server-rs/src/routes/pricing.rs index fab073a..60be2f3 100644 --- a/server-rs/src/routes/pricing.rs +++ b/server-rs/src/routes/pricing.rs @@ -11,7 +11,7 @@ use crate::state::AppState; /// Pricing tiers: (cumulative user cap, price in pence). const TIERS: &[(u64, u64)] = &[ - (10, 0), // First 10 users: free + (1, 0), // First 10 users: free (20, 1000), // Next 10: £10 (45, 2500), // Next 25: £25 (95, 5000), // Next 50: £50 From eb02b5832b41d20f603b21edd0887878158a7bf7 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 22 Feb 2026 21:09:07 +0000 Subject: [PATCH 2/7] Lots of improvements --- README.md | 3 - frontend/src/App.tsx | 6 +- .../src/components/account/AccountPage.tsx | 44 +++--- frontend/src/components/learn/LearnPage.tsx | 8 +- frontend/src/components/map/AiFilterInput.tsx | 10 +- frontend/src/components/map/AreaPane.tsx | 5 + .../components/map/ExternalSearchLinks.tsx | 68 +++++++-- .../src/components/map/FeatureBrowser.tsx | 27 +++- frontend/src/components/map/Filters.tsx | 139 +++++++++++++----- frontend/src/components/map/HoverCard.tsx | 21 ++- frontend/src/components/map/Map.tsx | 1 + frontend/src/components/map/MapPage.tsx | 73 +++++---- .../src/components/map/TravelTimeCard.tsx | 22 +++ frontend/src/hooks/useDeckLayers.ts | 25 ++-- frontend/src/hooks/useFilters.ts | 2 + frontend/src/hooks/useTravelTime.ts | 16 +- frontend/src/hooks/useTutorial.ts | 9 +- frontend/src/lib/MarchingAntsExtension.ts | 2 +- frontend/src/lib/api.ts | 3 +- frontend/src/lib/clipboard.ts | 16 ++ frontend/src/lib/consts.ts | 5 +- frontend/src/lib/external-search.ts | 99 +++++++------ frontend/src/lib/url-state.ts | 13 +- frontend/src/types.ts | 1 + r5-java/run.sh | 23 +-- r5-java/src/main/java/propertymap/App.java | 5 +- r5-java/src/main/java/propertymap/Router.java | 36 +++-- server-rs/Cargo.lock | 16 ++ server-rs/Cargo.toml | 2 +- server-rs/src/data/places.rs | 21 +-- server-rs/src/data/travel_time.rs | 26 +++- server-rs/src/main.rs | 7 +- server-rs/src/pocketbase.rs | 61 ++++---- server-rs/src/routes.rs | 2 + server-rs/src/routes/hexagon_stats.rs | 28 ++++ server-rs/src/routes/hexagons.rs | 22 ++- server-rs/src/routes/pb_proxy.rs | 19 +-- server-rs/src/routes/postcode_stats.rs | 1 + server-rs/src/routes/rightmove_typeahead.rs | 83 +++++++++++ 39 files changed, 699 insertions(+), 271 deletions(-) create mode 100644 frontend/src/lib/clipboard.ts create mode 100644 server-rs/src/routes/rightmove_typeahead.rs diff --git a/README.md b/README.md index 4bdcb70..0c90747 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,6 @@ rm data/d29f0314840ef7dcbb5cde66e383fe08059dab5a.zip https://xploria.co.uk/data-sources/ - - - --- - stripe diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8d58db..0b45089 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -230,7 +230,7 @@ export default function App() { )} {showLicenseSuccess && ( - setShowLicenseSuccess(false)} /> + { setShowLicenseSuccess(false); navigateTo('dashboard'); }} /> )}
); diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index 2ec003f..d8f6c6b 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import type { AuthUser } from '../../hooks/useAuth'; import type { SavedSearch } from '../../hooks/useSavedSearches'; import { apiUrl, authHeaders, assertOk, shortenUrl } from '../../lib/api'; +import { copyToClipboard } from '../../lib/clipboard'; import { formatRelativeTime } from '../../lib/format'; import { summarizeParams } from '../../lib/url-state'; import { SpinnerIcon } from '../ui/icons/SpinnerIcon'; @@ -42,37 +43,24 @@ function SavedSearchesContent({ setDeleteConfirmId(null); }, [deleteConfirmId, onDelete]); - const copyToClipboard = useCallback((text: string, id: string) => { - const onSuccess = () => { + const doCopy = useCallback((text: string, id: string) => { + copyToClipboard(text, () => { setCopiedId(id); setTimeout(() => setCopiedId(null), 2000); - }; - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(text).then(onSuccess); - } else { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - onSuccess(); - } + }); }, []); const handleShare = useCallback(async (params: string, id: string) => { setSharingId(id); try { const shortUrl = await shortenUrl(params); - copyToClipboard(shortUrl, id); + doCopy(shortUrl, id); } catch { - copyToClipboard(`${window.location.origin}/?${params}`, id); + doCopy(`${window.location.origin}/?${params}`, id); } finally { setSharingId(null); } - }, [copyToClipboard]); + }, [doCopy]); return ( <> @@ -270,7 +258,7 @@ function SettingsContent({ const handleCopyInvite = () => { if (!inviteUrl) return; - navigator.clipboard.writeText(inviteUrl).then(() => { + copyToClipboard(inviteUrl, () => { setInviteCopied(true); setTimeout(() => setInviteCopied(false), 2000); }); @@ -284,7 +272,7 @@ function SettingsContent({ const isLicensed = user.subscription === 'licensed' || user.isAdmin; return ( -
+
{/* Email */}
@@ -455,6 +443,20 @@ function SettingsContent({
)}
+ + {/* Support */} +
+

Need help? Email us at

+ + support@propertymap.co.uk + +

+ We typically respond within 24 hours. +

+
); } diff --git a/frontend/src/components/learn/LearnPage.tsx b/frontend/src/components/learn/LearnPage.tsx index 6fc0917..03487b5 100644 --- a/frontend/src/components/learn/LearnPage.tsx +++ b/frontend/src/components/learn/LearnPage.tsx @@ -255,7 +255,7 @@ function FAQItemCard({ item }: { item: FAQItem }) { } export default function LearnPage() { - const [tab, setTab] = useState('data-sources'); + const [tab, setTab] = useState('faq'); const [highlightedId, setHighlightedId] = useState(null); const cardRefs = useRef>({}); const scrollContainerRef = useRef(null); @@ -299,12 +299,12 @@ export default function LearnPage() {
- + diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx index 9b6cf5c..3608d3a 100644 --- a/frontend/src/components/map/AiFilterInput.tsx +++ b/frontend/src/components/map/AiFilterInput.tsx @@ -23,24 +23,24 @@ export default memo(function AiFilterInput({ loading, error, notes, onSubmit }: return (
-
+ setQuery(e.target.value)} - placeholder="Describe your ideal property..." - className="flex-1 min-w-0 px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400" + placeholder="Describe your ideal property and area..." + className="w-full px-2.5 py-1.5 text-sm rounded-lg border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-2 focus:ring-teal-400" disabled={loading} />
diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index ba90d25..56bec78 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -109,6 +109,11 @@ export default function AreaPane({ {propertyCount.toLocaleString()} properties

)} +

+ Stats for {isPostcode ? 'current and historical' : 'all'} properties + in this {isPostcode ? 'postcode' : 'area'} + {Object.keys(filters).length > 0 ? ' matching all active filters' : ''} +

{!isPostcode && stats && ( + + + + + + +
)}
{infoFeature && ( diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 730e8dd..04cbe09 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -39,13 +39,15 @@ function SliderLabels({ max, value, displayValues, - absoluteMax, + isAtMin, + isAtMax, }: { min: number; max: number; value: [number, number]; displayValues?: [number, number]; - absoluteMax?: boolean; + isAtMin?: boolean; + isAtMax?: boolean; }) { const range = max - min || 1; const leftPct = ((value[0] - min) / range) * 100; @@ -57,13 +59,13 @@ function SliderLabels({ className="absolute -translate-x-1/2" style={{ left: `${leftPct}%` }} > - {formatFilterValue(labels[0])} + {isAtMin ? 'min' : formatFilterValue(labels[0])} - {formatFilterValue(labels[1])}{absoluteMax && value[1] >= max ? '+' : ''} + {isAtMax ? 'max' : formatFilterValue(labels[1])}
); @@ -92,10 +94,14 @@ interface FiltersProps { onTravelTimeRemoveEntry: (index: number) => void; onTravelTimeSetDestination: (index: number, slug: string, label: string) => void; onTravelTimeRangeChange: (index: number, range: [number, number]) => void; + onTravelTimeToggleBest: (index: number) => void; aiFilterLoading: boolean; aiFilterError: string | null; aiFilterNotes: string | null; onAiFilterSubmit: (query: string) => void; + isLicensed: boolean; + onUpgradeClick?: () => void; + onResetTutorial?: () => void; } export default memo(function Filters({ @@ -121,10 +127,14 @@ export default memo(function Filters({ onTravelTimeRemoveEntry, onTravelTimeSetDestination, onTravelTimeRangeChange, + onTravelTimeToggleBest, aiFilterLoading, aiFilterError, aiFilterNotes, onAiFilterSubmit, + isLicensed, + onUpgradeClick, + onResetTutorial, }: FiltersProps) { const activeListingType = useMemo((): ListingType => { const val = filters['Listing status'] as string[] | undefined; @@ -145,16 +155,11 @@ export default memo(function Filters({ const handleListingSelect = useCallback( (type: ListingType) => { - if (type === activeListingType && !filters['Listing status']) return; for (const name of Object.keys(filters)) { if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) { onRemoveFilter(name); } } - if (type === 'historical' && !filters['Listing status']) { - onFilterChange('Listing status', ['Historical sale']); - return; - } const valueMap: Record = { historical: 'Historical sale', buy: 'For sale', @@ -162,7 +167,7 @@ export default memo(function Filters({ }; onFilterChange('Listing status', [valueMap[type]]); }, - [activeListingType, filters, onFilterChange, onRemoveFilter] + [filters, onFilterChange, onRemoveFilter] ); const containerRef = useRef(null); @@ -205,7 +210,7 @@ export default memo(function Filters({
@@ -416,6 +445,8 @@ export default memo(function Filters({ onClearOpenInfoFeature={onClearOpenInfoFeature} travelTimeEntries={travelTimeEntries} onAddTravelTimeEntry={onTravelTimeAddEntry} + isLicensed={isLicensed} + onUpgradeClick={onUpgradeClick} />
@@ -423,59 +454,95 @@ export default memo(function Filters({ {showPhilosophy && ( setShowPhilosophy(false)}>
+

+ Start with your must-haves, then layer on nice-to-haves. + The map narrows down as you add filters — the areas that survive are your best matches. +

+

- Be intentional, not reactive + 1. Budget & property basics

- Your future home isn't a box of cereal you grab because it's on sale. - Don't let a seemingly good deal turn into lifelong regret. Instead of waiting - for listings to appear, define what you actually want and go find it. + Set your price range, minimum floor area, and property type. + If you need a lease over freehold (or vice versa), filter for that too. + This eliminates most of the map immediately.

- See the full picture + 2. Commute & transport

- Current listings show only a fraction of the market. There are too few to give you a - complete picture, yet too many to evaluate one by one. We aggregate millions of - historical sales so you can understand what's truly available in any area. + Add a travel time filter to your workplace — choose public transport or cycling + and set your maximum tolerable commute. You can also filter by + how many stations are within walking distance.

- Your priorities, your filters + 3. Safety & environment

- We all care about different things. Some want peace and quiet; others want to be - near the action. Use our filters to define exactly what matters to you and discover - postcodes that match. + Use the crime filters to cap serious or minor crime rates. + Check road noise levels if you're a light sleeper, and + environmental risk filters for ground stability concerns.

- Find the right place, not just the right listing + 4. Schools & education

- The best areas to live don't always have properties listed right now. We help - you identify where you should be looking, so when something does come up, - you're ready. + Filter by the number of Ofsted-rated Good or Outstanding primary and + secondary schools nearby. The education deprivation score captures + broader area-level attainment.

- Know what's possible + 5. Lifestyle & amenities

- We'd rather tell you upfront if your expectations are unrealistic than have you - spend months searching for something that doesn't exist. + Want restaurants, parks, or grocery shops within walking distance? + Filter by nearby amenity counts. Broadband speed filters help if + you work from home.

+ +
+

+ 6. Energy & running costs +

+

+ EPC ratings from A to G indicate energy efficiency. + Filter for better ratings to find homes with lower bills and + fewer upgrade headaches. +

+
+ +
+

+ Tip: if nothing survives your filters, relax one constraint at a time + to see which compromise unlocks the most options. +

+
+ + {onResetTutorial && ( + + )}
)} diff --git a/frontend/src/components/map/HoverCard.tsx b/frontend/src/components/map/HoverCard.tsx index bb06df3..b9a99d8 100644 --- a/frontend/src/components/map/HoverCard.tsx +++ b/frontend/src/components/map/HoverCard.tsx @@ -1,5 +1,5 @@ -import { memo } from 'react'; -import type { FeatureFilters } from '../../types'; +import { memo, useMemo } from 'react'; +import type { FeatureFilters, FeatureMeta } from '../../types'; import { formatValue } from '../../lib/format'; interface HoverCardData { @@ -14,11 +14,17 @@ interface HoverCardProps { isPostcode: boolean; data: HoverCardData | null; filters: FeatureFilters; + features: FeatureMeta[]; } -export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: HoverCardProps) { +export default memo(function HoverCard({ x, y, id, isPostcode, data, filters, features }: HoverCardProps) { const activeFilterNames = Object.keys(filters); + const featureMap = useMemo( + () => new Map(features.map((f) => [f.name, f])), + [features] + ); + // Get key stats to show from local data (min_ values) const getDisplayStats = () => { if (!data) return []; @@ -28,8 +34,13 @@ export default memo(function HoverCard({ x, y, id, isPostcode, data, filters }: // Show stats for active filters (up to 4) for (const name of activeFilterNames.slice(0, 4)) { const val = data[`avg_${name}`] ?? data[`min_${name}`]; - if (val != null && typeof val === 'number') { - results.push({ name, value: formatValue(val) }); + if (val == null || typeof val !== 'number') continue; + const meta = featureMap.get(name); + if (meta?.type === 'enum' && meta.values) { + const label = meta.values[Math.round(val)]; + if (label) results.push({ name, value: label }); + } else { + results.push({ name, value: formatValue(val, meta) }); } } diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 2a0679a..f43f87b 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -296,6 +296,7 @@ export default memo(function Map({ : data.find((d) => d.h3 === hoveredHexagonId) || null } filters={filters} + features={features} /> )} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 66aaba7..4c92ec7 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -229,16 +229,21 @@ export default function MapPage({ const isPostcode = selection.selectedHexagon?.type === 'postcode'; if (isPostcode) { - // For postcodes, get centroid from postcodeData + // For postcodes, get centroid from postcodeData; postcode string is the selection id const postcodeFeature = mapData.postcodeData.find((f) => f.properties.postcode === hexId); if (!postcodeFeature?.properties.centroid) return null; const [lon, lat] = postcodeFeature.properties.centroid; - return { lat, lon, resolution: mapData.resolution }; + return { lat, lon, resolution: mapData.resolution, postcode: hexId, isPostcode: true }; } else { - // For hexagons, get lat/lon from hexagon data + // For hexagons, get lat/lon from hexagon data; central postcode comes from stats const hex = hexId ? mapData.data.find((d) => d.h3 === hexId) : null; if (!hex || typeof hex.lat !== 'number' || typeof hex.lon !== 'number') return null; - return { lat: hex.lat as number, lon: hex.lon as number, resolution: mapData.resolution }; + return { + lat: hex.lat as number, + lon: hex.lon as number, + resolution: mapData.resolution, + postcode: selection.areaStats?.central_postcode, + }; } }, [ selection.selectedHexagon?.id, @@ -246,6 +251,7 @@ export default function MapPage({ mapData.data, mapData.postcodeData, mapData.resolution, + selection.areaStats?.central_postcode, ]); const tutorial = useTutorial(initialLoading, isMobile); @@ -400,10 +406,14 @@ export default function MapPage({ onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeSetDestination={handleTravelTimeSetDestination} onTravelTimeRangeChange={travelTime.handleTimeRangeChange} + onTravelTimeToggleBest={travelTime.handleToggleBest} aiFilterLoading={aiFilters.loading} aiFilterError={aiFilters.error} aiFilterNotes={aiFilters.notes} onAiFilterSubmit={handleAiFilterSubmit} + isLicensed={user?.subscription === 'licensed'} + onUpgradeClick={() => onNavigateTo('pricing')} + onResetTutorial={tutorial.resetTutorial} /> ); @@ -560,6 +570,7 @@ export default function MapPage({ callback={tutorial.handleCallback} styles={getTutorialStyles(theme)} disableScrolling + locale={{ last: 'Finish' }} />
-
+ {selection.selectedHexagon && (
-
-
-
-
- selection.setRightPaneTab('area')} - /> - +
+
+
+
+ selection.setRightPaneTab('area')} + /> + +
-
- {selection.rightPaneTab === 'properties' - ? renderPropertiesPane() - : renderAreaPane()} +
+ {selection.rightPaneTab === 'properties' + ? renderPropertiesPane() + : renderAreaPane()} +
-
+ )} {mapData.licenseRequired && ( void; onSetDestination: (slug: string, label: string) => void; onTimeRangeChange: (range: [number, number]) => void; + onToggleBest: () => void; onRemove: () => void; } @@ -39,11 +41,13 @@ export function TravelTimeCard({ slug, label, timeRange, + useBest, dataRange, isPinned, onTogglePin, onSetDestination, onTimeRangeChange, + onToggleBest, onRemove, }: TravelTimeCardProps) { const search = useLocationSearch(mode); @@ -119,6 +123,24 @@ export function TravelTimeCard({ )}
+ {/* Best-case toggle — transit only, shown when destination is set */} + {slug && mode === 'transit' && ( + + )} + {/* Time range slider — only show when we have data */} {slug && dataRange && (
diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts index e1a057a..87a8d70 100644 --- a/frontend/src/hooks/useDeckLayers.ts +++ b/frontend/src/hooks/useDeckLayers.ts @@ -1,6 +1,7 @@ import { useCallback, useRef, useState, useMemo, useEffect } from 'react'; import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { GeoJsonLayer, IconLayer, TextLayer } from '@deck.gl/layers'; +import { cellToBoundary } from 'h3-js'; import type { PickingInfo } from '@deck.gl/core'; import type { HexagonData, @@ -80,13 +81,13 @@ export function useDeckLayers({ // Marching ants animation const [marchTime, setMarchTime] = useState(0); - const hasPostcodeGeometry = selectedPostcodeGeometry != null; + const hasSelection = selectedPostcodeGeometry != null || selectedHexagonId != null; useEffect(() => { - if (!hasPostcodeGeometry) return; + if (!hasSelection) return; setMarchTime(0); const id = setInterval(() => setMarchTime((t) => t + 0.3), 50); return () => clearInterval(id); - }, [hasPostcodeGeometry]); + }, [hasSelection]); const isDark = theme === 'dark'; const densityGradient = isDark ? DENSITY_GRADIENT_DARK : DENSITY_GRADIENT; @@ -332,14 +333,11 @@ export function useDeckLayers({ ); }, getLineColor: (d) => { - if (d.h3 === selectedHexagonIdRef.current) - return [255, 255, 255, 255] as [number, number, number, number]; if (d.h3 === hoveredHexagonIdRef.current) return [29, 228, 195, 200] as [number, number, number, number]; return [0, 0, 0, 0] as [number, number, number, number]; }, getLineWidth: (d) => { - if (d.h3 === selectedHexagonIdRef.current) return 3; if (d.h3 === hoveredHexagonIdRef.current) return 2; return 0; }, @@ -481,15 +479,22 @@ export function useDeckLayers({ [pois, stablePoiHover] ); - // Marching ants highlight layer for selected postcode + // Marching ants highlight layer for selected hexagon or postcode const marchingAntsLayer = useMemo(() => { - if (!selectedPostcodeGeometry) return null; + let geometry: PostcodeGeometry | null = null; + if (selectedPostcodeGeometry) { + geometry = selectedPostcodeGeometry; + } else if (selectedHexagonId) { + const boundary = cellToBoundary(selectedHexagonId, true); + geometry = { type: 'Polygon', coordinates: [boundary] }; + } + if (!geometry) return null; return new GeoJsonLayer({ id: 'marching-ants', data: [ { type: 'Feature' as const, - geometry: selectedPostcodeGeometry, + geometry, properties: {}, }, ], @@ -502,7 +507,7 @@ export function useDeckLayers({ marchTime, extensions: [new MarchingAntsExtension()], }); - }, [selectedPostcodeGeometry, marchTime]); + }, [selectedPostcodeGeometry, selectedHexagonId, marchTime]); const layers = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts index c23ede1..f12d975 100644 --- a/frontend/src/hooks/useFilters.ts +++ b/frontend/src/hooks/useFilters.ts @@ -31,6 +31,8 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) { if (!meta) return; if (meta.type === 'enum' && meta.values) { setFilters((prev) => ({ ...prev, [name]: [...meta.values!] })); + } else if (meta.type === 'numeric' && meta.histogram) { + setFilters((prev) => ({ ...prev, [name]: [meta.histogram!.min, meta.histogram!.max] })); } else if (meta.min != null && meta.max != null) { setFilters((prev) => ({ ...prev, [name]: [meta.min!, meta.max!] })); } diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts index d916435..09f4797 100644 --- a/frontend/src/hooks/useTravelTime.ts +++ b/frontend/src/hooks/useTravelTime.ts @@ -16,6 +16,8 @@ export interface TravelTimeEntry { slug: string; label: string; timeRange: [number, number] | null; + /** Use best-case (5th percentile) travel time instead of median. Transit only. */ + useBest: boolean; } /** Field key matching the backend response: tt_{mode}_{slug} */ @@ -33,7 +35,7 @@ export function useTravelTime(initial?: TravelTimeInitial) { const handleAddEntry = useCallback((mode: TransportMode) => { setEntries((prev) => [ ...prev, - { mode, slug: '', label: '', timeRange: null }, + { mode, slug: '', label: '', timeRange: null, useBest: false }, ]); }, []); @@ -63,6 +65,17 @@ export function useTravelTime(initial?: TravelTimeInitial) { [] ); + const handleToggleBest = useCallback( + (index: number) => { + setEntries((prev) => + prev.map((entry, i) => + i === index ? { ...entry, useBest: !entry.useBest, timeRange: null } : entry + ) + ); + }, + [] + ); + /** Entries that have a destination selected (slug is set) */ const activeEntries = useMemo( () => entries.filter((e) => e.slug !== ''), @@ -76,5 +89,6 @@ export function useTravelTime(initial?: TravelTimeInitial) { handleRemoveEntry, handleSetDestination, handleTimeRangeChange, + handleToggleBest, }; } diff --git a/frontend/src/hooks/useTutorial.ts b/frontend/src/hooks/useTutorial.ts index 32180d4..b6ac7fc 100644 --- a/frontend/src/hooks/useTutorial.ts +++ b/frontend/src/hooks/useTutorial.ts @@ -9,7 +9,7 @@ const STEPS: Step[] = [ target: '[data-tutorial="filters"]', title: 'Filter Properties', content: - 'Use filters to narrow down properties by price, energy rating, floor area, and more. Pin a filter to colour the map by that feature.', + 'Use filters to narrow down to areas which contain matching properties. Filter by crime rate, number of schools around, or filter to an area with detached houses. Pin a filter with the eye icon to colour the map by that feature.', placement: 'right', disableBeacon: true, }, @@ -17,7 +17,7 @@ const STEPS: Step[] = [ target: '[data-tutorial="map"]', title: 'Explore the Map', content: - 'Pan and zoom to explore property data across the UK. Click any hexagon to see detailed stats and individual properties.', + 'Pan and zoom to explore property data across England. Click any area (hexagon or postcode boundary) to see detailed stats of historical or currently sold properties matching your filters.', placement: 'bottom', disableBeacon: true, }, @@ -44,6 +44,11 @@ const STEPS: Step[] = [ 'Toggle points of interest like schools, shops, and transport stops to see what amenities are nearby.', placement: 'left', disableBeacon: true, + styles: { + tooltip: { + transform: 'translateY(-50px)', + }, + }, }, ]; diff --git a/frontend/src/lib/MarchingAntsExtension.ts b/frontend/src/lib/MarchingAntsExtension.ts index b462f48..232f008 100644 --- a/frontend/src/lib/MarchingAntsExtension.ts +++ b/frontend/src/lib/MarchingAntsExtension.ts @@ -26,7 +26,7 @@ uniform marchingAntsUniforms { } marchingAnts;`, 'fs:DECKGL_FILTER_COLOR': `\ float marchSegLen = 4.0; -float marchPos = mod(vPathPosition.y - marchingAnts.marchTime, marchSegLen * 2.0); +float marchPos = mod(geometry.uv.y - marchingAnts.marchTime, marchSegLen * 2.0); if (marchPos < marchSegLen) { color = vec4(1.0, 1.0, 1.0, color.a); } else { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ca53331..9edbb09 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -81,7 +81,8 @@ export function buildFilterString(filters: FeatureFilters, features: FeatureMeta return `${name}:${(value as string[]).join('|')}`; } const [min, max] = value as [number, number]; - const maxStr = meta?.absolute && max === meta.max ? 'inf' : String(max); + const isAtMax = meta?.histogram ? max >= meta.histogram.max : max === meta?.max; + const maxStr = meta?.absolute && isAtMax ? 'inf' : String(max); return `${name}:${min}:${maxStr}`; }) .join(';;'); diff --git a/frontend/src/lib/clipboard.ts b/frontend/src/lib/clipboard.ts new file mode 100644 index 0000000..c0a8b5d --- /dev/null +++ b/frontend/src/lib/clipboard.ts @@ -0,0 +1,16 @@ +/** Copy text to clipboard with execCommand fallback for older browsers. */ +export function copyToClipboard(text: string, onSuccess: () => void): void { + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(onSuccess); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + onSuccess(); + } +} diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index d5f25ee..df17cd7 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -19,7 +19,7 @@ export const FREE_ZONE_BOUNDS = { south: 51.42, west: -0.34, north: 51.60, east: export const INITIAL_VIEW_STATE: ViewState = { longitude: (FREE_ZONE_BOUNDS.west + FREE_ZONE_BOUNDS.east) / 2, latitude: (FREE_ZONE_BOUNDS.south + FREE_ZONE_BOUNDS.north) / 2, - zoom: 14, + zoom: 15, pitch: 0, }; @@ -33,10 +33,9 @@ export const ZOOM_TO_RESOLUTION_THRESHOLDS = [ { maxZoom: 10.5, resolution: 7 }, { maxZoom: 11.5, resolution: 8 }, { maxZoom: 13, resolution: 9 }, - { maxZoom: Infinity, resolution: 10 }, ] as const; -export const POSTCODE_ZOOM_THRESHOLD = 16; +export const POSTCODE_ZOOM_THRESHOLD = 14.5; export const FEATURE_GRADIENT: { t: number; color: [number, number, number] }[] = [ { t: 0, color: [46, 204, 113] }, diff --git a/frontend/src/lib/external-search.ts b/frontend/src/lib/external-search.ts index 6c08787..4a4dc79 100644 --- a/frontend/src/lib/external-search.ts +++ b/frontend/src/lib/external-search.ts @@ -4,6 +4,8 @@ export interface HexagonLocation { lat: number; lon: number; resolution: number; + postcode?: string; + isPostcode?: boolean; } const PROPERTY_TYPE_MAP: Record< @@ -32,10 +34,10 @@ export const H3_RADIUS_MILES: Record = { 6: 3, 7: 1, 8: 0.5, - 9: 0.25, - 10: 0.25, - 11: 0.25, - 12: 0.25, + 9: 1, + 10: 1, + 11: 1, + 12: 1, }; const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40]; @@ -46,13 +48,21 @@ function nearestRadius(target: number, allowed: number[]): number { return allowed.reduce((best, r) => (Math.abs(r - target) < Math.abs(best - target) ? r : best)); } -export function buildPropertySearchUrls( - location: HexagonLocation, - filters: FeatureFilters -): { rightmove: string; onthemarket: string; zoopla: string } { - const { lat, lon, resolution } = location; - const radiusMiles = H3_RADIUS_MILES[resolution] ?? 1; - const coordStr = `${lat.toFixed(5)},${lon.toFixed(5)}`; +interface SearchUrlOptions { + location: HexagonLocation; + filters: FeatureFilters; + rightmoveLocationId?: string; +} + +export function buildPropertySearchUrls({ + location, + filters, + rightmoveLocationId, +}: SearchUrlOptions): { rightmove: string | null; onthemarket: string; zoopla: string } | null { + const { postcode, resolution, isPostcode } = location; + if (!postcode) return null; + + const radiusMiles = isPostcode ? 0.25 : (H3_RADIUS_MILES[resolution] ?? 1); const priceFilter = filters['Last known price']; const minPrice = @@ -66,43 +76,51 @@ export function buildPropertySearchUrls( ? (propertyTypes as string[]) : []; - const rmParams = new URLSearchParams(); - rmParams.set('searchLocation', coordStr); - rmParams.set('channel', 'BUY'); - rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))); - if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice))); - if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice))); - if (selectedTypes.length > 0) { - const rmTypes = [ - ...new Set( - selectedTypes.flatMap((t) => { - const mapped = PROPERTY_TYPE_MAP[t]?.rightmove; - return mapped ? mapped.split(',') : []; - }) - ), - ]; - if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(',')); + // Rightmove — requires locationIdentifier from typeahead API + let rightmove: string | null = null; + if (rightmoveLocationId) { + const rmParams = new URLSearchParams(); + rmParams.set('searchLocation', postcode); + rmParams.set('useLocationIdentifier', 'true'); + rmParams.set('locationIdentifier', rightmoveLocationId); + rmParams.set('radius', String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))); + if (minPrice !== undefined) rmParams.set('minPrice', String(Math.round(minPrice))); + if (maxPrice !== undefined) rmParams.set('maxPrice', String(Math.round(maxPrice))); + if (selectedTypes.length > 0) { + const rmTypes = [ + ...new Set( + selectedTypes.flatMap((t) => { + const mapped = PROPERTY_TYPE_MAP[t]?.rightmove; + return mapped ? mapped.split(',') : []; + }) + ), + ]; + if (rmTypes.length > 0) rmParams.set('propertyTypes', rmTypes.join(',')); + } + rmParams.set('_includeSSTC', 'on'); + rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`; } - const rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`; - let otmType = 'property'; - if (selectedTypes.length > 0) { - const otmTypes = [ - ...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)), - ]; - if (otmTypes.length === 1 && otmTypes[0] !== 'property') otmType = otmTypes[0]!; - } + // OnTheMarket — postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa") + const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-'); const otmParams = new URLSearchParams(); otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII))); if (minPrice !== undefined) otmParams.set('min-price', String(Math.round(minPrice))); if (maxPrice !== undefined) otmParams.set('max-price', String(Math.round(maxPrice))); - otmParams.set('search-site', 'geo'); - otmParams.set('geo-lat', String(lat)); - otmParams.set('geo-lng', String(lon)); - const onthemarket = `https://www.onthemarket.com/for-sale/${otmType}/?${otmParams.toString()}`; + if (selectedTypes.length > 0) { + const otmTypes = [ + ...new Set(selectedTypes.map((t) => PROPERTY_TYPE_MAP[t]?.onthemarket).filter(Boolean)), + ]; + for (const ot of otmTypes) { + otmParams.append('prop-types', ot!); + } + } + otmParams.set('view', 'map-list'); + const onthemarket = `https://www.onthemarket.com/for-sale/property/${otmSlug}/?${otmParams.toString()}`; + // Zoopla const zParams = new URLSearchParams(); - zParams.set('q', coordStr); + zParams.set('q', postcode); zParams.set('search_source', 'for-sale'); zParams.set('radius', String(nearestRadius(radiusMiles, ZOOPLA_RADII))); if (minPrice !== undefined) zParams.set('price_min', String(Math.round(minPrice))); @@ -115,7 +133,6 @@ export function buildPropertySearchUrls( zParams.append('property_sub_type', zt!); } } - zParams.set('geo_autocomplete_identifier', `geo_${lat}_${lon}`); const zoopla = `https://www.zoopla.co.uk/for-sale/property/?${zParams.toString()}`; return { rightmove, onthemarket, zoopla }; diff --git a/frontend/src/lib/url-state.ts b/frontend/src/lib/url-state.ts index 3f97a18..2bf811b 100644 --- a/frontend/src/lib/url-state.ts +++ b/frontend/src/lib/url-state.ts @@ -71,7 +71,7 @@ export function parseUrlState(): { } // Travel time: repeated `tt` params - // Format: mode:slug:label or mode:slug:label:min:max + // Format: mode:slug:label or mode:slug:label:b or mode:slug:label:min:max or mode:slug:label:b:min:max const ttParams = params.getAll('tt'); if (ttParams.length > 0) { const entries: TravelTimeEntry[] = []; @@ -82,15 +82,17 @@ export function parseUrlState(): { if (!TRANSPORT_MODES.includes(mode)) continue; const slug = parts[1]; const label = decodeURIComponent(parts[2]); + const useBest = parts.length >= 4 && parts[3] === 'b'; + const rangeOffset = useBest ? 1 : 0; let timeRange: [number, number] | null = null; - if (parts.length >= 5) { - const min = Number(parts[3]); - const max = Number(parts[4]); + if (parts.length >= 5 + rangeOffset) { + const min = Number(parts[3 + rangeOffset]); + const max = Number(parts[4 + rangeOffset]); if (!isNaN(min) && !isNaN(max)) { timeRange = [min, max]; } } - entries.push({ mode, slug, label, timeRange }); + entries.push({ mode, slug, label, timeRange, useBest }); } if (entries.length > 0) { result.travelTime = { entries }; @@ -139,6 +141,7 @@ export function stateToParams( for (const entry of travelTimeEntries) { if (!entry.slug) continue; let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`; + if (entry.useBest) val += ':b'; if (entry.timeRange) { val += `:${entry.timeRange[0]}:${entry.timeRange[1]}`; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ff61a57..7a39379 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -172,4 +172,5 @@ export interface HexagonStatsResponse { numeric_features: NumericFeatureStats[]; enum_features: EnumFeatureStats[]; price_history?: PricePoint[]; + central_postcode?: string; } diff --git a/r5-java/run.sh b/r5-java/run.sh index 968977a..ab19ac1 100755 --- a/r5-java/run.sh +++ b/r5-java/run.sh @@ -4,6 +4,9 @@ set -euo pipefail # Batch-compute travel times from all places to all England postcodes # for all transport modes (car, bicycle, walking, transit). # +# Uses full England OSM + 2 GTFS feeds (BODS buses, National Rail). +# R5's TransportNetwork.fromDirectory() picks up all .osm.pbf and .zip files. +# # Uses each place as origin with all postcodes as destinations — R5 does one # routing computation per place, then reads off travel times to all postcodes. # For car/bicycle/walking this is symmetric (place->postcode = postcode->place). @@ -15,11 +18,10 @@ set -euo pipefail # # Usage: # ./r5-java/run.sh -# ./r5-java/run.sh --threads 8 --heap 24g --output-dir property-data/travel-times # --- Defaults --- -THREADS=16 -HEAP=16g +THREADS=4 +HEAP=12g NETWORK_DIR=property-data/r5-network OUTPUT_BASE=property-data/travel-times R5_DIR=r5-java @@ -102,25 +104,26 @@ fi # R5 writes .mapdb temp files next to OSM/GTFS files during network construction. # Copy source data to a writable build dir to avoid polluting the originals. mkdir -p "$NETWORK_DIR" -DATA_DIR="property-data/transit" +TRANSIT_SRC="property-data/transit" +NETWORK_DATA_DIR="$TRANSIT_SRC" if [ ! -f "$NETWORK_DIR/network.dat" ]; then BUILD_DIR="$NETWORK_DIR/build" echo "--- No cached network — copying transit data to build dir ---" mkdir -p "$BUILD_DIR" - if ! cp property-data/transit/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then - echo "Warning: no .osm.pbf files found in property-data/transit/raw/" + if ! cp "$TRANSIT_SRC"/raw/*.osm.pbf "$BUILD_DIR/" 2>/dev/null; then + echo "Warning: no .osm.pbf files found in $TRANSIT_SRC/raw/" fi - if ! cp property-data/transit/*.zip "$BUILD_DIR/" 2>/dev/null; then - echo "Warning: no .zip files found in property-data/transit/" + if ! cp "$TRANSIT_SRC"/*.zip "$BUILD_DIR/" 2>/dev/null; then + echo "Warning: no .zip files found in $TRANSIT_SRC/" fi - DATA_DIR="$BUILD_DIR" + NETWORK_DATA_DIR="$BUILD_DIR" fi # --- Step 5: Run batch --- echo "" echo "--- Starting batch computation ---" -DATA_DIR="$DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \ +DATA_DIR="$NETWORK_DATA_DIR" NETWORK_CACHE_DIR="$NETWORK_DIR" \ java -Xmx"$HEAP" -cp "$OUT_DIR:$LIB_DIR/*" propertymap.App \ --postcodes property-data/arcgis_data.parquet \ --places property-data/places.parquet \ diff --git a/r5-java/src/main/java/propertymap/App.java b/r5-java/src/main/java/propertymap/App.java index f00935a..afdc6d8 100644 --- a/r5-java/src/main/java/propertymap/App.java +++ b/r5-java/src/main/java/propertymap/App.java @@ -192,6 +192,9 @@ public class App { if (attempt < MAX_RETRIES) { System.err.printf("%n [RETRY %d/%d] %s: %s%n", attempt + 1, MAX_RETRIES, name, e.getMessage()); + } else { + System.err.printf("%n [FAIL TRACE] %s:%n", name); + e.printStackTrace(System.err); } } } @@ -215,7 +218,7 @@ public class App { String safe = name.toLowerCase() .replaceAll("[^a-z0-9 -]", "") .replaceAll("\\s+", "-"); - return String.format("%04d-%s.parquet", index, safe); + return String.format("%06d-%s.parquet", index, safe); } private static String requiredArg(String[] args, String name) { diff --git a/r5-java/src/main/java/propertymap/Router.java b/r5-java/src/main/java/propertymap/Router.java index 108f4ae..bff77a8 100644 --- a/r5-java/src/main/java/propertymap/Router.java +++ b/r5-java/src/main/java/propertymap/Router.java @@ -29,6 +29,10 @@ public class Router { private static final int DEPARTURE_TO_TIME = 9 * 3600; // 09:00 private static final int MAX_TRIP_DURATION_MINUTES = 120; + // Percentile indices in R5 result arrays (order must match task.percentiles in buildTask) + private static final int PERCENTILE_BEST = 0; // 5th percentile (transit only) + private static final int PERCENTILE_MEDIAN = 1; // 50th percentile (transit: index 1, others: index 0) + /** Result of computing travel times for a single origin with spatial pre-filtering. */ record FilteredResult(int[] originalIndices, short[] times, short[] bestTimes) {} @@ -102,10 +106,9 @@ public class Router { boolean isTransit = mode.equals("transit"); short[][] allTimes = computeTravelTimes(network, chunks, originLat, originLon, mode, fLats.length, date); - // For transit: allTimes[0]=best (5th percentile), allTimes[1]=median (50th) - // For others: allTimes[0]=median (50th), no best - short[] medianTimes = isTransit ? allTimes[1] : allTimes[0]; - short[] bestTimes = isTransit ? allTimes[0] : null; + // Transit requests [5th, 50th] percentiles; others request [50th] only + short[] medianTimes = isTransit ? allTimes[PERCENTILE_MEDIAN] : allTimes[0]; + short[] bestTimes = isTransit ? allTimes[PERCENTILE_BEST] : null; return new FilteredResult(filtered, medianTimes, bestTimes); } @@ -205,13 +208,24 @@ public class Router { OneOriginResult result = computer.computeTravelTimes(); TravelTimeResult tt = result.travelTimes; - if (tt != null) { - int[][] values = tt.getValues(); - for (int p = 0; p < nPercentiles && p < values.length; p++) { - for (int i = 0; i < chunk.originalIndices.length && i < values[p].length; i++) { - if (values[p][i] != Integer.MAX_VALUE) { - allTimes[p][chunk.originalIndices[i]] = (short) values[p][i]; - } + if (tt == null) { + throw new RuntimeException("R5 returned null travelTimes for chunk with " + + chunk.originalIndices.length + " destinations"); + } + int[][] values = tt.getValues(); + if (values.length < nPercentiles) { + throw new RuntimeException("R5 returned " + values.length + " percentiles, expected " + + nPercentiles); + } + for (int p = 0; p < nPercentiles; p++) { + if (values[p].length < chunk.originalIndices.length) { + throw new RuntimeException("R5 returned " + values[p].length + + " travel times for percentile " + p + ", expected " + + chunk.originalIndices.length); + } + for (int i = 0; i < chunk.originalIndices.length; i++) { + if (values[p][i] != Integer.MAX_VALUE) { + allTimes[p][chunk.originalIndices[i]] = (short) values[p][i]; } } } diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index d0c8390..6f8a242 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2743,6 +2743,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -2767,12 +2768,14 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] @@ -3803,6 +3806,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.85" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index fa897bd..72ad1e9 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -22,7 +22,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-appender = "0.2" metrics = "0.24" metrics-exporter-prometheus = "0.16" -reqwest = { version = "0.12", features = ["rustls-tls", "json"] } +reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream"] } urlencoding = "2" rust_xlsxwriter = "0.79" pmtiles = { version = "0.12", features = ["mmap-async-tokio"] } diff --git a/server-rs/src/data/places.rs b/server-rs/src/data/places.rs index d3abb0a..25f4f6c 100644 --- a/server-rs/src/data/places.rs +++ b/server-rs/src/data/places.rs @@ -22,18 +22,8 @@ pub struct PlaceData { fn type_rank(place_type: &str) -> u8 { match place_type { "city" => 0, - "borough" => 1, - "town" => 2, - "suburb" => 3, - "quarter" => 4, - "neighbourhood" => 5, - "village" => 6, - "station" => 7, - "island" => 8, - "hamlet" => 9, - "locality" => 10, - "isolated_dwelling" => 11, - _ => 12, + "station" => 1, + _ => 2, } } @@ -159,10 +149,7 @@ mod tests { #[test] fn type_rank_ordering() { - assert!(type_rank("city") < type_rank("town")); - assert!(type_rank("town") < type_rank("suburb")); - assert!(type_rank("suburb") < type_rank("village")); - assert!(type_rank("village") < type_rank("hamlet")); - assert!(type_rank("hamlet") < type_rank("isolated_dwelling")); + assert!(type_rank("city") < type_rank("station")); + assert!(type_rank("station") < type_rank("unknown")); } } diff --git a/server-rs/src/data/travel_time.rs b/server-rs/src/data/travel_time.rs index fe5814b..d8c6709 100644 --- a/server-rs/src/data/travel_time.rs +++ b/server-rs/src/data/travel_time.rs @@ -8,8 +8,15 @@ use polars::lazy::frame::LazyFrame; use rustc_hash::{FxHashMap, FxHashSet}; use tracing::info; -/// Cached postcode → travel_minutes mapping for a single destination file. -pub type TravelData = Arc>; +/// Per-postcode travel time data: median and optional best-case (transit only). +#[derive(Clone, Copy)] +pub struct TravelDataRow { + pub minutes: i16, + pub best_minutes: Option, +} + +/// Cached postcode → travel time data for a single destination file. +pub type TravelData = Arc>; /// Simple LRU cache for travel time data, limited to `capacity` entries. struct LruCache { @@ -159,12 +166,23 @@ impl TravelTimeStore { .context("Missing 'travel_minutes' column")? .i16() .context("'travel_minutes' is not i16")?; + let best = df + .column("best_minutes") + .ok() + .map(|col| col.i16().expect("'best_minutes' is not i16")); let mut map = FxHashMap::default(); map.reserve(df.height()); - for (pc, min) in postcodes.into_iter().zip(minutes.into_iter()) { + for (i, (pc, min)) in postcodes.into_iter().zip(minutes.into_iter()).enumerate() { if let (Some(pc), Some(min)) = (pc, min) { - map.insert(pc.to_string(), min); + let best_min = best.as_ref().and_then(|b| b.get(i)); + map.insert( + pc.to_string(), + TravelDataRow { + minutes: min, + best_minutes: best_min, + }, + ); } } diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index 997c81f..0104e8d 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -424,6 +424,7 @@ async fn main() -> anyhow::Result<()> { let state_invites_create = state.clone(); let state_invite_get = state.clone(); let state_redeem_invite = state.clone(); + let state_rightmove = state.clone(); let api = Router::new() .route( @@ -495,6 +496,10 @@ async fn main() -> anyhow::Result<()> { "/api/streetview", get(move |query| routes::get_streetview(state_streetview.clone(), query)), ) + .route( + "/api/rightmove-location", + get(move |query| routes::get_rightmove_typeahead(state_rightmove.clone(), query)), + ) .route( "/api/subscription", patch(move |ext, body| { @@ -569,7 +574,7 @@ async fn main() -> anyhow::Result<()> { let app = if let Some(ref dist) = cli.dist { api.fallback_service( - ServeDir::new(dist).not_found_service(ServeFile::new(dist.join("index.html"))), + ServeDir::new(dist).fallback(ServeFile::new(dist.join("index.html"))), ) } else { api diff --git a/server-rs/src/pocketbase.rs b/server-rs/src/pocketbase.rs index 7afb7d1..7a81246 100644 --- a/server-rs/src/pocketbase.rs +++ b/server-rs/src/pocketbase.rs @@ -405,35 +405,49 @@ pub async fn ensure_oauth_providers( let base_url = base_url.trim_end_matches('/'); let token = auth_superuser(client, base_url, admin_email, admin_password).await?; - // GET current settings + // Set meta.appURL in global settings for OAuth redirects + let app_url = format!("{}/pb", public_url.trim_end_matches('/')); let settings_url = format!("{base_url}/api/settings"); + let patch_resp = client + .patch(&settings_url) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "meta": { "appURL": app_url } })) + .send() + .await?; + if !patch_resp.status().is_success() { + let status = patch_resp.status(); + let text = patch_resp.text().await.unwrap_or_default(); + anyhow::bail!("Failed to update PocketBase meta.appURL ({status}): {text}"); + } + info!("PocketBase meta.appURL set to {app_url}"); + + // PocketBase 0.23+: OAuth providers are configured per-collection, not in global settings. + // GET the users collection to update its oauth2 config. + let collection_url = format!("{base_url}/api/collections/users"); let resp = client - .get(&settings_url) + .get(&collection_url) .header("Authorization", format!("Bearer {token}")) .send() .await?; - if !resp.status().is_success() { let status = resp.status(); let text = resp.text().await.unwrap_or_default(); - anyhow::bail!("Failed to fetch PocketBase settings ({status}): {text}"); + anyhow::bail!("Failed to fetch users collection ({status}): {text}"); } - let mut settings: serde_json::Value = resp.json().await?; + let mut collection: serde_json::Value = resp.json().await?; - // Set meta.appUrl for OAuth redirect - let app_url = format!("{}/pb", public_url.trim_end_matches('/')); - if let Some(meta) = settings.get_mut("meta") { - meta["appUrl"] = serde_json::json!(app_url); - } else { - settings["meta"] = serde_json::json!({ "appUrl": app_url }); - } + let oauth2 = collection + .get_mut("oauth2") + .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2 field"))?; - // Update OAuth2 providers - let providers = settings - .pointer_mut("/oauth2/providers") + // Ensure enabled + oauth2["enabled"] = serde_json::json!(true); + + let providers = oauth2 + .get_mut("providers") .and_then(|v| v.as_array_mut()) - .ok_or_else(|| anyhow::anyhow!("PocketBase settings missing oauth2.providers array — cannot configure OAuth"))?; + .ok_or_else(|| anyhow::anyhow!("users collection missing oauth2.providers array"))?; let google = match providers .iter() @@ -441,7 +455,7 @@ pub async fn ensure_oauth_providers( { Some(idx) => &mut providers[idx], None => { - info!("Google provider not found in PocketBase settings — adding it"); + info!("Google provider not found — adding it"); providers.push(serde_json::json!({"name": "google"})); providers.last_mut().expect("just pushed") } @@ -449,23 +463,20 @@ pub async fn ensure_oauth_providers( google["clientId"] = serde_json::json!(google_client_id); google["clientSecret"] = serde_json::json!(google_client_secret); - google["enabled"] = serde_json::json!(true); - info!("Configured Google OAuth provider"); - // PATCH settings back + // PATCH the collection let patch_resp = client - .patch(&settings_url) + .patch(&collection_url) .header("Authorization", format!("Bearer {token}")) - .json(&settings) + .json(&serde_json::json!({ "oauth2": oauth2 })) .send() .await?; - if !patch_resp.status().is_success() { let status = patch_resp.status(); let text = patch_resp.text().await.unwrap_or_default(); - anyhow::bail!("Failed to update PocketBase settings ({status}): {text}"); + anyhow::bail!("Failed to update users collection OAuth ({status}): {text}"); } - info!("PocketBase OAuth settings updated (appUrl: {app_url})"); + info!("PocketBase OAuth configured on users collection"); Ok(()) } diff --git a/server-rs/src/routes.rs b/server-rs/src/routes.rs index 6089973..f04e226 100644 --- a/server-rs/src/routes.rs +++ b/server-rs/src/routes.rs @@ -19,6 +19,7 @@ mod streetview; mod stripe_webhook; mod newsletter; pub(crate) mod pricing; +mod rightmove_typeahead; mod subscription; mod tiles; pub(crate) mod travel_time; @@ -46,4 +47,5 @@ pub use pricing::get_pricing; pub use stripe_webhook::post_stripe_webhook; pub use subscription::patch_subscription; pub use tiles::{get_style, get_tile, init_tile_reader}; +pub use rightmove_typeahead::get_rightmove_typeahead; pub use travel_modes::get_travel_modes; diff --git a/server-rs/src/routes/hexagon_stats.rs b/server-rs/src/routes/hexagon_stats.rs index 26a9428..c36785b 100644 --- a/server-rs/src/routes/hexagon_stats.rs +++ b/server-rs/src/routes/hexagon_stats.rs @@ -59,6 +59,8 @@ pub struct HexagonStatsResponse { pub enum_features: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub price_history: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub central_postcode: Option, } #[derive(Deserialize)] @@ -136,6 +138,31 @@ pub async fn get_hexagon_stats( let total_count = matching_rows.len(); + // Find the postcode of the property closest 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()) + } else { + None + }; + let price_history = stats::extract_price_history( &matching_rows, feature_data, @@ -170,6 +197,7 @@ pub async fn get_hexagon_stats( numeric_features, enum_features: enum_features_out, price_history, + central_postcode, }) }) .await diff --git a/server-rs/src/routes/hexagons.rs b/server-rs/src/routes/hexagons.rs index 2ee26b0..3eef756 100644 --- a/server-rs/src/routes/hexagons.rs +++ b/server-rs/src/routes/hexagons.rs @@ -43,12 +43,13 @@ pub struct HexagonParams { struct TravelEntry { mode: String, slug: String, + use_best: bool, filter_min: Option, filter_max: Option, } /// Parse `travel` param into a list of travel entries. -/// Format: `mode:slug` or `mode:slug:min:max` +/// Format: `mode:slug` or `mode:slug:best` or `mode:slug:min:max` or `mode:slug:best:min:max` fn parse_travel_entries(travel_str: &str) -> Result, String> { let mut entries = Vec::new(); let mut seen_keys = Vec::new(); @@ -63,12 +64,15 @@ fn parse_travel_entries(travel_str: &str) -> Result, String> { let mode = parts[0].trim().to_string(); let slug = parts[1].trim().to_string(); - let (filter_min, filter_max) = if parts.len() >= 4 { - let min: f32 = parts[2] + let use_best = parts.len() >= 3 && parts[2].trim() == "best"; + let filter_offset = if use_best { 1 } else { 0 }; + + let (filter_min, filter_max) = if parts.len() >= 4 + filter_offset { + let min: f32 = parts[2 + filter_offset] .trim() .parse() .map_err(|_| format!("invalid travel filter min in '{}'", segment))?; - let max: f32 = parts[3] + let max: f32 = parts[3 + filter_offset] .trim() .parse() .map_err(|_| format!("invalid travel filter max in '{}'", segment))?; @@ -85,6 +89,7 @@ fn parse_travel_entries(travel_str: &str) -> Result, String> { entries.push(TravelEntry { mode, slug, + use_best, filter_min, filter_max, }); @@ -286,7 +291,14 @@ pub async fn get_hexagons( let postcode = pc_interner.resolve(&pc_keys[row]); travel_minutes.reserve(travel_entries.len()); for (ti, entry) in travel_entries.iter().enumerate() { - let minutes = travel_data[ti].get(postcode).copied(); + let row_data = travel_data[ti].get(postcode); + let minutes = row_data.map(|r| { + if entry.use_best { + r.best_minutes.unwrap_or(r.minutes) + } else { + r.minutes + } + }); travel_minutes.push(minutes); if let (Some(fmin), Some(fmax)) = (entry.filter_min, entry.filter_max) { match minutes { diff --git a/server-rs/src/routes/pb_proxy.rs b/server-rs/src/routes/pb_proxy.rs index 122507d..06fc2db 100644 --- a/server-rs/src/routes/pb_proxy.rs +++ b/server-rs/src/routes/pb_proxy.rs @@ -11,10 +11,11 @@ use crate::state::AppState; /// Dedicated HTTP client for proxying — does not follow redirects so 3xx /// responses are passed through to the browser (needed for OAuth flows). +/// No overall timeout because SSE (Server-Sent Events) connections used by +/// PocketBase realtime/OAuth2 are long-lived streams. static PROXY_CLIENT: LazyLock = LazyLock::new(|| { reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) - .timeout(Duration::from_secs(30)) .connect_timeout(Duration::from_secs(5)) .build() .expect("Failed to build proxy HTTP client") @@ -97,16 +98,12 @@ pub async fn proxy_to_pocketbase(state: Arc, req: Request) -> impl Int } } - match upstream.bytes().await { - Ok(bytes) => response.body(Body::from(bytes)).unwrap(), - Err(err) => { - warn!("Failed to read upstream response: {err}"); - Response::builder() - .status(StatusCode::BAD_GATEWAY) - .body(Body::from("Failed to read upstream response")) - .unwrap() - } - } + // Stream the response body instead of buffering it entirely. + // This is critical for SSE (Server-Sent Events) used by PocketBase's + // realtime system and OAuth2 flow — buffering would hang forever + // since SSE responses never complete. + let body = Body::from_stream(upstream.bytes_stream()); + response.body(body).unwrap() } Err(err) => { warn!("PocketBase proxy error: {err}"); diff --git a/server-rs/src/routes/postcode_stats.rs b/server-rs/src/routes/postcode_stats.rs index 8e6aed1..6b96c4a 100644 --- a/server-rs/src/routes/postcode_stats.rs +++ b/server-rs/src/routes/postcode_stats.rs @@ -135,6 +135,7 @@ pub async fn get_postcode_stats( numeric_features, enum_features: enum_features_out, price_history, + central_postcode: None, }) }) .await diff --git a/server-rs/src/routes/rightmove_typeahead.rs b/server-rs/src/routes/rightmove_typeahead.rs new file mode 100644 index 0000000..a241290 --- /dev/null +++ b/server-rs/src/routes/rightmove_typeahead.rs @@ -0,0 +1,83 @@ +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()) +} From 7a12e6c09aca04392c93cdb1d0d46537475b24ed Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 22 Feb 2026 21:09:22 +0000 Subject: [PATCH 3/7] . --- frontend/src/components/ui/Header.tsx | 28 ++++++++------------------- frontend/src/hooks/useMapData.ts | 3 ++- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index e55ec2e..7c666f7 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect } from 'react'; import type { AuthUser } from '../../hooks/useAuth'; import { shortenUrl } from '../../lib/api'; +import { copyToClipboard } from '../../lib/clipboard'; import { DownloadIcon } from './icons/DownloadIcon'; import { BookmarkIcon } from './icons/BookmarkIcon'; import { LogoIcon } from './icons/LogoIcon'; @@ -63,42 +64,29 @@ export default function Header({ if (!isMobile) setMenuOpen(false); }, [isMobile]); - const copyToClipboard = useCallback((text: string) => { - const onSuccess = () => { + const doCopy = useCallback((text: string) => { + copyToClipboard(text, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); - }; - if (navigator.clipboard?.writeText) { - navigator.clipboard.writeText(text).then(onSuccess); - } else { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - onSuccess(); - } + }); }, []); const handleShare = useCallback(async () => { const params = window.location.search.replace(/^\?/, ''); if (!params) { - copyToClipboard(window.location.href); + doCopy(window.location.href); return; } setSharing(true); try { const shortUrl = await shortenUrl(params); - copyToClipboard(shortUrl); + doCopy(shortUrl); } catch { - copyToClipboard(window.location.href); + doCopy(window.location.href); } finally { setSharing(false); } - }, [copyToClipboard]); + }, [doCopy]); const tabClass = (page: Page) => `px-3 py-1.5 rounded text-sm font-medium transition-colors ${ diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index 27f2b50..80e0fb6 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -76,12 +76,13 @@ export function useMapData({ ); // Build the travel param string from entries with destinations - // Format: mode:slug|mode:slug or mode:slug:min:max|mode:slug + // Format: mode:slug|mode:slug:best or mode:slug:min:max|mode:slug:best:min:max const travelParam = useMemo((): string => { const segments: string[] = []; for (const entry of travelTimeEntries) { if (!entry.slug) continue; let seg = `${entry.mode}:${entry.slug}`; + if (entry.useBest) seg += ':best'; if (entry.timeRange) { seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`; } From 9da2db707f92c1f05a03b03d298be248fa56e303 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 22 Feb 2026 21:32:47 +0000 Subject: [PATCH 4/7] Remove journey times --- pipeline/journey_times/__init__.py | 25 --- pipeline/journey_times/__main__.py | 167 ---------------- pipeline/journey_times/config.py | 23 --- pipeline/journey_times/models.py | 30 --- pipeline/journey_times/rate_limiter.py | 35 ---- pipeline/journey_times/results.py | 82 -------- pipeline/journey_times/tfl_client.py | 254 ------------------------- 7 files changed, 616 deletions(-) delete mode 100644 pipeline/journey_times/__init__.py delete mode 100644 pipeline/journey_times/__main__.py delete mode 100644 pipeline/journey_times/config.py delete mode 100644 pipeline/journey_times/models.py delete mode 100644 pipeline/journey_times/rate_limiter.py delete mode 100644 pipeline/journey_times/results.py delete mode 100644 pipeline/journey_times/tfl_client.py diff --git a/pipeline/journey_times/__init__.py b/pipeline/journey_times/__init__.py deleted file mode 100644 index 799c14b..0000000 --- a/pipeline/journey_times/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Journey times calculation module for TfL transit data.""" - -from .config import ( - DESTINATIONS, - MAX_CONCURRENT, - MAX_DELAY, - MAX_POSTCODES, - REQUESTS_PER_MIN, -) -from .models import Destination, JourneyResult -from .results import results_to_dataframe, save_results -from .tfl_client import fetch_journey_times - -__all__ = [ - "MAX_DELAY", - "REQUESTS_PER_MIN", - "MAX_POSTCODES", - "MAX_CONCURRENT", - "DESTINATIONS", - "Destination", - "JourneyResult", - "fetch_journey_times", - "results_to_dataframe", - "save_results", -] diff --git a/pipeline/journey_times/__main__.py b/pipeline/journey_times/__main__.py deleted file mode 100644 index 96494f5..0000000 --- a/pipeline/journey_times/__main__.py +++ /dev/null @@ -1,167 +0,0 @@ -import argparse -import asyncio -import random -from datetime import date, timedelta -from pathlib import Path - -import polars as pl -from tqdm import tqdm - -from .config import ( - DESTINATIONS, - MAX_CONCURRENT, - MAX_POSTCODES, - MAX_DISTANCE_KM, -) -from .models import JourneyResult -from .results import CheckpointSaver, results_to_dataframe, save_results -from .tfl_client import fetch_journey_times -from pipeline.utils import haversine_km_expr - - -def main(): - parser = argparse.ArgumentParser(description="Fetch TfL journey times") - parser.add_argument( - "--destination", - required=True, - choices=list(DESTINATIONS.keys()), - help="Destination key", - ) - parser.add_argument( - "--output-dir", - required=True, - type=Path, - help="Directory for output and checkpoint files", - ) - parser.add_argument( - "--postcodes", - required=True, - type=Path, - help="ArcGIS postcode parquet file", - ) - args = parser.parse_args() - - destination = DESTINATIONS[args.destination] - output_dir = args.output_dir - - # Calculate next Monday at 8am - today = date.today() - days_until_monday = (7 - today.weekday()) % 7 or 7 - journey_date = today + timedelta(days=days_until_monday) - journey_time = "0845" - - print(f"Destination: {destination.name}") - print( - f"Journey: {journey_date.strftime('%A %Y-%m-%d')} " - f"at {journey_time[:2]}:{journey_time[2:]}" - ) - - postcodes_df = pl.read_parquet(args.postcodes).select( - pl.col("pcds").alias("postcode"), - "lat", - "long", - ) - print(f"Loaded {postcodes_df.height:,} postcodes") - - # Filter to postcodes within range of destination - postcodes_df = postcodes_df.with_columns( - haversine_km_expr("lat", "long", destination.lat, destination.lon).alias( - "distance_km" - ) - ).filter(pl.col("distance_km") <= MAX_DISTANCE_KM) - - print(f"Filtered to {postcodes_df.height:,} postcodes within {MAX_DISTANCE_KM}km") - - postcode_data = list( - zip( - postcodes_df["postcode"].to_list(), - postcodes_df["lat"].to_list(), - postcodes_df["long"].to_list(), - ) - ) - - if MAX_POSTCODES is not None and len(postcode_data) > MAX_POSTCODES: - postcode_data = random.sample(postcode_data, MAX_POSTCODES) - print(f"Randomly sampled {MAX_POSTCODES} postcodes") - - checkpoint_saver = CheckpointSaver( - destination_name=destination.name, - output_dir=output_dir, - on_save=lambda path, count: print( - f"Checkpoint saved: {count:,} results to {path}" - ), - ) - - # Resume from checkpoint if one exists - checkpoint_path = checkpoint_saver._checkpoint_path() - prior_results: list[JourneyResult] = [] - if checkpoint_path.exists(): - checkpoint_df = pl.read_parquet(checkpoint_path) - # Deduplicate checkpoint rows per postcode, preferring rows with data - checkpoint_df = checkpoint_df.sort( - "public_transport_quick_minutes", nulls_last=True - ).unique(subset=["postcode"], keep="first") - completed_postcodes = set(checkpoint_df["postcode"].to_list()) - prior_results = [ - JourneyResult( - postcode=row["postcode"], - public_transport_easy_minutes=row["public_transport_easy_minutes"], - public_transport_quick_minutes=row["public_transport_quick_minutes"], - cycling_minutes=row["cycling_minutes"], - error=row["error"], - ) - for row in checkpoint_df.iter_rows(named=True) - ] - checkpoint_saver.results = prior_results - checkpoint_saver._last_save_count = len(prior_results) - postcode_data = [ - (pc, lat, lon) - for pc, lat, lon in postcode_data - if pc not in completed_postcodes - ] - print( - f"Resumed from checkpoint: {len(prior_results):,} already done, " - f"{len(postcode_data):,} remaining" - ) - - def on_result(result): - pbar.update(1) - checkpoint_saver.add_result(result) - - with tqdm(total=len(postcode_data), desc="Fetching journeys") as pbar: - new_results = asyncio.run( - fetch_journey_times( - postcode_data, - destination, - journey_date.strftime("%Y%m%d"), - journey_time, - MAX_CONCURRENT, - progress_callback=on_result, - ) - ) - - all_results = prior_results + new_results - results_df = results_to_dataframe(all_results) - - all_postcodes = {r.postcode for r in all_results} - coords_df = postcodes_df.filter(pl.col("postcode").is_in(all_postcodes)).select( - ["postcode", "lat", "long"] - ) - results_df = coords_df.join(results_df, on="postcode", how="left") - - results_df = results_df.with_columns( - pl.lit(destination.name).alias("destination"), - pl.lit(journey_date.strftime("%Y-%m-%d")).alias("journey_date"), - pl.lit(f"{journey_time[:2]}:{journey_time[2:]}").alias("journey_time"), - ) - - successful = results_df.filter(pl.col("cycling_minutes").is_not_null()).height - print(f"Completed: {successful}/{len(all_results)} successful") - - parquet_path = save_results(results_df, destination.name, output_dir) - checkpoint_saver.cleanup_checkpoint() - print(f"Saved to {parquet_path}") - - -if __name__ == "__main__": - main() diff --git a/pipeline/journey_times/config.py b/pipeline/journey_times/config.py deleted file mode 100644 index ed36d58..0000000 --- a/pipeline/journey_times/config.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Configuration constants for journey times processing.""" - -from .models import Destination - -MAX_DELAY = 10 -REQUESTS_PER_MIN = 500 -MAX_POSTCODES = None -MAX_CONCURRENT = 80 -MAX_DISTANCE_KM = 110 -CHECKPOINT_INTERVAL = 10000 - - -DESTINATIONS = { - "bank": Destination(51.5133, -0.0886, "Bank", "940GZZLUBNK"), - "waterloo": Destination(51.5031, -0.1132, "Waterloo", "940GZZLUWLO"), - "kings-cross": Destination(51.5308, -0.1238, "King's Cross", "940GZZLUKSX"), - "liverpool-street": Destination( - 51.5178, -0.0823, "Liverpool Street", "940GZZLULVS" - ), - "paddington": Destination(51.5154, -0.1755, "Paddington", "940GZZLUPAC"), - "victoria": Destination(51.4965, -0.1447, "Victoria", "940GZZLUVIC"), - "fitzrovia": Destination(51.5165, -0.1310, "Fitzrovia", "940GZZLUTCR"), -} diff --git a/pipeline/journey_times/models.py b/pipeline/journey_times/models.py deleted file mode 100644 index 9261357..0000000 --- a/pipeline/journey_times/models.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Data models for journey times processing.""" - -from dataclasses import dataclass - - -@dataclass -class Destination: - """A destination point for journey planning.""" - - lat: float - lon: float - name: str - naptan_id: str | None = None - - def to_tfl_location(self) -> str: - """Convert to TfL API location string.""" - if self.naptan_id: - return self.naptan_id - return f"{self.lat},{self.lon}" - - -@dataclass -class JourneyResult: - """Result of a journey time calculation for a postcode.""" - - postcode: str - public_transport_easy_minutes: int | None = None - cycling_minutes: int | None = None - public_transport_quick_minutes: int | None = None - error: str | None = None diff --git a/pipeline/journey_times/rate_limiter.py b/pipeline/journey_times/rate_limiter.py deleted file mode 100644 index eba2d1a..0000000 --- a/pipeline/journey_times/rate_limiter.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Rate limiting for TfL API requests.""" - -import asyncio -import warnings - -from .config import REQUESTS_PER_MIN - - -class RateLimiter: - """Rate limiter enforcing max requests per minute.""" - - def __init__(self): - self.request_times: list[float] = [] - self._lock = asyncio.Lock() - - async def acquire(self): - """Wait until we can make a request within rate limits.""" - async with self._lock: - now = asyncio.get_event_loop().time() - cutoff = now - 10.0 # 10 seconds - self.request_times = [t for t in self.request_times if t > cutoff] - - if ( - len(self.request_times) >= REQUESTS_PER_MIN // 6 - ): # we look at it every 10 seconds instead of minutes - wait_time = self.request_times[0] - cutoff - if wait_time > 0: - warnings.warn( - f"Rate limit reached ({REQUESTS_PER_MIN}/min), " - f"waiting {wait_time:.1f}s", - stacklevel=1, - ) - await asyncio.sleep(wait_time) - - self.request_times.append(asyncio.get_event_loop().time()) diff --git a/pipeline/journey_times/results.py b/pipeline/journey_times/results.py deleted file mode 100644 index 293e28f..0000000 --- a/pipeline/journey_times/results.py +++ /dev/null @@ -1,82 +0,0 @@ -from pathlib import Path -from typing import Callable - -import polars as pl - -from .config import CHECKPOINT_INTERVAL -from .models import JourneyResult - - -def results_to_dataframe(results: list[JourneyResult]) -> pl.DataFrame: - return pl.DataFrame( - [ - { - "postcode": r.postcode, - "public_transport_easy_minutes": r.public_transport_easy_minutes, - "public_transport_quick_minutes": r.public_transport_quick_minutes, - "cycling_minutes": r.cycling_minutes, - "error": r.error, - } - for r in results - ] - ) - - -class CheckpointSaver: - """Collects results and saves checkpoints at regular intervals.""" - - def __init__( - self, - destination_name: str, - output_dir: Path, - interval: int = CHECKPOINT_INTERVAL, - on_save: Callable[[Path, int], None] | None = None, - ): - self.destination_name = destination_name - self.output_dir = output_dir - self.interval = interval - self.on_save = on_save - self.results: list[JourneyResult] = [] - self._last_save_count = 0 - - def add_result(self, result: JourneyResult) -> None: - """Add a result and save checkpoint if interval is reached.""" - self.results.append(result) - if len(self.results) - self._last_save_count >= self.interval: - self.save_checkpoint() - - def save_checkpoint(self) -> Path: - """Save current results to checkpoint file.""" - df = results_to_dataframe(self.results) - path = self._checkpoint_path() - df.write_parquet(path) - self._last_save_count = len(self.results) - if self.on_save: - self.on_save(path, len(self.results)) - return path - - def _checkpoint_path(self) -> Path: - safe_name = self.destination_name.lower().replace(" ", "-") - return self.output_dir / f"journey_times_{safe_name}_checkpoint.parquet" - - def get_results(self) -> list[JourneyResult]: - """Return all collected results.""" - return self.results - - def cleanup_checkpoint(self) -> None: - """Remove the checkpoint file after successful completion.""" - path = self._checkpoint_path() - if path.exists(): - path.unlink() - - -def save_results( - results: pl.DataFrame, - destination_name: str, - output_dir: Path, -) -> Path: - safe_name = destination_name.lower().replace(" ", "-") - parquet_path = output_dir / f"journey_times_{safe_name}.parquet" - results.write_parquet(parquet_path) - - return parquet_path diff --git a/pipeline/journey_times/tfl_client.py b/pipeline/journey_times/tfl_client.py deleted file mode 100644 index 4ddf634..0000000 --- a/pipeline/journey_times/tfl_client.py +++ /dev/null @@ -1,254 +0,0 @@ -import asyncio -import os -from typing import Literal -import warnings -from collections.abc import Callable -from http import HTTPStatus - -import httpx - -from .config import MAX_DELAY -from .models import Destination, JourneyResult -from .rate_limiter import RateLimiter - - -BASE_URL = "https://api.tfl.gov.uk" - - -async def fetch_journey_for_mode( - client: httpx.AsyncClient, - rate_limiter: RateLimiter, - from_location: str, - to_location: str, - journey_date: str, - journey_time: str, - journey_type: Literal["quick"] | Literal["easy"] | Literal["cycle"], - retry_count: int = 5, -) -> int | None: - """Fetch journey time for a specific mode with rate limiting.""" - backoff = 1.0 - for attempt in range(retry_count): - try: - await rate_limiter.acquire() - - journey_preference = { - "quick": "LeastTime", - "easy": "LeastInterchange", - "cycle": None, - }[journey_type] - - cycle_preference = { - "quick": None, - "easy": None, - "cycle": "AllTheWay", - }[journey_type] - - # curl -s "https://api.tfl.gov.uk/Journey/Meta/Modes" | jq '.[].modeName' - mode = { - "quick": [ - "bus", - "overground", - "national-rail", - "international-rail", - "elizabeth-line", - "tube", - "coach", - "dlr", - "cable-car", - "replacement-bus", - "tram", - "river-bus", - "walking", - "cycle", - ], - "easy": [ - "bus", - "overground", - "national-rail", - "international-rail", - "elizabeth-line", - "replacement-bus", - "tube", - "coach", - "dlr", - "cable-car", - "tram", - "river-bus", - ], - "cycle": ["cycle"], - }[journey_type] - - params: dict = { - "date": journey_date, - "time": journey_time, - "nationalSearch": "true", - "timeIs": "Arriving", - "cyclePreference": cycle_preference, - "bikeProficiency": "Fast", - "walkingOptimization": str(journey_type == "quick").lower(), - "mode": ",".join(mode), - } - if journey_preference: - params["journeyPreference"] = journey_preference - - url = f"/Journey/JourneyResults/{from_location}/to/{to_location}" - response = await client.get(url, params=params) - - if response.status_code == HTTPStatus.OK: - data = response.json() - journeys = data.get("journeys", []) - if journeys: - durations = [ - j["duration"] for j in journeys if j.get("duration") is not None - ] - if durations: - return min(durations) - return None - elif response.status_code in ( - HTTPStatus.TOO_MANY_REQUESTS, - HTTPStatus.INTERNAL_SERVER_ERROR, - HTTPStatus.BAD_GATEWAY, - HTTPStatus.SERVICE_UNAVAILABLE, - HTTPStatus.GATEWAY_TIMEOUT, - ): - warnings.warn( - f"HTTP {response.status_code} for {journey_type} from {from_location}, " - f"retrying in {backoff:.1f}s (attempt {attempt + 1}/{retry_count})", - stacklevel=2, - ) - await asyncio.sleep(backoff) - backoff = min(backoff * 2, MAX_DELAY) - continue - else: - return None - except Exception as e: - warnings.warn( - f"Network error for {journey_type} from {from_location}: {e}, " - f"retrying in {backoff:.1f}s (attempt {attempt + 1}/{retry_count})", - stacklevel=2, - ) - await asyncio.sleep(backoff) - backoff = min(backoff * 2, MAX_DELAY) - continue - warnings.warn( - f"Failed to fetch {journey_type} from {from_location} after {retry_count} attempts", - stacklevel=2, - ) - return None - - -async def fetch_all_modes( - client: httpx.AsyncClient, - rate_limiter: RateLimiter, - postcode: str, - lat: float, - lon: float, - to_location: str, - journey_date: str, - journey_time: str, - semaphore: asyncio.Semaphore, -) -> JourneyResult: - """Fetch journey times for all transport modes using coordinates.""" - async with semaphore: - try: - from_location = f"{lat},{lon}" - - easy = await fetch_journey_for_mode( - client, - rate_limiter, - from_location, - to_location, - journey_date, - journey_time, - journey_type="easy", - ) - quick = await fetch_journey_for_mode( - client, - rate_limiter, - from_location, - to_location, - journey_date, - journey_time, - journey_type="quick", - ) - cycling = await fetch_journey_for_mode( - client, - rate_limiter, - from_location, - to_location, - journey_date, - journey_time, - journey_type="cycle", - ) - - return JourneyResult( - postcode=postcode, - public_transport_easy_minutes=easy, - public_transport_quick_minutes=quick, - cycling_minutes=cycling, - ) - except Exception as e: - print(f"Error: {e}") - return JourneyResult(postcode=postcode, error=str(e)) - - -async def fetch_journey_times( - postcode_data: list[tuple[str, float, float]], - dest: Destination, - journey_date: str, - journey_time: str, - max_concurrent: int = 2, - progress_callback: Callable[[JourneyResult], None] | None = None, -) -> list[JourneyResult]: - """Fetch journey times for all postcodes with rate limiting. - - Args: - postcode_data: List of (postcode, lat, lon) tuples - dest: Destination for journey planning - journey_date: Date in YYYYMMDD format - journey_time: Time in HHMM format - max_concurrent: Maximum concurrent API requests - progress_callback: Optional callback called with each result - - Returns: - List of JourneyResult objects in the same order as postcode_data - """ - semaphore = asyncio.Semaphore(max_concurrent) - to_location = dest.to_tfl_location() - rate_limiter = RateLimiter() - - # TFL API authentication via app_key query parameter - tfl_token = os.environ.get("TFL_TOKEN") - if not tfl_token: - raise RuntimeError("TFL_TOKEN environment variable not set") - params = {"app_key": tfl_token} - - async with httpx.AsyncClient( - base_url=BASE_URL, - params=params, - timeout=httpx.Timeout(30), - ) as client: - tasks = [ - fetch_all_modes( - client, - rate_limiter, - pc, - lat, - lon, - to_location, - journey_date, - journey_time, - semaphore, - ) - for pc, lat, lon in postcode_data - ] - - results = [] - for coro in asyncio.as_completed(tasks): - result = await coro - results.append(result) - if progress_callback: - progress_callback(result) - - postcode_to_result = {r.postcode: r for r in results} - return [postcode_to_result[pc] for pc, _, _ in postcode_data] From 8032011708118ff1d7391c3dd12c0fcf55e82f5c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 22 Feb 2026 22:36:40 +0000 Subject: [PATCH 5/7] Good stuff --- Makefile.data | 35 +- frontend/src/App.tsx | 13 +- .../src/components/account/AccountPage.tsx | 4 +- frontend/src/components/map/AreaPane.tsx | 2 +- .../src/components/map/FeatureBrowser.tsx | 17 +- frontend/src/components/map/Filters.tsx | 65 +- frontend/src/components/map/Map.tsx | 1 + frontend/src/components/map/MapPage.tsx | 22 +- .../src/components/map/TravelTimeCard.tsx | 8 +- .../src/components/pricing/PricingPage.tsx | 49 +- frontend/src/components/ui/FeatureLabel.tsx | 5 + .../src/components/ui/SaveSearchModal.tsx | 103 ++- frontend/src/components/ui/UserMenu.tsx | 24 +- .../src/components/ui/icons/ChartBarIcon.tsx | 21 + .../components/ui/icons/GraduationCapIcon.tsx | 20 + .../src/components/ui/icons/HouseIcon.tsx | 20 + .../src/components/ui/icons/ShieldIcon.tsx | 19 + .../components/ui/icons/ShoppingBagIcon.tsx | 21 + frontend/src/components/ui/icons/TagIcon.tsx | 20 + frontend/src/components/ui/icons/TreeIcon.tsx | 20 + .../src/components/ui/icons/UsersIcon.tsx | 22 + frontend/src/components/ui/icons/index.ts | 8 + frontend/src/hooks/useDeckLayers.ts | 7 +- frontend/src/lib/group-icons.ts | 31 + frontend/src/types.ts | 3 + pipeline/download/places.py | 34 +- pipeline/download/transit_network.py | 598 ++++++++++++++---- pipeline/transform/merge.py | 46 -- server-rs/src/data/travel_time.rs | 53 +- server-rs/src/features.rs | 116 +++- server-rs/src/routes/features.rs | 10 + server-rs/src/routes/invites.rs | 9 +- 32 files changed, 1052 insertions(+), 374 deletions(-) create mode 100644 frontend/src/components/ui/icons/ChartBarIcon.tsx create mode 100644 frontend/src/components/ui/icons/GraduationCapIcon.tsx create mode 100644 frontend/src/components/ui/icons/HouseIcon.tsx create mode 100644 frontend/src/components/ui/icons/ShieldIcon.tsx create mode 100644 frontend/src/components/ui/icons/ShoppingBagIcon.tsx create mode 100644 frontend/src/components/ui/icons/TagIcon.tsx create mode 100644 frontend/src/components/ui/icons/TreeIcon.tsx create mode 100644 frontend/src/components/ui/icons/UsersIcon.tsx create mode 100644 frontend/src/lib/group-icons.ts diff --git a/Makefile.data b/Makefile.data index 278c8bd..5eb8eb5 100644 --- a/Makefile.data +++ b/Makefile.data @@ -28,8 +28,6 @@ MERGE_STAMP := $(DATA_DIR)/.merge_done PRICE_INDEX := $(DATA_DIR)/price_index.parquet PRICES_STAMP := $(DATA_DIR)/.prices_done EPC := $(MANUAL_DATA)/certificates.csv -JT_BANK := $(MANUAL_DATA)/journey_times_bank.parquet -JT_FITZROVIA := $(MANUAL_DATA)/journey_times_fitzrovia.parquet ETHNICITY := $(DATA_DIR)/ethnicity_by_la.parquet CRIME_DIR := $(MANUAL_DATA)/crime CRIME := $(DATA_DIR)/crime_by_lsoa.parquet @@ -68,8 +66,7 @@ PMTILES_VERSION := 1.22.3 download-oa-boundaries download-uprn-lookup download-transit-network download-greenspace download-pbf download-places \ transform-pois transform-epc-pp transform-crime transform-poi-proximity \ transform-school-proximity transform-geosure transform-postcode-boundaries \ - generate-postcode-boundaries \ - journey-times + generate-postcode-boundaries prepare: $(PRICES_STAMP) merge: $(MERGE_STAMP) @@ -185,32 +182,6 @@ $(GREENSPACE): $(PBF) $(PLACES): $(PBF) uv run python -m pipeline.download.places --output $@ --pbf $(PBF) -# ── Journey times (requires TFL_API_KEY) ────────────────────────────────────── - -$(JT_BANK): - @echo "" - @echo "=== TFL journey times (bank) not found ===" - @echo "Place journey_times_bank.parquet in $(MANUAL_DATA)/" - @echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin" - @echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=bank" - @echo "" - @exit 1 - -$(JT_FITZROVIA): - @echo "" - @echo "=== TFL journey times (fitzrovia) not found ===" - @echo "Place journey_times_fitzrovia.parquet in $(MANUAL_DATA)/" - @echo "or register for a TFL API key at https://api-portal.tfl.gov.uk/signin" - @echo "and run: TFL_API_KEY=... make -f Makefile.data journey-times DEST=fitzrovia" - @echo "" - @exit 1 - -journey-times: $(ARCGIS) -ifndef DEST - $(error DEST required — e.g. make journey-times DEST=bank) -endif - uv run python -m pipeline.journey_times --destination $(DEST) --output-dir $(DATA_DIR) --postcodes $(ARCGIS) - # ── Transforms ──────────────────────────────────────────────────────────────── $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) @@ -256,15 +227,13 @@ $(PC_BOUNDARIES): # ── Final merge → postcode.parquet + properties.parquet ────────────────────── -$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) $(JT_BANK) $(JT_FITZROVIA) \ +$(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ $(ETHNICITY) $(CRIME) $(NOISE) $(SCHOOL_PROX) $(BROADBAND) $(GEOSURE) $(RENTAL) uv run python -m pipeline.transform.merge \ --epc-pp $(EPC_PP) \ --arcgis $(ARCGIS) \ --iod $(IOD) \ --poi-proximity $(POI_PROXIMITY) \ - --journey-times-bank $(JT_BANK) \ - --journey-times-fitzrovia $(JT_FITZROVIA) \ --ethnicity $(ETHNICITY) \ --crime $(CRIME) \ --noise $(NOISE) \ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b45089..8e208e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -77,7 +77,10 @@ export default function App() { const [poiCategoryGroups, setPOICategoryGroups] = useState([]); const [initialLoading, setInitialLoading] = useState(true); const [pendingInfoFeature, setPendingInfoFeature] = useState(null); - const [inviteCode, setInviteCode] = useState(null); + const [inviteCode, setInviteCode] = useState(() => { + const fromPath = pathToPage(window.location.pathname); + return fromPath?.inviteCode ?? null; + }); const [activePage, setActivePage] = useState(() => { if (isScreenshotMode) return 'dashboard'; @@ -91,13 +94,6 @@ export default function App() { return 'home'; }); - useEffect(() => { - const fromPath = pathToPage(window.location.pathname); - if (fromPath?.inviteCode) { - setInviteCode(fromPath.inviteCode); - } - }, []); - const { theme, toggleTheme } = useTheme(); const isMobile = useIsMobile(); const { @@ -366,6 +362,7 @@ export default function App() { setShowSaveModal(false)} onSave={savedSearches.saveSearch} + onViewSearches={() => { setShowSaveModal(false); navigateTo('account'); }} saving={savedSearches.saving} error={savedSearches.error} /> diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index d8f6c6b..a255c05 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -368,7 +368,7 @@ function SettingsContent({ {isLicensed && (

- {user.isAdmin ? 'Generate invite link (free access)' : 'Invite friends (30% off)'} + {user.isAdmin ? 'Invite friends (100% off)' : 'Invite friends (30% off)'}

{inviteUrl ? (
@@ -397,7 +397,7 @@ function SettingsContent({ className="px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-2" > {creatingInvite && } - {user.isAdmin ? 'Generate invite link' : 'Generate referral link'} + {user.isAdmin ? 'Generate free invite link' : 'Generate referral link'} )} {inviteError && ( diff --git a/frontend/src/components/map/AreaPane.tsx b/frontend/src/components/map/AreaPane.tsx index 56bec78..704385c 100644 --- a/frontend/src/components/map/AreaPane.tsx +++ b/frontend/src/components/map/AreaPane.tsx @@ -133,6 +133,7 @@ export default function AreaPane({ ) : stats ? (
+ {hexagonLocation && } {featureGroups.map((group) => { const hasData = group.features.some( @@ -375,7 +376,6 @@ export default function AreaPane({
); })} - {hexagonLocation && }
) : null}
diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index 3f29d59..289b24c 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -9,10 +9,10 @@ import { groupFeaturesByCategory } from '../../lib/features'; import { FeatureInfoPopup } from '../ui/FeatureInfoPopup'; import { FeatureActions } from '../ui/FeatureIcons'; import { FeatureLabel } from '../ui/FeatureLabel'; -import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon, EyeIcon } from '../ui/icons'; +import { CarIcon, BicycleIcon, WalkingIcon, TransitIcon, PlusIcon } from '../ui/icons'; import type { ComponentType } from 'react'; import { IconButton } from '../ui/IconButton'; -import { TRANSPORT_MODES, MODE_LABELS, travelFieldKey, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; +import { TRANSPORT_MODES, MODE_LABELS, type TransportMode, type TravelTimeEntry } from '../../hooks/useTravelTime'; const MODE_ICONS: Record> = { car: CarIcon, @@ -96,9 +96,6 @@ export default function FeatureBrowser({ {(isSearching || expandedGroups.has('Travel Time')) && TRANSPORT_MODES.map((mode) => { - const activeEntry = travelTimeEntries.find((e) => e.mode === mode && e.slug); - const fieldKey = activeEntry ? travelFieldKey(activeEntry) : null; - const isPinned = fieldKey !== null && pinnedFeature === fieldKey; const ModeIcon = MODE_ICONS[mode]; return (
- {fieldKey && ( - onTogglePin(fieldKey)} - active={isPinned} - title={isPinned ? 'Unpin color view' : 'Color map by this feature'} - size="md" - > - - - )} onAddTravelTimeEntry(mode)} title={`Add ${MODE_LABELS[mode]} travel time`} size="md"> diff --git a/frontend/src/components/map/Filters.tsx b/frontend/src/components/map/Filters.tsx index 04cbe09..e3ad939 100644 --- a/frontend/src/components/map/Filters.tsx +++ b/frontend/src/components/map/Filters.tsx @@ -25,15 +25,6 @@ import { type ListingType = 'historical' | 'buy' | 'rent'; -const MODE_RESTRICTED_FEATURES: Record> = { - 'Bathrooms': new Set(['buy', 'rent']), -}; - -function isFeatureAllowedInMode(featureName: string, mode: ListingType): boolean { - const allowed = MODE_RESTRICTED_FEATURES[featureName]; - return !allowed || allowed.has(mode); -} - function SliderLabels({ min, max, @@ -89,7 +80,6 @@ interface FiltersProps { openInfoFeature?: string | null; onClearOpenInfoFeature?: () => void; travelTimeEntries: TravelTimeEntry[]; - travelTimeDataRanges: Map; onTravelTimeAddEntry: (mode: TransportMode) => void; onTravelTimeRemoveEntry: (index: number) => void; onTravelTimeSetDestination: (index: number, slug: string, label: string) => void; @@ -122,7 +112,6 @@ export default memo(function Filters({ openInfoFeature, onClearOpenInfoFeature, travelTimeEntries, - travelTimeDataRanges, onTravelTimeAddEntry, onTravelTimeRemoveEntry, onTravelTimeSetDestination, @@ -136,6 +125,36 @@ export default memo(function Filters({ onUpgradeClick, onResetTutorial, }: FiltersProps) { + const modeRestrictions = useMemo(() => { + const map: Record> = {}; + for (const f of features) { + if (f.modes && f.modes.length > 0) { + map[f.name] = new Set(f.modes as ListingType[]); + } + } + return map; + }, [features]); + + const linkedFeatures = useMemo(() => { + const pairs: [string, string][] = []; + const seen = new Set(); + for (const f of features) { + if (f.linked && !seen.has(f.name)) { + pairs.push([f.name, f.linked]); + seen.add(f.linked); + } + } + return pairs; + }, [features]); + + const isAllowed = useCallback( + (name: string, mode: ListingType) => { + const allowed = modeRestrictions[name]; + return !allowed || allowed.has(mode); + }, + [modeRestrictions] + ); + const activeListingType = useMemo((): ListingType => { const val = filters['Listing status'] as string[] | undefined; if (!val || val.length === 0) return 'historical'; @@ -145,8 +164,8 @@ export default memo(function Filters({ }, [filters]); const availableFeatures = useMemo( - () => features.filter((f) => !enabledFeatures.has(f.name) && isFeatureAllowedInMode(f.name, activeListingType)), - [features, enabledFeatures, activeListingType] + () => features.filter((f) => !enabledFeatures.has(f.name) && isAllowed(f.name, activeListingType)), + [features, enabledFeatures, activeListingType, isAllowed] ); const enabledFeatureList = useMemo( () => features.filter((f) => enabledFeatures.has(f.name) && f.name !== 'Listing status'), @@ -156,7 +175,22 @@ export default memo(function Filters({ const handleListingSelect = useCallback( (type: ListingType) => { for (const name of Object.keys(filters)) { - if (name !== 'Listing status' && !isFeatureAllowedInMode(name, type)) { + if (name === 'Listing status') continue; + if (isAllowed(name, type)) continue; + + // Check if this feature has a linked counterpart in the new mode + let swapped = false; + for (const [a, b] of linkedFeatures) { + const counterpart = name === a ? b : name === b ? a : null; + if (counterpart && isAllowed(counterpart, type)) { + onFilterChange(counterpart, filters[name] as [number, number]); + onRemoveFilter(name); + swapped = true; + break; + } + } + + if (!swapped) { onRemoveFilter(name); } } @@ -167,7 +201,7 @@ export default memo(function Filters({ }; onFilterChange('Listing status', [valueMap[type]]); }, - [filters, onFilterChange, onRemoveFilter] + [filters, onFilterChange, onRemoveFilter, isAllowed, linkedFeatures] ); const containerRef = useRef(null); @@ -275,7 +309,6 @@ export default memo(function Filters({ label={entry.label} timeRange={entry.timeRange} useBest={entry.useBest} - dataRange={travelTimeDataRanges.get(index) ?? null} isPinned={pinnedFeature === travelFieldKey(entry)} onTogglePin={() => onTogglePin(travelFieldKey(entry))} onSetDestination={(slug, label) => onTravelTimeSetDestination(index, slug, label)} diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index f43f87b..34790ea 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -170,6 +170,7 @@ export default memo(function Map({ data, postcodeData, usePostcodeView, + zoom: viewState.zoom, pois, viewFeature, colorRange, diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 4c92ec7..15bffa2 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -188,24 +188,6 @@ export default function MapPage({ const pois = usePOIData(mapData.bounds, selectedPOICategories); - const travelTimeDataRanges = useMemo((): globalThis.Map => { - const ranges = new globalThis.Map(); - for (let i = 0; i < travelTime.entries.length; i++) { - const entry = travelTime.entries[i]; - if (!entry.slug) continue; - const fieldName = `avg_${travelFieldKey(entry)}`; - const vals: number[] = []; - for (const item of mapData.data) { - const val = item[fieldName]; - if (typeof val === 'number' && !isNaN(val)) vals.push(val); - } - if (vals.length === 0) continue; - vals.sort((a, b) => a - b); - ranges.set(i, [vals[0], vals[vals.length - 1]]); - } - return ranges; - }, [travelTime.entries, mapData.data]); - useUrlSync(mapData.currentView, filters, features, selectedPOICategories, selection.rightPaneTab, travelTime.entries); useEffect(() => { @@ -401,7 +383,6 @@ export default function MapPage({ openInfoFeature={pendingInfoFeature} onClearOpenInfoFeature={onClearPendingInfoFeature} travelTimeEntries={travelTime.entries} - travelTimeDataRanges={travelTimeDataRanges} onTravelTimeAddEntry={travelTime.handleAddEntry} onTravelTimeRemoveEntry={handleTravelTimeRemoveEntry} onTravelTimeSetDestination={handleTravelTimeSetDestination} @@ -625,9 +606,10 @@ export default function MapPage({ {/* Floating POI panel */} {poiPaneOpen && ( diff --git a/frontend/src/components/map/TravelTimeCard.tsx b/frontend/src/components/map/TravelTimeCard.tsx index 3a68a2e..81df15a 100644 --- a/frontend/src/components/map/TravelTimeCard.tsx +++ b/frontend/src/components/map/TravelTimeCard.tsx @@ -27,7 +27,6 @@ interface TravelTimeCardProps { label: string; timeRange: [number, number] | null; useBest: boolean; - dataRange: [number, number] | null; isPinned: boolean; onTogglePin: () => void; onSetDestination: (slug: string, label: string) => void; @@ -42,7 +41,6 @@ export function TravelTimeCard({ label, timeRange, useBest, - dataRange, isPinned, onTogglePin, onSetDestination, @@ -74,8 +72,8 @@ export function TravelTimeCard({ [onSetDestination, search.clear], ); - const sliderMin = dataRange ? Math.floor(dataRange[0]) : 0; - const sliderMax = dataRange ? Math.ceil(dataRange[1]) : 120; + const sliderMin = 0; + const sliderMax = 120; const displayRange = timeRange ?? [sliderMin, sliderMax]; const ModeIcon = MODE_ICONS[mode]; @@ -142,7 +140,7 @@ export function TravelTimeCard({ )} {/* Time range slider — only show when we have data */} - {slug && dataRange && ( + {slug && (
Max time diff --git a/frontend/src/components/pricing/PricingPage.tsx b/frontend/src/components/pricing/PricingPage.tsx index 0e6fcc9..7c7a8ce 100644 --- a/frontend/src/components/pricing/PricingPage.tsx +++ b/frontend/src/components/pricing/PricingPage.tsx @@ -58,16 +58,6 @@ export default function PricingPage({ if (scrollRef.current) setScrolledLeft(scrollRef.current.scrollLeft > 0); }, []); - useEffect(() => { - if (!pricing || !scrollRef.current || !activeCardRef.current) return; - if (currentTierIndex === 0) return; - const container = scrollRef.current; - const card = activeCardRef.current; - const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; - container.scrollLeft = Math.max(0, scrollLeft); - setScrolledLeft(container.scrollLeft > 0); - }, [pricing, currentTierIndex]); - useEffect(() => { fetch(apiUrl('pricing')) .then((res) => { @@ -98,6 +88,16 @@ export default function PricingPage({ } } + useEffect(() => { + if (!pricing || !scrollRef.current || !activeCardRef.current) return; + if (currentTierIndex === 0) return; + const container = scrollRef.current; + const card = activeCardRef.current; + const scrollLeft = card.offsetLeft - container.offsetLeft - (container.clientWidth - card.offsetWidth) / 2; + container.scrollLeft = Math.max(0, scrollLeft); + setScrolledLeft(container.scrollLeft > 0); + }, [pricing, currentTierIndex]); + const ctaButton = isLicensed ? (
-
+

Early access pricing

- No subscriptions, no recurring fees. Pay once and get lifetime - access to every feature. The earlier you join, the less you pay. + Pay once, access forever. The earlier you join, the less you pay. +

+
+ +
+

+ Buying a home costs £10k+ in stamp duty, £1,500 in solicitor fees, + £500 for a survey. Get the wrong area and you're stuck with a long + commute, bad schools, or a road you didn't know about. +

+

+ Less than your survey costs. Vastly more useful.

@@ -203,7 +213,7 @@ export default function PricingPage({
{scrolledLeft &&
}
-
+
{pricing.tiers.map((tier, i) => { const isCurrent = i === currentTierIndex; const isFilled = @@ -348,17 +358,6 @@ export default function PricingPage({ )}
-
-

- Stamp duty on a £400k house: £10,000. Solicitor fees: £1,500. - Survey: £500. Moving costs: £1,000. And that's just the money. Get the - wrong area and you're stuck — with a long commute, bad schools, or a street - that looked fine on the listing photos but turns out to be on a motorway. -

-

- One payment. Lifetime access. Less than your survey costs and vastly more useful. -

-
); } diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx index 3bcaa2c..5c4487f 100644 --- a/frontend/src/components/ui/FeatureLabel.tsx +++ b/frontend/src/components/ui/FeatureLabel.tsx @@ -1,5 +1,6 @@ import type { FeatureMeta } from '../../types'; import { InfoIcon } from './icons'; +import { getGroupIcon } from '../../lib/group-icons'; interface FeatureLabelProps { feature: FeatureMeta; @@ -15,11 +16,15 @@ export function FeatureLabel({ size = 'xs', }: FeatureLabelProps) { const textClass = size === 'sm' ? 'text-sm' : 'text-xs'; + const GroupIcon = feature.group ? getGroupIcon(feature.group) : null; return (
+ {GroupIcon && ( + + )} diff --git a/frontend/src/components/ui/SaveSearchModal.tsx b/frontend/src/components/ui/SaveSearchModal.tsx index c8da9e7..0fd7611 100644 --- a/frontend/src/components/ui/SaveSearchModal.tsx +++ b/frontend/src/components/ui/SaveSearchModal.tsx @@ -1,19 +1,23 @@ import { useState, useCallback, useEffect } from 'react'; +import { CheckIcon } from './icons/CheckIcon'; import { CloseIcon } from './icons/CloseIcon'; import { SpinnerIcon } from './icons/SpinnerIcon'; export default function SaveSearchModal({ onClose, onSave, + onViewSearches, saving, error, }: { onClose: () => void; onSave: (name: string) => Promise; + onViewSearches: () => void; saving: boolean; error: string | null; }) { const [name, setName] = useState(''); + const [saved, setSaved] = useState(false); const handleSubmit = useCallback( async (e: React.FormEvent) => { @@ -21,12 +25,12 @@ export default function SaveSearchModal({ if (!name.trim() || saving) return; try { await onSave(name.trim()); - onClose(); + setSaved(true); } catch { // Error displayed in modal } }, - [name, saving, onSave, onClose] + [name, saving, onSave] ); useEffect(() => { @@ -45,7 +49,9 @@ export default function SaveSearchModal({ onClick={(e) => e.stopPropagation()} >
-

Save Search

+

+ {saved ? 'Search saved' : 'Save Search'} +

-
-
- - setName(e.target.value)} - className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" - placeholder="My search" - autoFocus - /> + {saved ? ( +
+
+ +

+ Your search has been saved successfully. +

+
+
+ + +
+ ) : ( + +
+ + setName(e.target.value)} + className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" + placeholder="My search" + autoFocus + /> +
- {error &&

{error}

} + {error &&

{error}

} -
- - -
- +
+ + +
+ + )}
); diff --git a/frontend/src/components/ui/UserMenu.tsx b/frontend/src/components/ui/UserMenu.tsx index 3d84025..04f3f68 100644 --- a/frontend/src/components/ui/UserMenu.tsx +++ b/frontend/src/components/ui/UserMenu.tsx @@ -38,11 +38,29 @@ export default function UserMenu({ {open && (
-

- {user.email} -

+
+

+ {user.email} +

+ + {user.subscription === 'licensed' || user.isAdmin ? 'Pro' : 'Free'} + +
+ setOpen(false)} + className="block w-full text-left px-3 py-2 text-sm text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700 rounded" + > + Account +