Compare commits

..

9 commits

Author SHA1 Message Date
cf348c3ea4 new storyboard
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 8m50s
CI / Check (push) Failing after 10m22s
2026-07-13 21:03:38 +01:00
f14981afee scrape loop 2026-07-13 21:03:14 +01:00
61114833b7 Rerender 2026-07-12 22:16:52 +01:00
f4d1b58bd8 release script 2026-07-12 21:36:50 +01:00
d7f844d566 Better 2026-07-12 21:31:13 +01:00
ca771a7edf fine 2 2026-07-12 20:37:39 +01:00
9e4e65fa2a fine 2026-07-12 20:30:19 +01:00
6df2812a4e Rename and purple instant 2026-07-12 15:31:05 +01:00
f948efc06c Refactor and show password 2026-07-12 15:23:29 +01:00
105 changed files with 3261 additions and 435 deletions

View file

@ -23,6 +23,10 @@ jobs:
- name: Install Python dependencies
run: uv sync
- name: Install finder (scraper) dependencies
working-directory: finder
run: uv sync
- uses: actions/setup-node@v4
with:
node-version: 22

2
.gitignore vendored
View file

@ -27,5 +27,5 @@ video/auth.*
r5-java/tmp
property-data
property-data-snapshot
property-data-snapshot2
property-data-snapshot*
video/.audit*

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.0.2

View file

@ -14,6 +14,14 @@ step "Python lint: ruff" uv run ruff check .
step "Python dependency lint: deptry" uv run deptry .
step "Python unit tests" uv run pytest pipeline
(
cd "$ROOT_DIR/finder"
# finder is a separate uv project (the scraper) with its own venv, so the
# root `pytest pipeline` run above never reaches it. pytest is not a declared
# finder dependency, so pull it in ephemerally as its README documents.
step "Finder (scraper) unit tests" uv run --with pytest pytest -q
)
(
cd "$ROOT_DIR/frontend"
step "Frontend lint: ESLint" npm run lint

View file

@ -30,6 +30,13 @@ services:
- cargo-home:/usr/local/cargo
- cargo-target:/app/server-rs/target
environment:
# Disable incremental compilation. cargo-watch does incremental
# rebuilds over the mounted source; editing a struct with a generic
# serde impl mid-flight can corrupt the incremental cache and produce
# `rust-lld: undefined hidden symbol ...::serialize` link failures
# that never resolve until a clean rebuild. Off = slightly slower
# warm rebuilds, but no such corruption.
CARGO_INCREMENTAL: "0"
# Fallback only: the binary uses jemalloc as its global allocator
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
MALLOC_ARENA_MAX: "2"

120
finder/price_history.py Normal file
View file

@ -0,0 +1,120 @@
"""Forward-only asking-price history, accrued across recurring scrape runs.
Rightmove (and the other portals) never expose a listing's full asking-price
timeline: a detail page carries only the current price plus one most-recent
"Reduced on <date>" event, and the previous price is never published. The only
way to obtain a real "listed at X, reduced to Y" series is therefore to record
each listing's price ourselves every run and diff it over time.
This module keeps a persistent, listing-id-keyed store of price observations:
{"<listing id>": [{"date": "YYYY-MM-DD", "price": 425000, "reason": "listed"},
{"date": "YYYY-MM-DD", "price": 410000, "reason": "reduced"}]}
Entries are oldest -> newest. A new point is appended only when the price
actually changes (or on first sight), so an unchanged listing costs nothing. The
store is seeded from / dumped to disk with the same atomic JSON helpers as the
detail-postcode caches (see postcode_cache.py), so a recurring scrape extends the
history rather than rebuilding it.
Two hard limitations, both inherent to the data source:
* There is NO backfill. History accrues only from the first instrumented run;
prior asking prices cannot be reconstructed (portals don't publish them and
we kept no snapshots).
* On a listing's first sight we know exactly one price, so we record one point.
If the portal's own most-recent event says that price was itself a reduction/
increase, we date and label that single point accordingly; we still cannot
invent the pre-change price.
"""
import logging
import re
from pathlib import Path
from postcode_cache import load_cache, save_cache
log = logging.getLogger("rightmove")
# Portal `listingUpdateReason` codes -> our canonical reasons. Anything not a
# recognised price move (e.g. "new", "under_offer", "auction", or absent) leaves
# the first-sight point labelled "listed" and never fabricates a change.
_REASON_MAP = {
"price_reduced": "reduced",
"price_increased": "increased",
}
_ISO_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})")
def normalize_reason(raw: object) -> str | None:
"""Map a portal update-reason code to 'reduced'/'increased', else None."""
if not isinstance(raw, str):
return None
return _REASON_MAP.get(raw.strip().lower())
def _iso_to_date(value: object) -> str | None:
"""Return the YYYY-MM-DD prefix of an ISO timestamp, or None."""
if not isinstance(value, str):
return None
match = _ISO_DATE_RE.match(value.strip())
return match.group(1) if match else None
def load_history(path: str | Path) -> dict:
"""Load the persisted asking-price history. Returns {} when absent/unreadable."""
return load_cache(path)
def save_history(path: str | Path, history: dict) -> None:
"""Atomically persist the asking-price history to disk."""
save_cache(path, history)
def update_history(history: dict, listings: list[dict], run_date: str) -> None:
"""Fold one run's listings into ``history`` in place.
``run_date`` is the ISO date (YYYY-MM-DD) of the scrape. For each listing with
a stable id and a positive price:
* first sight -> append one point. Its date/reason come from the portal's
most-recent change event when that event is a price move (so a listing we
first meet already-reduced reads "Reduced on <event date>"); otherwise the
point is the "listed" price dated to ``first_visible_date`` (falling back
to ``run_date``).
* later runs -> append a point ONLY when the price differs from the last
recorded one, labelled "reduced"/"increased" by direction and dated to
``run_date`` (we only know the change happened by this run).
An unchanged price is a no-op, so the series stays a list of genuine moves.
"""
for listing in listings:
listing_id_raw = listing.get("id")
if listing_id_raw is None:
continue
listing_id = str(listing_id_raw).strip()
if not listing_id:
continue
try:
price = int(listing.get("price") or 0)
except (TypeError, ValueError):
continue
if price <= 0:
continue
entries = history.get(listing_id)
if not isinstance(entries, list) or not entries:
reason = normalize_reason(listing.get("listing_update_reason"))
if reason is not None:
date = _iso_to_date(listing.get("listing_update_date")) or run_date
else:
reason = "listed"
date = _iso_to_date(listing.get("first_visible_date")) or run_date
history[listing_id] = [{"date": date, "price": price, "reason": reason}]
continue
last_price = entries[-1].get("price")
if last_price == price:
continue
reason = "reduced" if last_price is not None and price < last_price else "increased"
entries.append({"date": run_date, "price": price, "reason": reason})

View file

@ -5,6 +5,7 @@ import signal
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from datetime import datetime, timezone
from functools import partial
from pathlib import Path
from typing import Callable, Iterable
@ -34,6 +35,7 @@ from postcode_cache import load_cache, save_cache
from rightmove import resolve_outcode_id
from rightmove import search_outcode as rightmove_search_outcode
from spatial import PostcodeSpatialIndex
from price_history import load_history, save_history, update_history
from storage import write_parquet
from zoopla import TurnstileError
from zoopla import launch_browser as launch_zoopla_browser
@ -839,7 +841,18 @@ def run_scrape(
merged, source_counts, deduped = _merge_properties(results)
output_path = output_base / "online_listings_buy.parquet"
if merged:
write_parquet(merged, output_path)
# Accrue the per-listing asking-price history before writing: load the
# persistent store, append this run's price moves, dump it, then embed
# each listing's series in the parquet. Forward-only by nature — the
# first run seeds one point per listing and reductions appear over time.
history_path = output_base / "price_history" / "listings.json"
history = load_history(history_path)
run_date = (
datetime.fromtimestamp(started_at, tz=timezone.utc).strftime("%Y-%m-%d")
)
update_history(history, merged, run_date)
save_history(history_path, history)
write_parquet(merged, output_path, price_history=history)
else:
if output_path.exists():
output_path.unlink()

View file

@ -10,8 +10,16 @@ from transform import map_property_type, normalize_postcode
log = logging.getLogger("rightmove")
def write_parquet(properties: list[dict], path: Path) -> None:
"""Write sale properties list to parquet with server-ready column names."""
def write_parquet(
properties: list[dict], path: Path, price_history: dict | None = None
) -> None:
"""Write sale properties list to parquet with server-ready column names.
``price_history`` is the persistent listing-id -> [{date, price, reason}]
store (see finder/price_history.py); each listing's accrued asking-price
series is embedded as a ``price_history`` list column. Absent id -> empty
list, so pre-instrumentation runs and tests simply write empty series.
"""
if not properties:
log.warning("No properties to write to %s", path)
return
@ -95,6 +103,14 @@ def write_parquet(properties: list[dict], path: Path) -> None:
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
listing_statuses = ["For sale"] * len(properties)
# Accrued asking-price history per listing (oldest -> newest). Look up with
# the SAME normalisation the store keys on (str(id).strip(), matching
# price_history.update_history and scraper dedup); an unseen id -> empty.
history_lookup = price_history or {}
price_history_col = [
history_lookup.get(str(p.get("id")).strip(), []) for p in properties
]
df = pl.DataFrame(
{
"Bedrooms": [p["Bedrooms"] for p in properties],
@ -144,6 +160,7 @@ def write_parquet(properties: list[dict], path: Path) -> None:
"Listing date": listing_dates,
"Listing status": listing_statuses,
"Asking price": asking_prices,
"price_history": price_history_col,
},
schema={
"Bedrooms": pl.Int32,
@ -169,6 +186,11 @@ def write_parquet(properties: list[dict], path: Path) -> None:
"Listing date": pl.Datetime("us"),
"Listing status": pl.Utf8,
"Asking price": pl.Int64,
"price_history": pl.List(
pl.Struct(
{"date": pl.Utf8, "price": pl.Int64, "reason": pl.Utf8}
)
),
},
)

View file

@ -0,0 +1,77 @@
"""Tests for the forward-only asking-price history store (price_history.py)."""
from price_history import normalize_reason, update_history
def test_first_sight_new_listing_records_listed_at_first_visible_date() -> None:
history: dict = {}
update_history(
history,
[{"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}],
"2026-07-12",
)
assert history["A"] == [{"date": "2026-07-01", "price": 500000, "reason": "listed"}]
def test_first_sight_already_reduced_uses_event_date_and_reason() -> None:
history: dict = {}
update_history(
history,
[
{
"id": "B",
"price": 425000,
"first_visible_date": "2026-06-01T09:00:00Z",
"listing_update_reason": "price_reduced",
"listing_update_date": "2026-07-10T14:00:00Z",
}
],
"2026-07-12",
)
assert history["B"] == [{"date": "2026-07-10", "price": 425000, "reason": "reduced"}]
def test_later_run_appends_only_on_price_change_with_direction() -> None:
history: dict = {}
listing = {"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}
update_history(history, [listing], "2026-07-12")
# Unchanged price -> no new point.
update_history(history, [{"id": "A", "price": 500000}], "2026-07-19")
assert len(history["A"]) == 1
# Reduced -> appended, dated to the run.
update_history(history, [{"id": "A", "price": 480000}], "2026-07-26")
# Increased -> appended.
update_history(history, [{"id": "A", "price": 490000}], "2026-08-02")
assert history["A"] == [
{"date": "2026-07-01", "price": 500000, "reason": "listed"},
{"date": "2026-07-26", "price": 480000, "reason": "reduced"},
{"date": "2026-08-02", "price": 490000, "reason": "increased"},
]
def test_zero_or_missing_price_and_id_are_skipped() -> None:
history: dict = {}
update_history(
history,
[
{"id": "POA", "price": 0},
{"id": "", "price": 100000},
{"price": 100000}, # no id
],
"2026-07-12",
)
assert history == {}
def test_run_date_fallback_when_dates_absent() -> None:
history: dict = {}
update_history(history, [{"id": "A", "price": 300000}], "2026-07-12")
assert history["A"] == [{"date": "2026-07-12", "price": 300000, "reason": "listed"}]
def test_normalize_reason_maps_only_price_moves() -> None:
assert normalize_reason("price_reduced") == "reduced"
assert normalize_reason("price_increased") == "increased"
assert normalize_reason("new") is None
assert normalize_reason("under_offer") is None
assert normalize_reason(None) is None

View file

@ -125,7 +125,9 @@ def test_run_scrape_runs_all_sources_merges_and_persists_caches(tmp_path, monkey
monkeypatch.setattr(
scraper,
"write_parquet",
lambda props, path: written.update(count=len(props), path=path),
lambda props, path, price_history=None: written.update(
count=len(props), path=path, price_history=price_history
),
)
result = scraper.run_scrape(

View file

@ -385,6 +385,16 @@ def transform_property(
if not listing_id:
return None
# The search API already carries the single most-recent price-change event
# (reason + ISO date) with no extra request. Rightmove never exposes the
# previous price, so this only seeds the accruing asking-price history (see
# finder/price_history.py); it is not written to the output parquet itself.
listing_update = prop.get("listingUpdate")
if not isinstance(listing_update, dict):
listing_update = {}
listing_update_reason = listing_update.get("listingUpdateReason")
listing_update_date = listing_update.get("listingUpdateDate")
return {
"id": listing_id,
"Bedrooms": bedrooms,
@ -411,4 +421,7 @@ def transform_property(
"Listing URL": build_listing_url(property_url, bool(prop.get("development"))),
"Listing features": key_features,
"first_visible_date": prop.get("firstVisibleDate", ""),
# Fed into the asking-price history store, not the parquet directly.
"listing_update_reason": listing_update_reason,
"listing_update_date": listing_update_date,
}

BIN
frontend/public/video/ad-01-say-it.jpg (Stored with Git LFS)

Binary file not shown.

BIN
frontend/public/video/ad-01-say-it.mp4 (Stored with Git LFS)

Binary file not shown.

View file

@ -1,14 +1,14 @@
WEBVTT
00:00:00.201 --> 00:00:03.961
00:00:00.203 --> 00:00:03.803
My whole house brief is one plain sentence.
00:00:04.361 --> 00:00:08.841
00:00:04.103 --> 00:00:08.023
Every postcode in England that fits, sorted by value.
00:00:10.141 --> 00:00:14.941
00:00:09.103 --> 00:00:13.423
Same schools, same commute, the price quietly drops nearby.
00:00:15.591 --> 00:00:19.511
00:00:13.923 --> 00:00:17.843
The underpriced twin is on this map. Find it.

Binary file not shown.

Binary file not shown.

View file

@ -3,12 +3,12 @@ WEBVTT
00:00:02.121 --> 00:00:05.961
Every colour on this map is a commute to central London.
00:00:06.361 --> 00:00:09.801
00:00:06.291 --> 00:00:09.731
Here's what twenty minutes actually leaves you.
00:00:11.301 --> 00:00:16.741
00:00:10.961 --> 00:00:15.041
Same twenty minutes wherever it's lit, but not the same price.
00:00:17.391 --> 00:00:21.151
00:00:15.541 --> 00:00:19.221
The commute is priced in. The bargain is not.

Binary file not shown.

Binary file not shown.

View file

@ -1,14 +1,14 @@
WEBVTT
00:00:01.021 --> 00:00:05.341
00:00:01.021 --> 00:00:05.901
One name costs more. The block next door scores the same.
00:00:05.741 --> 00:00:11.581
00:00:06.231 --> 00:00:12.071
Sold prices, schools, crime, even Street View, for this exact postcode.
00:00:12.031 --> 00:00:17.071
00:00:12.451 --> 00:00:17.011
The same evidence a pricey postcode has, sitting quietly cheaper here.
00:00:17.671 --> 00:00:22.071
00:00:17.511 --> 00:00:22.711
Every postcode, proven on the numbers, not its reputation.

Binary file not shown.

Binary file not shown.

View file

@ -3,12 +3,12 @@ WEBVTT
00:00:00.201 --> 00:00:04.441
Listing photos are silent. Main roads are not.
00:00:04.841 --> 00:00:07.881
00:00:04.771 --> 00:00:07.811
This is London under fifty-five decibels.
00:00:09.181 --> 00:00:13.021
00:00:08.891 --> 00:00:12.491
Nearby, just as quiet, and overlooked.
00:00:13.671 --> 00:00:16.071
00:00:12.991 --> 00:00:15.391
The quiet street nobody bid up.

BIN
frontend/public/video/ad-05-school-run.jpg (Stored with Git LFS)

Binary file not shown.

BIN
frontend/public/video/ad-05-school-run.mp4 (Stored with Git LFS)

Binary file not shown.

View file

@ -3,12 +3,12 @@ WEBVTT
00:00:00.201 --> 00:00:06.521
Good primary, low crime, under three hundred and fifty. The actual family brief.
00:00:06.921 --> 00:00:11.081
00:00:06.851 --> 00:00:10.611
Everything still on the map passes all three filters.
00:00:12.381 --> 00:00:17.741
00:00:11.691 --> 00:00:17.211
Same primary, same low crime, without the name everyone else is bidding up.
00:00:18.391 --> 00:00:21.831
00:00:17.711 --> 00:00:20.831
The schools are real. The premium is optional.

Binary file not shown.

Binary file not shown.

View file

@ -1,14 +1,14 @@
WEBVTT
00:00:00.204 --> 00:00:05.724
00:00:00.202 --> 00:00:05.722
A Waitrose, a tube stop, a park nearby. Search by what you want.
00:00:06.124 --> 00:00:10.204
00:00:06.052 --> 00:00:10.132
London, narrowed to the postcodes that fit the way you live.
00:00:11.504 --> 00:00:14.704
00:00:11.333 --> 00:00:14.373
Same amenities, lower price nearby.
00:00:15.354 --> 00:00:19.354
00:00:14.873 --> 00:00:19.113
Same Waitrose. Same tube. Cheaper postcode.

Binary file not shown.

Binary file not shown.

View file

@ -1,14 +1,14 @@
WEBVTT
00:00:00.201 --> 00:00:05.561
00:00:00.201 --> 00:00:05.241
Rent under sixteen hundred, half an hour to central, on a quiet street.
00:00:05.961 --> 00:00:10.201
00:00:05.571 --> 00:00:10.131
Letting sites rank flats. This ranks the streets around them.
00:00:11.501 --> 00:00:15.821
00:00:11.211 --> 00:00:15.131
Same commute, same quiet, lower rent nearby.
00:00:16.471 --> 00:00:19.831
00:00:15.631 --> 00:00:18.831
The name costs more. The street does not.

View file

@ -1,14 +1,14 @@
WEBVTT
00:00:00.201 --> 00:00:05.641
00:00:00.201 --> 00:00:05.721
Two streets, same schools, same commute, priced tens of thousands apart.
00:00:06.041 --> 00:00:10.201
00:00:06.051 --> 00:00:10.771
You pay for the postcode's name; the value sits a postcode away.
00:00:11.501 --> 00:00:15.581
00:00:11.851 --> 00:00:16.411
Pay once, find the bargain, stop overpaying for the name.
00:00:16.231 --> 00:00:19.351
00:00:16.911 --> 00:00:19.551
Buy value, not reputation.

View file

@ -1,14 +1,17 @@
WEBVTT
00:00:00.203 --> 00:00:06.283
Beckenham and Croydon sit side by side: same trains, same school catchment.
00:00:00.211 --> 00:00:07.251
Beckenham and Croydon sit two kilometres apart, on the same trains, in the same school catchments.
00:00:06.683 --> 00:00:10.523
Rank them by what each pound of floor space actually buys.
00:00:07.551 --> 00:00:12.591
Now colour every postcode by what a square metre of home actually costs there.
00:00:11.823 --> 00:00:16.943
One postcode over, the same home quietly costs about a third less.
00:00:13.671 --> 00:00:19.831
Green means cheaper floor space. Croydon runs about a third under Beckenham, street for street.
00:00:18.093 --> 00:00:22.893
00:00:20.261 --> 00:00:24.101
That's over two hundred thousand pounds back on an average home.
00:00:24.701 --> 00:00:30.701
Beckenham's cheaper twin is on this map. Find yours, free.

View file

@ -1,14 +1,17 @@
WEBVTT
00:00:00.202 --> 00:00:05.882
Stanmore and Kenton sit right next door, with the same schools and transport links.
00:00:00.203 --> 00:00:06.683
Stanmore and Kenton sit two and a half kilometres apart, with the same schools and transport links.
00:00:06.282 --> 00:00:10.842
Rank every postcode by what each pound of floor space actually buys.
00:00:06.983 --> 00:00:12.023
Now colour every postcode by what a square metre of home actually costs there.
00:00:12.142 --> 00:00:17.422
One postcode over, the same home quietly costs about a sixth less.
00:00:13.103 --> 00:00:19.103
Green means cheaper floor space. Kenton runs about a sixth under Stanmore, street for street.
00:00:18.572 --> 00:00:22.652
00:00:19.533 --> 00:00:23.213
That's more than a hundred thousand pounds back on an average home.
00:00:23.813 --> 00:00:28.853
Stanmore's cheaper twin is on this map. Find yours, free.

View file

@ -1,14 +1,17 @@
WEBVTT
00:00:00.203 --> 00:00:06.843
Childwall and Broadgreen sit right next door, with the same schools and transport links.
00:00:00.201 --> 00:00:07.081
Childwall and Broadgreen sit under two kilometres apart, with the same schools and transport links.
00:00:07.243 --> 00:00:11.803
Rank every postcode by what each pound of floor space actually buys.
00:00:07.381 --> 00:00:12.421
Now colour every postcode by what a square metre of home actually costs there.
00:00:13.103 --> 00:00:18.223
One postcode over, the same home quietly costs about a third less.
00:00:13.501 --> 00:00:19.901
Green means cheaper floor space. Broadgreen runs nearly a third under Childwall, street for street.
00:00:19.373 --> 00:00:24.493
00:00:20.331 --> 00:00:24.571
That's well over a hundred thousand pounds back on an average home.
00:00:25.171 --> 00:00:30.691
Childwall's cheaper twin is on this map. Find yours, free.

View file

@ -17,7 +17,7 @@ import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
import { trackEvent } from './lib/analytics';
import { parseUrlState } from './lib/url-state';
import pb from './lib/pocketbase';
import { DEFAULT_DEMO_FILTERS, INITIAL_VIEW_STATE } from './lib/consts';
import { DEFAULT_DEMO_FILTERS, DEFAULT_DEMO_TRAVEL, INITIAL_VIEW_STATE } from './lib/consts';
import { useTheme } from './hooks/useTheme';
import { useIsMobile } from './hooks/useIsMobile';
import { useAuth } from './hooks/useAuth';
@ -38,6 +38,7 @@ const LearnPage = lazy(() => import('./components/learn/LearnPage'));
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
const AccountPage = lazy(() => import('./components/account/AccountPage'));
const ResetPasswordPage = lazy(() => import('./components/account/ResetPasswordPage'));
const SavedPage = lazy(() =>
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
);
@ -160,6 +161,8 @@ function pageToPath(page: Page, inviteCode?: string): string {
return '/saved';
case 'account':
return '/account';
case 'reset-password':
return '/reset-password';
case 'invite':
if (!inviteCode) {
throw new Error('Cannot build invite path without an invite code');
@ -183,6 +186,7 @@ function pathToPage(rawPathname: string): RouteMatch | null {
const seoContentPage = getSeoContentPage(pathname);
if (seoContentPage) return { page: seoContentPage };
if (pathname === '/account') return { page: 'account' };
if (pathname === '/reset-password') return { page: 'reset-password' };
if (pathname === '/support') return { page: 'learn' };
if (pathname === '/terms') return { page: 'terms' };
if (pathname === '/privacy') return { page: 'privacy' };
@ -234,15 +238,26 @@ export default function App() {
return params.get('og') === '1';
}, []);
// Funnel fix: pre-seed high-intent filters on a cold (empty) map open so first-time visitors
// immediately see value and are one filter from the demo cap. Deep links (OG screenshots, the
// SEO landing-page CTAs) already carry filters, so they're left as-is. See DEFAULT_DEMO_FILTERS.
const initialMapFilters = useMemo(() => {
if (isScreenshotMode || isOgMode) return mapUrlState.filters;
return Object.keys(mapUrlState.filters).length === 0
? { ...DEFAULT_DEMO_FILTERS }
: mapUrlState.filters;
}, [mapUrlState.filters, isScreenshotMode, isOgMode]);
// Funnel fix: pre-seed high-intent defaults on a cold map open so first-time visitors immediately
// see value and are one filter from the demo cap. "Cold" means the URL carries neither filters nor
// a travel time; a deep link (OG screenshot, SEO landing-page CTA) sets at least one of those and
// is left as-is, as is the non-interactive screenshot/OG render. See DEFAULT_DEMO_FILTERS.
const isColdMapOpen = useMemo(() => {
if (isScreenshotMode || isOgMode) return false;
const hasFilters = Object.keys(mapUrlState.filters).length > 0;
const hasTravel = (mapUrlState.travelTime?.entries?.length ?? 0) > 0;
return !hasFilters && !hasTravel;
}, [mapUrlState.filters, mapUrlState.travelTime, isScreenshotMode, isOgMode]);
const initialMapFilters = useMemo(
() => (isColdMapOpen ? { ...DEFAULT_DEMO_FILTERS } : mapUrlState.filters),
[isColdMapOpen, mapUrlState.filters]
);
const initialMapTravelTime = useMemo(
() => (isColdMapOpen ? DEFAULT_DEMO_TRAVEL : mapUrlState.travelTime),
[isColdMapOpen, mapUrlState.travelTime]
);
const [features, setFeatures] = useState<FeatureMeta[]>([]);
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
@ -283,6 +298,7 @@ export default function App() {
loginWithOAuth,
logout,
requestPasswordReset,
confirmPasswordReset,
refreshAuth,
clearError,
} = useAuth();
@ -708,6 +724,17 @@ export default function App() {
<LearnPage />
) : activePage === 'terms' || activePage === 'privacy' ? (
<LegalPage kind={activePage} />
) : activePage === 'reset-password' ? (
<ResetPasswordPage
onConfirm={confirmPasswordReset}
onLoginClick={() => {
navigateTo('home');
openAuthModal('login');
}}
loading={authLoading}
error={authError}
onClearError={clearError}
/>
) : isSeoLandingPage(activePage) ? (
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
) : isSeoContentPage(activePage) ? (
@ -769,7 +796,7 @@ export default function App() {
}}
onDashboardReadyChange={setDashboardReady}
isMobile={isMobile}
initialTravelTime={mapUrlState.travelTime}
initialTravelTime={initialMapTravelTime}
initialPostcode={mapUrlState.postcode}
shareCode={mapUrlState.share}
onSessionRejected={() => {

View file

@ -0,0 +1,53 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<typeof import('react-i18next')>()),
useTranslation: () => ({ t: (key: string) => key }),
}));
import ResetPasswordPage from './ResetPasswordPage';
function renderPage(search: string, onConfirm = vi.fn(async () => {})) {
window.history.replaceState({}, '', `/reset-password${search}`);
const onLoginClick = vi.fn();
render(
<ResetPasswordPage
onConfirm={onConfirm}
onLoginClick={onLoginClick}
loading={false}
error={null}
onClearError={() => {}}
/>
);
return { onConfirm, onLoginClick };
}
afterEach(cleanup);
describe('ResetPasswordPage', () => {
it('shows an incomplete-link message and no form when the token is missing', () => {
const { onLoginClick } = renderPage('');
expect(screen.getByText('auth.resetMissingToken')).toBeTruthy();
expect(screen.queryByLabelText('auth.newPassword')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'auth.goToLogin' }));
expect(onLoginClick).toHaveBeenCalledOnce();
});
it('submits the URL token plus the new password, then shows success', async () => {
const { onConfirm } = renderPage('?token=reset-token-123');
const input = screen.getByLabelText('auth.newPassword') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'sup3rs3cret' } });
fireEvent.click(screen.getByRole('button', { name: 'auth.updatePassword' }));
expect(onConfirm).toHaveBeenCalledWith('reset-token-123', 'sup3rs3cret');
expect(await screen.findByText('auth.resetSuccessBody')).toBeTruthy();
});
it('toggles password visibility', () => {
renderPage('?token=abc');
const input = screen.getByLabelText('auth.newPassword') as HTMLInputElement;
expect(input.type).toBe('password');
fireEvent.click(screen.getByRole('button', { name: 'auth.showPassword' }));
expect(input.type).toBe('text');
});
});

View file

@ -0,0 +1,137 @@
import { useCallback, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EyeIcon } from '../ui/icons/EyeIcon';
import { EyeOffIcon } from '../ui/icons/EyeOffIcon';
/**
* Landing page for the PocketBase password-reset email link
* (`/reset-password?token=…`). Reads the one-time token from the URL, takes a new
* password, and calls `confirmPasswordReset`. PocketBase does not issue a session
* on confirm, so on success we point the user at the log in screen rather than
* auto-authenticating them.
*/
export default function ResetPasswordPage({
onConfirm,
onLoginClick,
loading,
error,
onClearError,
}: {
onConfirm: (token: string, password: string) => Promise<void>;
onLoginClick: () => void;
loading: boolean;
error: string | null;
onClearError: () => void;
}) {
const { t } = useTranslation();
const token = useMemo(
() => new URLSearchParams(window.location.search).get('token') ?? '',
[]
);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [done, setDone] = useState(false);
const fieldId = useId();
const passwordInputId = `${fieldId}-new-password`;
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
onClearError();
try {
await onConfirm(token, password);
setDone(true);
} catch {
// Error surfaces through the `error` prop (set by useAuth).
}
},
[onConfirm, token, password, onClearError]
);
const loginButtonClass =
'w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 transition-colors text-center';
return (
<div className="flex-1 flex items-start justify-center bg-warm-50 dark:bg-navy-950 px-4 py-16">
<div className="w-full max-w-sm bg-white dark:bg-warm-900 rounded-lg shadow-sm border border-warm-200 dark:border-warm-700 p-6">
<h1 className="text-lg font-semibold text-navy-950 dark:text-white mb-4">
{t('auth.resetPassword')}
</h1>
{!token ? (
<div className="space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-300">{t('auth.resetMissingToken')}</p>
<button type="button" onClick={onLoginClick} className={loginButtonClass}>
{t('auth.goToLogin')}
</button>
</div>
) : done ? (
<div className="space-y-4">
<p className="text-sm text-teal-700 dark:text-teal-400">{t('auth.resetSuccessBody')}</p>
<button type="button" onClick={onLoginClick} className={loginButtonClass}>
{t('auth.goToLogin')}
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={passwordInputId}
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
>
{t('auth.newPassword')}
</label>
<div className="relative">
<input
id={passwordInputId}
name="new-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
autoFocus
className="w-full pl-3 pr-10 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={t('auth.newPasswordPlaceholder')}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
aria-pressed={showPassword}
title={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 outline-none focus-visible:text-teal-600 dark:focus-visible:text-teal-400"
>
{showPassword ? (
<EyeOffIcon className="w-5 h-5" />
) : (
<EyeIcon filled={false} className="w-5 h-5" />
)}
</button>
</div>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{loading ? t('auth.pleaseWait') : t('auth.updatePassword')}
</button>
<button
type="button"
onClick={onLoginClick}
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
{t('auth.backToLogin')}
</button>
</form>
)}
</div>
</div>
);
}

View file

@ -143,11 +143,11 @@ export default memo(function AiFilterInput({
if (!expanded) {
return (
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
<button
type="button"
onClick={() => setExpanded(true)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group"
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group"
>
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
@ -159,8 +159,8 @@ export default memo(function AiFilterInput({
}
return (
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
<div className="flex items-center gap-1.5 mb-1.5">
<div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
<div className="flex items-center gap-1.5 mb-1">
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">
{t('aiFilter.aiSearch')}

View file

@ -8,7 +8,10 @@ import type {
FeatureMeta,
FilterExclusion,
HexagonStatsResponse,
PriceMetric,
PricePoint,
} from '../../types';
import { postcodeOutcode, postcodeSector } from '../../lib/postcode';
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import {
@ -289,6 +292,7 @@ export default function AreaPane({
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
}, [paneFeatureGroups, hexagonLocation]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [priceMetric, setPriceMetric] = useState<PriceMetric>('price');
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
restoreKey: scrollRestoreKey ?? hexagonId,
scrollTopRef,
@ -548,14 +552,105 @@ export default function AreaPane({
{stats.price_history &&
(() => {
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
return uniqueYears.size > 1 ? (
if (uniqueYears.size <= 1) return null;
const charts: {
key: string;
label: string;
dot: string;
points: PricePoint[];
}[] = [
{
key: 'area',
label: t('areaPane.priceHistoryThisArea'),
dot: 'bg-teal-500 dark:bg-teal-400',
points: stats.price_history,
},
];
// Sector/outcode context is postcode-only: a hexagon cell
// straddles arbitrary postcodes, so its sector/outcode is not a
// meaningful aggregation unit. The Total/Per-m² toggle still
// applies to the area chart in both cases.
if (isPostcode && hexagonId) {
const sector = postcodeSector(hexagonId);
const outcode = postcodeOutcode(hexagonId);
if (sector && stats.sector_price_history?.length) {
charts.push({
key: 'sector',
label: sector,
dot: 'bg-indigo-500 dark:bg-indigo-400',
points: stats.sector_price_history,
});
}
if (outcode && stats.outcode_price_history?.length) {
charts.push({
key: 'outcode',
label: outcode,
dot: 'bg-amber-500 dark:bg-amber-400',
points: stats.outcode_price_history,
});
}
}
const hasPerSqm = charts.some((c) =>
c.points.some((p) => p.price_per_sqm != null)
);
const metric: PriceMetric = hasPerSqm ? priceMetric : 'price';
const optionClass = (active: boolean) =>
`px-2 py-0.5 text-[11px] font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
active
? 'bg-teal-600 text-white dark:bg-teal-500'
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
}`;
return (
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
{t('areaPane.priceHistory')}
</span>
<PriceHistoryChart points={stats.price_history} />
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
{t('areaPane.priceHistory')}
</span>
{hasPerSqm && (
<div
className="grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700"
role="radiogroup"
aria-label={t('areaPane.priceMetric')}
>
<button
type="button"
role="radio"
aria-checked={metric === 'price'}
onClick={() => setPriceMetric('price')}
className={optionClass(metric === 'price')}
>
{t('areaPane.priceMetricTotal')}
</button>
<button
type="button"
role="radio"
aria-checked={metric === 'price_per_sqm'}
onClick={() => setPriceMetric('price_per_sqm')}
className={optionClass(metric === 'price_per_sqm')}
>
{t('areaPane.priceMetricPerSqm')}
</button>
</div>
)}
</div>
{charts.map((chart) => (
<div key={chart.key} className={chart.key === 'area' ? '' : 'mt-1.5'}>
{charts.length > 1 && (
<span className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-warm-500 dark:text-warm-400">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${chart.dot}`}
/>
{chart.label}
</span>
)}
<PriceHistoryChart points={chart.points} metric={metric} />
</div>
))}
</div>
) : null;
);
})()}
{displayFeatureGroups.map((group) => {
const showNearbyStations =

View file

@ -105,7 +105,7 @@ export default function FeatureBrowser({
return (
<>
<div className="shrink-0 px-2 py-1.5 border-b border-warm-200 dark:border-navy-700">
<div className="shrink-0 px-2 py-1 border-b border-warm-200 dark:border-navy-700">
<SearchInput
value={search}
onChange={setSearch}
@ -125,7 +125,7 @@ export default function FeatureBrowser({
toggleGroup(group.name);
onGroupToggle?.(group.name, willExpand);
}}
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length +
@ -141,7 +141,7 @@ export default function FeatureBrowser({
return (
<div
key={mode}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div
className="flex items-center gap-2 min-w-0"
@ -184,7 +184,7 @@ export default function FeatureBrowser({
return (
<div
key={f.name}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} size="sm" description={f.description} />

View file

@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from '@testing-library/react';
import { ListingPopupSingleContent } from './ListingPopups';
import type { ActualListing } from '../../types';
// No global RTL setup file registers auto-cleanup, so unmount between cases to
// keep each render isolated in the shared jsdom document.
afterEach(cleanup);
// Minimal react-i18next stub: t() echoes a readable label per key; i18n.language
// is fixed so date formatting is deterministic.
vi.mock('react-i18next', () => {
const labels: Record<string, string> = {
'listing.priceHistory': 'Price history',
'listing.priceListed': 'Listed',
'listing.priceReduced': 'Reduced',
'listing.priceIncreased': 'Increased',
'listing.openListing': 'Open listing',
'listing.viewed': 'Viewed',
};
return {
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
labels[key] ?? (opts ? `${key} ${JSON.stringify(opts)}` : key),
i18n: { language: 'en-GB' },
}),
};
});
function baseListing(overrides: Partial<ActualListing> = {}): ActualListing {
return {
lat: 51.5,
lon: -0.1,
postcode: 'SW9 0HD',
listing_url: 'https://www.rightmove.co.uk/properties/1',
asking_price: 490000,
features: [],
...overrides,
};
}
describe('ListingPopupSingleContent price history', () => {
it('renders points newest-first with signed deltas and reason labels', () => {
const listing = baseListing({
price_history: [
{ date: '2026-07-01', price: 500000, reason: 'listed' },
{ date: '2026-07-19', price: 480000, reason: 'reduced' },
{ date: '2026-07-26', price: 490000, reason: 'increased' },
],
});
const { getByText, container } = render(
<ListingPopupSingleContent listing={listing} clickedUrls={new Set()} onOpen={() => {}} />
);
getByText('Price history');
// Newest first: increased row shows +£10,000 vs the £480k point before it.
const items = Array.from(container.querySelectorAll('ol li'));
expect(items).toHaveLength(3);
expect(items[0].textContent).toContain('£490,000');
expect(items[0].textContent).toContain('+£10,000');
expect(items[0].textContent).toContain('Increased');
// Middle: the reduction from £500k -> £480k.
expect(items[1].textContent).toContain('£480,000');
expect(items[1].textContent).toContain('£20,000');
expect(items[1].textContent).toContain('Reduced');
// Oldest: the initial listing, no delta.
expect(items[2].textContent).toContain('£500,000');
expect(items[2].textContent).toContain('Listed');
expect(items[2].textContent).not.toContain('£0');
// UTC-stable date: "2026-07-01" must read as 1 Jul regardless of runner TZ.
expect(items[2].textContent).toContain('1 Jul 2026');
});
it('renders a single listed point with no delta', () => {
const listing = baseListing({
price_history: [{ date: '2026-06-15', price: 325000, reason: 'listed' }],
});
const { getByText, container } = render(
<ListingPopupSingleContent listing={listing} clickedUrls={new Set()} onOpen={() => {}} />
);
getByText('Price history');
expect(container.querySelectorAll('ol li')).toHaveLength(1);
});
it('omits the section entirely when there is no history', () => {
const { queryByText } = render(
<ListingPopupSingleContent
listing={baseListing({ price_history: [] })}
clickedUrls={new Set()}
onOpen={() => {}}
/>
);
expect(queryByText('Price history')).toBeNull();
});
});

View file

@ -2,12 +2,89 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import type { ActualListing } from '../../types';
import type { ActualListing, PriceHistoryPoint } from '../../types';
function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`;
}
function formatHistoryDate(iso: string, locale: string): string {
const parsed = new Date(iso);
if (Number.isNaN(parsed.getTime())) return iso;
// `iso` is a UTC calendar date ("YYYY-MM-DD"), parsed as UTC midnight. Format
// in UTC too, or a viewer west of UTC would see every date shifted a day back.
return parsed.toLocaleDateString(locale, {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
function reasonLabel(reason: string, t: TFunction): string {
switch (reason) {
case 'reduced':
return t('listing.priceReduced');
case 'increased':
return t('listing.priceIncreased');
default:
return t('listing.priceListed');
}
}
/** Compact asking-price timeline for the hover card. Renders most-recent first,
* with the £ change from the prior point on each reduction/increase. Accrues from
* the scraper's forward-only store, so a fresh listing shows a single point. */
function ListingPriceHistory({ history }: { history: PriceHistoryPoint[] }) {
const { t, i18n } = useTranslation();
if (history.length === 0) return null;
// history is oldest -> newest; show up to the 5 most recent, newest first.
const shown = history.slice(-5);
const baseIndex = history.length - shown.length;
const rows = shown
.map((point, idx) => {
const globalIdx = baseIndex + idx;
const prev = globalIdx > 0 ? history[globalIdx - 1] : null;
const delta = prev ? point.price - prev.price : 0;
return { point, delta };
})
.reverse();
return (
<div className="mt-2 border-t border-warm-100 pt-1.5 dark:border-warm-700/60">
<div className="text-[11px] font-medium text-warm-500 dark:text-warm-400">
{t('listing.priceHistory')}
</div>
<ol className="mt-1 space-y-0.5">
{rows.map(({ point, delta }, idx) => {
const isReduction = point.reason === 'reduced';
const isIncrease = point.reason === 'increased';
const accent = isReduction
? 'text-emerald-600 dark:text-emerald-400'
: isIncrease
? 'text-red-600 dark:text-red-400'
: 'text-warm-600 dark:text-warm-300';
return (
<li key={idx} className="flex items-baseline justify-between gap-2 text-[11px]">
<span className="flex items-baseline gap-1.5">
<span className={`font-semibold ${accent}`}>{formatListingPrice(point.price)}</span>
{delta !== 0 && (
<span className={accent}>
{delta < 0 ? '' : '+'}£{Math.abs(delta).toLocaleString()}
</span>
)}
</span>
<span className="shrink-0 text-warm-400 dark:text-warm-500">
{reasonLabel(point.reason, t)} · {formatHistoryDate(point.date, i18n.language)}
</span>
</li>
);
})}
</ol>
</div>
);
}
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
const parts: string[] = [];
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
@ -78,6 +155,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
))}
</ul>
)}
{listing.price_history && listing.price_history.length > 0 && (
<ListingPriceHistory history={listing.price_history} />
)}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
{visited && (
<span className="text-violet-600 dark:text-violet-400"> {t('listing.viewed')}</span>

View file

@ -710,6 +710,11 @@ export default function MapPage({
const shareAndSaveView = isMobile
? (mapData.currentVisibleView ?? mapData.currentView)
: mapData.currentView;
// Params for share/save/checkout-return/last-session, deliberately WITHOUT the
// selected postcode: the lat/lon/zoom already convey the location, so focusing
// a postcode adds nothing to a shared link and shouldn't be baked into a saved
// search. The live URL (useUrlSync above) still carries `pc` so a reload
// re-opens the selection.
const dashboardParams = useMemo(
() =>
stateToParams(
@ -723,7 +728,7 @@ export default function MapPage({
activeOverlays,
basemap,
crimeTypes,
selectedPostcodeParam,
undefined,
colorOpacity,
listingsMode
).toString(),
@ -738,7 +743,6 @@ export default function MapPage({
listingsMode,
rightPaneTab,
selectedPOICategories,
selectedPostcodeParam,
shareCode,
shareAndSaveView,
]
@ -894,6 +898,9 @@ export default function MapPage({
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
statsUseFilters={areaStatsUseFilters}
onStatsUseFiltersChange={setAreaStatsUseFilters}
filtersActive={Object.keys(filters).length + activeEntries.length > 0}
onLoadMore={handleLoadMoreProperties}
scrollTopRef={propertiesPaneScrollTopRef}
scrollRestoreKey={
@ -903,7 +910,17 @@ export default function MapPage({
/>
</Suspense>
),
[handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon]
[
activeEntries,
areaStatsUseFilters,
filters,
handleLoadMoreProperties,
loadingProperties,
properties,
propertiesTotal,
selectedHexagon,
setAreaStatsUseFilters,
]
);
const poiPane = useMemo(

View file

@ -1,9 +1,11 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import type { PricePoint } from '../../types';
import type { PriceMetric, PricePoint } from '../../types';
import { formatValue } from '../../lib/format';
interface PriceHistoryChartProps {
points: PricePoint[];
/** Which value to plot. Defaults to the absolute sale price. */
metric?: PriceMetric;
}
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
@ -22,7 +24,7 @@ interface PriceScale {
ticks: number[];
}
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
export default function PriceHistoryChart({ points, metric = 'price' }: PriceHistoryChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
@ -37,7 +39,18 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
return () => observer.disconnect();
}, []);
// Project each point onto the chosen metric as a plain {year, price} so the
// rest of the chart is metric-agnostic. In per-m² mode, points without a
// recorded floor area drop out.
const plotPoints = useMemo<PricePoint[]>(() => {
if (metric === 'price') return points;
return points
.filter((p) => Number.isFinite(p.price_per_sqm))
.map((p) => ({ year: p.year, price: p.price_per_sqm as number }));
}, [points, metric]);
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
const points = plotPoints;
let yMin = Infinity,
yMax = -Infinity;
for (const p of points) {
@ -83,7 +96,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
priceScale: getPriceScale(points),
medians: meds,
};
}, [points]);
}, [plotPoints]);
const plotW = width - PADDING.left - PADDING.right;
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
@ -104,7 +117,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
return (
<div ref={containerRef} style={{ height: HEIGHT }}>
{width > 0 && (
{width > 0 && plotPoints.length > 0 && (
<svg width={width} height={HEIGHT}>
{/* Grid lines */}
{priceScale.ticks.map((tick) => (
@ -120,7 +133,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
))}
{/* Dots */}
{points.map((p, i) => (
{plotPoints.map((p, i) => (
<circle
key={i}
cx={scaleX(p.year)}

View file

@ -23,6 +23,9 @@ interface PropertiesPaneProps {
total: number;
loading: boolean;
hexagonId: string | null;
statsUseFilters: boolean;
onStatsUseFiltersChange: (useFilters: boolean) => void;
filtersActive: boolean;
onLoadMore: () => void;
onNavigateToSource?: (slug: string) => void;
scrollTopRef?: MutableRefObject<number>;
@ -35,6 +38,9 @@ export function PropertiesPane({
total,
loading,
hexagonId,
statsUseFilters,
onStatsUseFiltersChange,
filtersActive,
onLoadMore,
onNavigateToSource,
scrollTopRef,
@ -106,13 +112,41 @@ export function PropertiesPane({
</InfoPopup>
)}
<div className="p-2">
<div className="p-2 space-y-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('propertyCard.searchPlaceholder')}
className="p-2"
/>
{filtersActive && (
<div className="grid grid-cols-2 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
<button
type="button"
aria-pressed={statsUseFilters}
onClick={() => onStatsUseFiltersChange(true)}
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
statsUseFilters
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.matchingFiltersOption')}
</button>
<button
type="button"
aria-pressed={!statsUseFilters}
onClick={() => onStatsUseFiltersChange(false)}
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
!statsUseFilters
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.allPropertiesOption')}
</button>
</div>
)}
</div>
<div>

View file

@ -448,12 +448,12 @@ export function ActiveFilterList({
onToggleGroup(group.name);
onGroupToggle?.(group.name, !expanded);
}}
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
className="sticky top-0 z-30 px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
</CollapsibleGroupHeader>
{expanded && (
<div className="px-2 py-1.5 space-y-3.5">
<div className="px-2 py-1 space-y-2">
{group.name === TRANSPORT_GROUP && travelCards}
{group.features.map((feature) => renderFeatureCard(feature))}
</div>

View file

@ -127,7 +127,7 @@ export function ActiveFiltersPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-2 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
@ -202,7 +202,7 @@ export function ActiveFiltersPanel({
onLoginRequired={onLoginRequired}
/>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
<p className="px-3 py-1 text-xs text-warm-400 dark:text-warm-500">
{t('filters.addFiltersHint')}
</p>
)}

View file

@ -32,6 +32,28 @@ import {
type PoiFilterName,
} from '../../../lib/poi-distance-filter';
// Each resolver maps a raw pinned feature name to the folded filter card that
// represents it in the browser, or returns null when it doesn't belong to that fold.
const FOLDED_FILTER_RESOLVERS: Array<(name: string) => string | null> = [
(name) => (isSchoolFilterName(name) ? SCHOOL_FILTER_NAME : null),
(name) => (isSpecificCrimeFilterName(name) ? SPECIFIC_CRIMES_FILTER_NAME : null),
(name) => (isElectionVoteShareFilterName(name) ? ELECTION_VOTE_SHARE_FILTER_NAME : null),
(name) => (isEthnicityFilterName(name) ? ETHNICITIES_FILTER_NAME : null),
(name) => (isQualificationFilterName(name) ? QUALIFICATIONS_FILTER_NAME : null),
(name) => (isTenureFilterName(name) ? TENURE_FILTER_NAME : null),
(name) => (isCouncilFilterName(name) ? COUNCIL_FILTER_NAME : null),
(name) => (isPoiDistanceFilterName(name) ? (getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME) : null),
(name) => (isCrimeSeverityFilterName(name) ? (getCrimeSeverityFilterName(name) ?? name) : null),
];
function resolveBrowserPinnedFeature(name: string): string {
for (const resolve of FOLDED_FILTER_RESOLVERS) {
const folded = resolve(name);
if (folded) return folded;
}
return name;
}
interface AddFilterPanelProps {
collapsed: boolean;
isLoggedIn: boolean;
@ -91,26 +113,7 @@ export function AddFilterPanel({
const { t } = useTranslation();
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
const browserPinnedFeature =
pinnedFeature && isSchoolFilterName(pinnedFeature)
? SCHOOL_FILTER_NAME
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
? SPECIFIC_CRIMES_FILTER_NAME
: pinnedFeature && isElectionVoteShareFilterName(pinnedFeature)
? ELECTION_VOTE_SHARE_FILTER_NAME
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
? ETHNICITIES_FILTER_NAME
: pinnedFeature && isQualificationFilterName(pinnedFeature)
? QUALIFICATIONS_FILTER_NAME
: pinnedFeature && isTenureFilterName(pinnedFeature)
? TENURE_FILTER_NAME
: pinnedFeature && isCouncilFilterName(pinnedFeature)
? COUNCIL_FILTER_NAME
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
: pinnedFeature && isCrimeSeverityFilterName(pinnedFeature)
? (getCrimeSeverityFilterName(pinnedFeature) ?? pinnedFeature)
: pinnedFeature;
const browserPinnedFeature = pinnedFeature && resolveBrowserPinnedFeature(pinnedFeature);
const handleTogglePin = (name: string) => {
if (name === SCHOOL_FILTER_NAME) {
@ -163,7 +166,7 @@ export function AddFilterPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
className="shrink-0 flex items-center justify-between border-b border-teal-300 bg-teal-100 px-3 py-2 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')}
@ -176,7 +179,7 @@ export function AddFilterPanel({
{(!collapsed || !isLicensed) && (
<div className="flex min-h-0 flex-1 flex-col">
{!collapsed && (
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto md:min-h-[12rem]">
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={allFeatures}
@ -194,7 +197,7 @@ export function AddFilterPanel({
</div>
)}
{!isLicensed && (
<div className="mt-auto shrink-0 flex flex-col items-center gap-2 px-3 pt-3 pb-2.5 border-t border-warm-200 dark:border-warm-700">
<div className="mt-auto shrink-0 flex flex-col items-center gap-1.5 px-3 pt-2 pb-2 border-t border-warm-200 dark:border-warm-700">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-snug">
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '}
<span className="text-xs text-warm-400 dark:text-warm-500">
@ -203,7 +206,7 @@ export function AddFilterPanel({
</p>
<button
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick}
className="w-full px-5 py-2 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
className="w-full px-5 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
</button>

View file

@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({
return (
<div
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME}
className={`space-y-1.5 px-2 py-1.5 rounded ${
className={`space-y-1 px-2 py-1 rounded ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
@ -151,7 +151,7 @@ export function ElectionVoteShareFilterCard({
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.party')}
</label>
<div className="relative">

View file

@ -35,7 +35,7 @@ export function EnumFeatureFilterCard({
return (
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`space-y-0.5 px-2 py-1 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="flex items-start justify-between gap-1">
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap />

View file

@ -119,7 +119,7 @@ export function EthnicityFilterCard({
return (
<div
data-filter-name={ETHNICITIES_FILTER_NAME}
className={`space-y-1.5 px-2 py-1.5 rounded ${
className={`space-y-1 px-2 py-1 rounded ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
@ -147,7 +147,7 @@ export function EthnicityFilterCard({
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.ethnicity')}
</label>
<div className="relative">

View file

@ -77,7 +77,7 @@ export function NumericFeatureFilterCard({
return (
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`space-y-0.5 px-2 py-1 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel

View file

@ -128,7 +128,7 @@ export function PoiDistanceFilterCard({
return (
<div
data-filter-name={filterName}
className={`space-y-1.5 px-2 py-1.5 rounded ${
className={`space-y-1 px-2 py-1 rounded ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
@ -156,7 +156,7 @@ export function PoiDistanceFilterCard({
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.poiType')}
</label>
<PoiTypeDropdown

View file

@ -107,7 +107,7 @@ export function SchoolFilterCard({
return (
<div
data-filter-name={SCHOOL_FILTER_NAME}
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`space-y-1 px-2 py-1 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel
@ -127,7 +127,7 @@ export function SchoolFilterCard({
/>
</div>
<div className="space-y-1.5">
<div className="space-y-1">
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.schoolType')}

View file

@ -118,7 +118,7 @@ export function SliderLabels({
if (feature && onValueChange) {
return (
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<div className="relative h-4 mt-1.5 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<EditableLabel
value={labels[0]}
formatted={minLabel}

View file

@ -159,7 +159,7 @@ export function VariantFilterCard({
return (
<div
data-filter-name={config.filterName}
className={`space-y-1.5 px-2 py-1.5 rounded ${
className={`space-y-1 px-2 py-1 rounded ${
isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned
@ -190,7 +190,7 @@ export function VariantFilterCard({
so the dropdown is hidden and only the window toggle + slider remain. */}
{variantOptions.length > 1 && (
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t(config.dropdownLabelKey)}
</label>
<div className="relative">
@ -216,7 +216,7 @@ export function VariantFilterCard({
{windowConfig && currentWindow && windowOptions.length > 1 && (
<div>
{windowConfig.labelKey && (
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t(windowConfig.labelKey)}
</label>
)}

View file

@ -179,7 +179,7 @@ export function DesktopMapPage({
>
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
<div
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
className="w-2 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...leftPaneHandlers}
>
<div className="flex flex-col gap-1.5">

View file

@ -0,0 +1,63 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../lib/analytics', () => ({ trackEvent: () => {} }));
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<typeof import('react-i18next')>()),
useTranslation: () => ({ t: (key: string) => key }),
Trans: ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}</>,
}));
import AuthModal from './AuthModal';
const noop = async () => {};
function renderModal(initialTab: 'login' | 'register' = 'login') {
return render(
<AuthModal
onClose={() => {}}
onLogin={noop}
onRegister={noop}
onOAuthLogin={noop}
onForgotPassword={noop}
loading={false}
error={null}
onClearError={() => {}}
initialTab={initialTab}
/>
);
}
afterEach(cleanup);
describe('AuthModal password visibility toggle', () => {
it('starts hidden and reveals the password when clicked', () => {
renderModal('login');
const input = screen.getByLabelText('auth.password') as HTMLInputElement;
expect(input.type).toBe('password');
// Hidden state exposes a "show" toggle.
const toggle = screen.getByRole('button', { name: 'auth.showPassword' });
expect(toggle.getAttribute('type')).toBe('button');
expect(toggle.getAttribute('aria-pressed')).toBe('false');
fireEvent.click(toggle);
expect(input.type).toBe('text');
const hideToggle = screen.getByRole('button', { name: 'auth.hidePassword' });
expect(hideToggle.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(hideToggle);
expect(input.type).toBe('password');
});
it('offers the toggle on the register tab too', () => {
renderModal('register');
const input = screen.getByLabelText('auth.password') as HTMLInputElement;
expect(input.type).toBe('password');
fireEvent.click(screen.getByRole('button', { name: 'auth.showPassword' }));
expect(input.type).toBe('text');
});
});

View file

@ -3,6 +3,8 @@ import { Trans, useTranslation } from 'react-i18next';
import type { ParseKeys } from 'i18next';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { EyeIcon } from './icons/EyeIcon';
import { EyeOffIcon } from './icons/EyeOffIcon';
import { trackEvent } from '../../lib/analytics';
import { useModalA11y } from '../../hooks/useModalA11y';
import type { AuthErrorAction } from '../../lib/auth-errors';
@ -42,6 +44,7 @@ export default function AuthModal({
const [view, setView] = useState<View>(initialTab);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [resetSent, setResetSent] = useState(false);
const dialogRef = useModalA11y();
const fieldId = useId();
@ -232,22 +235,38 @@ export default function AuthModal({
>
{t('auth.password')}
</label>
<input
id={passwordInputId}
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete={view === 'register' ? 'new-password' : 'current-password'}
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={
view === 'register'
? t('auth.passwordPlaceholderRegister')
: t('auth.passwordPlaceholderLogin')
}
/>
<div className="relative">
<input
id={passwordInputId}
name="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
autoComplete={view === 'register' ? 'new-password' : 'current-password'}
className="w-full pl-3 pr-10 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={
view === 'register'
? t('auth.passwordPlaceholderRegister')
: t('auth.passwordPlaceholderLogin')
}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
aria-pressed={showPassword}
title={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 outline-none focus-visible:text-teal-600 dark:focus-visible:text-teal-400"
>
{showPassword ? (
<EyeOffIcon className="w-5 h-5" />
) : (
<EyeIcon filled={false} className="w-5 h-5" />
)}
</button>
</div>
{view === 'login' && (
<button
type="button"

View file

@ -37,7 +37,8 @@ export type Page =
| 'privacy'
| 'account'
| 'saved'
| 'invite';
| 'invite'
| 'reset-password';
export interface HeaderExportState {
onExport: (options?: { postcodes?: string[] }) => void;
@ -65,6 +66,7 @@ export const PAGE_PATHS: Record<Page, string> = {
saved: '/saved',
account: '/account',
invite: '/invite',
'reset-password': '/reset-password',
};
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
@ -206,33 +208,6 @@ export default function Header({
return (
<>
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{showEditingBar && (
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-0 flex items-center justify-center px-4">
<div className="pointer-events-auto flex items-center gap-3 max-w-[60%]">
<span className="text-sm text-warm-300 truncate" title={editingSearch.name}>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{ strong: <strong className="font-semibold text-white" /> }}
/>
</span>
<button
onClick={onCancelEdit}
className="cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{t('common.cancel')}
</button>
<button
onClick={onUpdateEdit}
disabled={savingSearch || dashboardActionsBlocked}
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
</div>
)}
{/* Left: Logo + nav */}
<div className="flex min-w-0 items-center gap-4">
<a
@ -246,9 +221,9 @@ export default function Header({
</span>
</a>
{/* Desktop nav: hidden while the saved-search "is being updated" banner
is shown so the centered pointer-events-auto banner can't overlap (and
block clicks on) the Invite Friends / Learn / Pricing links at ~1366px. */}
{/* Desktop nav: hidden while the saved-search "is being updated" bar is
shown so the centered edit bar has room and the header can't get
cramped (the bar would otherwise crowd the Learn / Pricing links). */}
{!useSidebarNav && !showEditingBar && (
<nav className="top-menu flex items-center">
<a
@ -287,6 +262,36 @@ export default function Header({
)}
</div>
{/* Saved-search "is being updated" bar. In normal flow (not an absolute
overlay) between the logo and the right-side actions so flexbox
reserves its space: the Cancel / Update buttons can never overlap the
Share / Export buttons. Text truncates; the buttons stay put. */}
{showEditingBar && (
<div className="flex min-w-0 flex-1 items-center justify-center gap-3 px-3">
<span className="min-w-0 truncate text-sm text-warm-300" title={editingSearch.name}>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{ strong: <strong className="font-semibold text-white" /> }}
/>
</span>
<button
onClick={onCancelEdit}
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{t('common.cancel')}
</button>
<button
onClick={onUpdateEdit}
disabled={savingSearch || dashboardActionsBlocked}
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
)}
{/* Right side */}
<div className="ml-auto flex shrink-0 items-center gap-2">
{/* Desktop-only dashboard actions: shown to everyone; a logged-out click

View file

@ -0,0 +1,22 @@
interface IconProps {
className?: string;
}
export function EyeOffIcon({ className = 'w-7 h-7' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 1 12s4 8 11 8a9.74 9.74 0 0 0 5.39-1.61" />
<line x1="2" y1="2" x2="22" y2="22" />
</svg>
);
}

View file

@ -9,6 +9,7 @@ export { CloseIcon } from './CloseIcon';
export { DownloadIcon } from './DownloadIcon';
export { ExpandIcon } from './ExpandIcon';
export { EyeIcon } from './EyeIcon';
export { EyeOffIcon } from './EyeOffIcon';
export { FilterIcon } from './FilterIcon';
export { GoogleIcon } from './GoogleIcon';
export { GraduationCapIcon } from './GraduationCapIcon';

View file

@ -32,6 +32,16 @@ function authRefreshInvalidated(error: unknown): boolean {
return status === 401 || status === 403;
}
/**
* Normalize an email so login and registration are case- and
* whitespace-insensitive: ` John@Example.com` and `john@example.com`
* resolve to the same account. Applied at the PocketBase boundary rather
* than on the input field, so the user still sees exactly what they typed.
*/
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function useAuth() {
const { t } = useTranslation();
const [user, setUser] = useState<AuthUser | null>(() => {
@ -62,7 +72,9 @@ export function useAuth() {
setError(null);
setErrorAction(null);
try {
const result = await pb.collection('users').authWithPassword(email, password);
const result = await pb
.collection('users')
.authWithPassword(normalizeEmail(email), password);
setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' });
} catch (err) {
@ -82,12 +94,13 @@ export function useAuth() {
setLoading(true);
setError(null);
setErrorAction(null);
const normalizedEmail = normalizeEmail(email);
try {
// Step 1: create the account. A failure here means nothing was created,
// so surface a friendly, field-aware message (e.g. "email already exists").
try {
await pb.collection('users').create({
email,
email: normalizedEmail,
password,
passwordConfirm: password,
newsletter: true,
@ -103,7 +116,7 @@ export function useAuth() {
// orphaned: tell the user it was created and let them log in with the
// same credentials, instead of a misleading "registration failed".
try {
const result = await pb.collection('users').authWithPassword(email, password);
const result = await pb.collection('users').authWithPassword(normalizedEmail, password);
setUser(recordToUser(result.record));
trackEvent('Register');
} catch (err) {
@ -159,7 +172,7 @@ export function useAuth() {
setError(null);
setErrorAction(null);
try {
await pb.collection('users').requestPasswordReset(email);
await pb.collection('users').requestPasswordReset(normalizeEmail(email));
} catch (err) {
const { message, action } = resolveAuthError(err, 'reset', t);
setError(message);
@ -172,6 +185,27 @@ export function useAuth() {
[t]
);
const confirmPasswordReset = useCallback(
async (token: string, password: string) => {
setLoading(true);
setError(null);
setErrorAction(null);
try {
// PocketBase requires the password twice; the reset form uses a single
// (show/hide) field, so both arguments are the same value.
await pb.collection('users').confirmPasswordReset(token, password, password);
} catch (err) {
const { message, action } = resolveAuthError(err, 'confirmReset', t);
setError(message);
setErrorAction(action);
throw err;
} finally {
setLoading(false);
}
},
[t]
);
const refreshAuth = useCallback(async () => {
setLoading(true);
setError(null);
@ -208,6 +242,7 @@ export function useAuth() {
loginWithOAuth,
logout,
requestPasswordReset,
confirmPasswordReset,
refreshAuth,
clearError,
};

View file

@ -14,8 +14,10 @@ function currentUserId(): string | null {
*
* Reads the user from the shared PocketBase authStore so it stays self-contained, with no need to
* thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in
* the map's colour accessor; a fresh Set instance is produced on every change so it can double
* as a deck.gl `updateTrigger` (identity-compared).
* the map's colour accessor; a fresh Set instance is produced on every change so React re-renders.
* Note: a Set can NOT be used directly as a deck.gl `updateTrigger` deck diffs triggers with
* `compareProps`, which finds no enumerable keys on a Set and reports "no change". Callers must
* pass `Array.from(clickedUrls)` (or a version counter) as the trigger instead.
*
* Writes are optimistic: `markClicked` updates local state synchronously so the pin recolours
* the instant it is clicked, independent of the PocketBase round-trip or the next listings fetch.

View file

@ -93,23 +93,23 @@ interface UseFiltersOptions {
onFilterLimitReached?: () => void;
}
// Applied in order: each normalizer folds its own raw feature names into a single
// folded filter. Council folds AFTER tenure so a bare "% Social rent" is claimed by
// tenure first.
const FILTER_NORMALIZERS: Array<(filters: FeatureFilters) => FeatureFilters> = [
normalizeSchoolFilters,
normalizeSpecificCrimeFilters,
normalizeCrimeSeverityFilters,
normalizeElectionVoteShareFilters,
normalizeEthnicityFilters,
normalizeQualificationFilters,
normalizeTenureFilters,
normalizeCouncilFilters,
normalizePoiDistanceFilters,
];
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
return normalizePoiDistanceFilters(
// Council folds AFTER tenure so a bare "% Social rent" is claimed by tenure.
normalizeCouncilFilters(
normalizeTenureFilters(
normalizeQualificationFilters(
normalizeEthnicityFilters(
normalizeElectionVoteShareFilters(
normalizeCrimeSeverityFilters(
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
)
)
)
)
)
)
);
return FILTER_NORMALIZERS.reduce((acc, normalize) => normalize(acc), filters);
}
function getBackendFeatureName(name: string): string {

View file

@ -385,7 +385,9 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
highlightColor: [29, 228, 195, 220],
onHover: stableHover,
onClick: stableClick,
updateTriggers: { getFillColor: clickedUrls },
// deck.gl can't diff a Set (no enumerable keys), so a click never
// re-runs getFillColor until data changes. Feed it a diffable array.
updateTriggers: { getFillColor: Array.from(clickedUrls) },
}),
[visibleListings, clickedUrls, stableHover, stableClick]
);
@ -483,7 +485,9 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
highlightColor: [29, 228, 195, 220],
onHover: stableExpandedHover,
onClick: stableExpandedClick,
updateTriggers: { getFillColor: clickedUrls },
// deck.gl can't diff a Set (no enumerable keys), so a click never
// re-runs getFillColor until data changes. Feed it a diffable array.
updateTriggers: { getFillColor: Array.from(clickedUrls) },
}),
[expandedListings, clickedUrls, stableExpandedHover, stableExpandedClick]
);

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import pb from '../lib/pocketbase';
import { apiUrl, authHeaders } from '../lib/api';
import { trackEvent } from '../lib/analytics';
import { stripSelectedPostcodeParam } from '../lib/url-state';
export interface SavedSearch {
id: string;
@ -141,7 +142,11 @@ export function useSavedSearches(userId: string | null) {
setSaving(true);
setError(null);
try {
const params = paramsOverride ?? window.location.search.replace(/^\?/, '');
// A saved search stores filter criteria, so drop the transient
// selected-postcode param (a search/map-click leaves it in the URL).
const params = stripSelectedPostcodeParam(
paramsOverride ?? window.location.search.replace(/^\?/, '')
);
// Create record immediately without screenshot
const formData = new FormData();
@ -220,11 +225,14 @@ export function useSavedSearches(userId: string | null) {
);
const updateSearchParams = useCallback(
async (id: string, params: string) => {
async (id: string, rawParams: string) => {
if (!userId) return;
setSaving(true);
setError(null);
try {
// Match saveSearch: a saved search never carries the transient
// selected-postcode param.
const params = stripSelectedPostcodeParam(rawParams);
const record = await pb.collection('saved_searches').update(id, { params });
trackEvent('Search Update');
setSearches((prev) =>

View file

@ -42,7 +42,7 @@ const de: Translations = {
minute: 'Min.',
or: 'oder',
area: 'Gebiet',
properties: 'Immobilien',
properties: 'Verkaufte Immobilien',
postcode: 'Postcode',
noAreaSelected: 'Kein Gebiet ausgewählt',
noAreaSelectedDesc:
@ -631,6 +631,8 @@ const de: Translations = {
email: 'E-Mail',
emailPlaceholder: 'name@beispiel.de',
password: 'Passwort',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
passwordPlaceholderLogin: 'Dein Passwort',
forgotPassword: 'Passwort vergessen?',
@ -644,6 +646,16 @@ const de: Translations = {
registrationFailed: 'Registrierung fehlgeschlagen',
oauthFailed: 'OAuth-Anmeldung fehlgeschlagen',
passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts fehlgeschlagen',
newPassword: 'Neues Passwort',
newPasswordPlaceholder: 'Mindestens 8 Zeichen',
updatePassword: 'Passwort aktualisieren',
resetSuccessBody:
'Dein Passwort wurde geändert. Du kannst dich jetzt mit deinem neuen Passwort anmelden.',
resetMissingToken:
'Dieser Link zum Zurücksetzen ist unvollständig. Fordere über die Anmeldeseite einen neuen an.',
resetInvalidLink:
'Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Fordere über die Anmeldeseite einen neuen an.',
goToLogin: 'Zur Anmeldung',
},
// ── Upgrade Modal ──────────────────────────────────
@ -951,6 +963,10 @@ const de: Translations = {
viewProperties: '{{count}} Immobilien ansehen',
viewPropertiesShort: 'Immobilien ansehen',
priceHistory: 'Preisentwicklung',
priceHistoryThisArea: 'Dieses Gebiet',
priceMetric: 'Preisbasis',
priceMetricTotal: 'Gesamt',
priceMetricPerSqm: 'Pro m²',
journeysFrom: 'Fahrzeiten für {{label}}',
to: 'Nach {{destination}}',
noJourneyData: 'Keine Verbindungsdaten verfügbar',
@ -1576,6 +1592,10 @@ const de: Translations = {
listing: 'Inserat',
showingOf: '{{visible}} von {{count}} werden angezeigt',
groupedNear: 'Gruppiert an dieser Kartenposition',
priceHistory: 'Preisverlauf',
priceListed: 'Gelistet',
priceReduced: 'Reduziert',
priceIncreased: 'Erhöht',
},
overlays: {
@ -1896,6 +1916,7 @@ const de: Translations = {
'Tube station': 'U-Bahn-Station',
'DLR station': 'DLR-Station',
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
'Any station': 'Beliebige Station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',

View file

@ -38,7 +38,7 @@ const en = {
minute: 'min',
or: 'or',
area: 'Area',
properties: 'Properties',
properties: 'Sold properties',
postcode: 'Postcode',
noAreaSelected: 'No area selected',
noAreaSelectedDesc:
@ -618,6 +618,8 @@ const en = {
email: 'Email',
emailPlaceholder: 'you@example.com',
password: 'Password',
showPassword: 'Show password',
hidePassword: 'Hide password',
passwordPlaceholderRegister: 'Min 8 characters',
passwordPlaceholderLogin: 'Your password',
forgotPassword: 'Forgot password?',
@ -631,6 +633,15 @@ const en = {
registrationFailed: 'Registration failed',
oauthFailed: 'OAuth login failed',
passwordResetFailed: 'Password reset request failed',
newPassword: 'New password',
newPasswordPlaceholder: 'At least 8 characters',
updatePassword: 'Update password',
resetSuccessBody: 'Your password has been changed. You can now log in with your new password.',
resetMissingToken:
'This password reset link is incomplete. Request a new one from the log in screen.',
resetInvalidLink:
'This reset link is invalid or has expired. Request a new one from the log in screen.',
goToLogin: 'Go to log in',
},
// ── Upgrade Modal ──────────────────────────────────
@ -939,6 +950,10 @@ const en = {
viewProperties: 'View {{count}} properties',
viewPropertiesShort: 'View properties',
priceHistory: 'Price History',
priceHistoryThisArea: 'This area',
priceMetric: 'Price basis',
priceMetricTotal: 'Total',
priceMetricPerSqm: 'Per m²',
journeysFrom: 'Journey times for {{label}}',
to: 'To {{destination}}',
noJourneyData: 'No journey data available',
@ -1556,6 +1571,10 @@ const en = {
listing: 'Listing',
showingOf: 'Showing {{visible}} of {{count}}',
groupedNear: 'Grouped near this map position',
priceHistory: 'Price history',
priceListed: 'Listed',
priceReduced: 'Reduced',
priceIncreased: 'Increased',
},
// ── Map Overlays Pane ──────────────────────────────
@ -1878,6 +1897,7 @@ const en = {
'Tube station': 'Tube station',
'DLR station': 'DLR station',
'Tram & Metro stop': 'Tram & Metro stop',
'Any station': 'Any station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',

View file

@ -40,7 +40,7 @@ const fr: Translations = {
minute: 'min',
or: 'ou',
area: 'Secteur',
properties: 'Biens',
properties: 'Biens vendus',
postcode: 'Code postal',
noAreaSelected: 'Aucun secteur sélectionné',
noAreaSelectedDesc:
@ -642,6 +642,8 @@ const fr: Translations = {
email: 'E-mail',
emailPlaceholder: 'vous@exemple.com',
password: 'Mot de passe',
showPassword: 'Afficher le mot de passe',
hidePassword: 'Masquer le mot de passe',
passwordPlaceholderRegister: '8 caractères minimum',
passwordPlaceholderLogin: 'Votre mot de passe',
forgotPassword: 'Mot de passe oublié ?',
@ -655,6 +657,16 @@ const fr: Translations = {
registrationFailed: 'Échec de linscription',
oauthFailed: 'Échec de la connexion OAuth',
passwordResetFailed: 'Échec de la demande de réinitialisation du mot de passe',
newPassword: 'Nouveau mot de passe',
newPasswordPlaceholder: 'Au moins 8 caractères',
updatePassword: 'Mettre à jour le mot de passe',
resetSuccessBody:
'Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.',
resetMissingToken:
'Ce lien de réinitialisation est incomplet. Demandez-en un nouveau depuis lécran de connexion.',
resetInvalidLink:
'Ce lien de réinitialisation est invalide ou a expiré. Demandez-en un nouveau depuis lécran de connexion.',
goToLogin: 'Aller à la connexion',
},
// ── Upgrade Modal ──────────────────────────────────
@ -961,6 +973,10 @@ const fr: Translations = {
viewProperties: 'Voir {{count}} biens',
viewPropertiesShort: 'Voir les biens',
priceHistory: 'Historique des prix',
priceHistoryThisArea: 'Cette zone',
priceMetric: 'Base de prix',
priceMetricTotal: 'Total',
priceMetricPerSqm: 'Au m²',
journeysFrom: 'Temps de trajet pour {{label}}',
to: 'Vers {{destination}}',
noJourneyData: 'Aucune donnée de trajet disponible',
@ -1593,6 +1609,10 @@ const fr: Translations = {
listing: 'Annonce',
showingOf: 'Affichage de {{visible}} sur {{count}}',
groupedNear: 'Regroupées près de cette position sur la carte',
priceHistory: 'Historique des prix',
priceListed: 'Mis en vente',
priceReduced: 'Réduit',
priceIncreased: 'Augmenté',
},
// ── Overlays ───────────────────────────────────────
@ -1916,6 +1936,7 @@ const fr: Translations = {
'Tube station': 'Station de métro londonien',
'DLR station': 'Station DLR',
'Tram & Metro stop': 'Arrêt de tramway et métro',
'Any station': 'Toute station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',

View file

@ -39,7 +39,7 @@ const hi: Translations = {
minute: 'मिनट',
or: 'या',
area: 'क्षेत्र',
properties: 'प्रॉपर्टीज़',
properties: 'बिकी हुई प्रॉपर्टीज़',
postcode: 'पोस्टकोड',
noAreaSelected: 'कोई क्षेत्र चुना नहीं गया',
noAreaSelectedDesc:
@ -615,6 +615,8 @@ const hi: Translations = {
email: 'ईमेल',
emailPlaceholder: 'you@example.com',
password: 'पासवर्ड',
showPassword: 'पासवर्ड दिखाएं',
hidePassword: 'पासवर्ड छिपाएं',
passwordPlaceholderRegister: 'कम से कम 8 अक्षर',
passwordPlaceholderLogin: 'आपका पासवर्ड',
forgotPassword: 'पासवर्ड भूल गए?',
@ -628,6 +630,13 @@ const hi: Translations = {
registrationFailed: 'पंजीकरण विफल रहा',
oauthFailed: 'OAuth लॉग इन विफल रहा',
passwordResetFailed: 'पासवर्ड रीसेट अनुरोध विफल रहा',
newPassword: 'नया पासवर्ड',
newPasswordPlaceholder: 'कम से कम 8 अक्षर',
updatePassword: 'पासवर्ड अपडेट करें',
resetSuccessBody: 'आपका पासवर्ड बदल दिया गया है। अब आप अपने नए पासवर्ड से लॉग इन कर सकते हैं।',
resetMissingToken: 'यह रीसेट लिंक अधूरा है। लॉग इन स्क्रीन से नया लिंक प्राप्त करें।',
resetInvalidLink: 'यह रीसेट लिंक अमान्य है या समाप्त हो चुका है। लॉग इन स्क्रीन से नया लिंक प्राप्त करें।',
goToLogin: 'लॉग इन पर जाएँ',
},
upgrade: {
@ -919,6 +928,10 @@ const hi: Translations = {
viewProperties: '{{count}} संपत्तियां देखें',
viewPropertiesShort: 'संपत्तियां देखें',
priceHistory: 'कीमत इतिहास',
priceHistoryThisArea: 'यह क्षेत्र',
priceMetric: 'मूल्य आधार',
priceMetricTotal: 'कुल',
priceMetricPerSqm: 'प्रति मी²',
journeysFrom: '{{label}} के लिए यात्रा समय',
to: '{{destination}} तक',
noJourneyData: 'कोई यात्रा डेटा उपलब्ध नहीं',
@ -1511,6 +1524,10 @@ const hi: Translations = {
listing: 'लिस्टिंग',
showingOf: '{{count}} में से {{visible}} दिखाई जा रही हैं',
groupedNear: 'इस मैप स्थिति के पास समूहीकृत',
priceHistory: 'मूल्य इतिहास',
priceListed: 'सूचीबद्ध',
priceReduced: 'घटाया गया',
priceIncreased: 'बढ़ाया गया',
},
overlays: {
@ -1800,6 +1817,7 @@ const hi: Translations = {
'Tube station': 'ट्यूब स्टेशन',
'DLR station': 'DLR स्टेशन',
'Tram & Metro stop': 'ट्राम और मेट्रो स्टॉप',
'Any station': 'कोई भी स्टेशन',
Café: 'कैफे',
Restaurant: 'रेस्तरां',
Pub: 'पब',

View file

@ -40,7 +40,7 @@ const hu: Translations = {
minute: 'perc',
or: 'vagy',
area: 'Terület',
properties: 'Ingatlanok',
properties: 'Eladott ingatlanok',
postcode: 'Irányítószám',
noAreaSelected: 'Nincs kiválasztott terület',
noAreaSelectedDesc:
@ -633,6 +633,8 @@ const hu: Translations = {
email: 'E-mail',
emailPlaceholder: 'te@pelda.hu',
password: 'Jelszó',
showPassword: 'Jelszó megjelenítése',
hidePassword: 'Jelszó elrejtése',
passwordPlaceholderRegister: 'Minimum 8 karakter',
passwordPlaceholderLogin: 'Jelszavad',
forgotPassword: 'Elfelejtetted a jelszavad?',
@ -646,6 +648,16 @@ const hu: Translations = {
registrationFailed: 'A regisztráció sikertelen',
oauthFailed: 'Az OAuth-bejelentkezés sikertelen',
passwordResetFailed: 'A jelszó-visszaállítási kérés sikertelen',
newPassword: 'Új jelszó',
newPasswordPlaceholder: 'Legalább 8 karakter',
updatePassword: 'Jelszó frissítése',
resetSuccessBody:
'A jelszavad megváltozott. Mostantól bejelentkezhetsz az új jelszavaddal.',
resetMissingToken:
'Ez a jelszó-visszaállító link hiányos. Kérj egy újat a bejelentkezési képernyőről.',
resetInvalidLink:
'Ez a visszaállító link érvénytelen vagy lejárt. Kérj egy újat a bejelentkezési képernyőről.',
goToLogin: 'Tovább a bejelentkezéshez',
},
// ── Upgrade Modal ──────────────────────────────────
@ -949,6 +961,10 @@ const hu: Translations = {
viewProperties: '{{count}} ingatlan megtekintése',
viewPropertiesShort: 'Ingatlanok megtekintése',
priceHistory: 'Ártörténet',
priceHistoryThisArea: 'Ez a terület',
priceMetric: 'Ár alapja',
priceMetricTotal: 'Teljes',
priceMetricPerSqm: 'm²-enként',
journeysFrom: 'Utazási idők ehhez: {{label}}',
to: 'Ide: {{destination}}',
noJourneyData: 'Nincs elérhető utazási adat',
@ -1576,6 +1592,10 @@ const hu: Translations = {
listing: 'Hirdetés',
showingOf: '{{visible}} / {{count}} megjelenítve',
groupedNear: 'Ennél a térképponton csoportosítva',
priceHistory: 'Ártörténet',
priceListed: 'Meghirdetve',
priceReduced: 'Csökkentve',
priceIncreased: 'Növelve',
},
// ── Overlays ───────────────────────────────────────
@ -1898,6 +1918,7 @@ const hu: Translations = {
'Tube station': 'Londoni metróállomás',
'DLR station': 'DLR-állomás',
'Tram & Metro stop': 'Villamos- és metrómegálló',
'Any station': 'Bármely állomás',
Café: 'Kávézó',
Restaurant: 'Étterem',
Pub: 'Kocsma',

View file

@ -40,7 +40,7 @@ const zh: Translations = {
minute: '分钟',
or: '或',
area: '区域',
properties: '房产',
properties: '已售房产',
postcode: '邮编',
noAreaSelected: '未选择区域',
noAreaSelectedDesc: '点击地图上的任意彩色区域,查看治安、学校、房价等信息',
@ -579,6 +579,8 @@ const zh: Translations = {
email: '邮箱',
emailPlaceholder: 'you@example.com',
password: '密码',
showPassword: '显示密码',
hidePassword: '隐藏密码',
passwordPlaceholderRegister: '至少 8 个字符',
passwordPlaceholderLogin: '您的密码',
forgotPassword: '忘记密码?',
@ -592,6 +594,13 @@ const zh: Translations = {
registrationFailed: '注册失败',
oauthFailed: 'OAuth 登录失败',
passwordResetFailed: '密码重置请求失败',
newPassword: '新密码',
newPasswordPlaceholder: '至少 8 个字符',
updatePassword: '更新密码',
resetSuccessBody: '您的密码已更改。现在可以使用新密码登录。',
resetMissingToken: '此重置链接不完整。请从登录界面重新获取。',
resetInvalidLink: '此重置链接无效或已过期。请从登录界面重新获取。',
goToLogin: '前往登录',
},
// ── Upgrade Modal ──────────────────────────────────
@ -887,6 +896,10 @@ const zh: Translations = {
viewProperties: '查看 {{count}} 套房产',
viewPropertiesShort: '查看房产',
priceHistory: '价格历史',
priceHistoryThisArea: '此区域',
priceMetric: '价格基准',
priceMetricTotal: '总价',
priceMetricPerSqm: '每平方米',
journeysFrom: '{{label}} 的出行时间',
to: '前往 {{destination}}',
noJourneyData: '暂无出行数据',
@ -1492,6 +1505,10 @@ const zh: Translations = {
listing: '房源',
showingOf: '显示 {{count}} 套中的 {{visible}} 套',
groupedNear: '按此地图位置聚合',
priceHistory: '价格记录',
priceListed: '上架',
priceReduced: '降价',
priceIncreased: '涨价',
},
// ── Overlays ───────────────────────────────────────
@ -1811,6 +1828,7 @@ const zh: Translations = {
'Tube station': '伦敦地铁站',
'DLR station': 'DLR 轻轨站',
'Tram & Metro stop': '有轨电车与城市轨道站',
'Any station': '任意车站',
Café: '咖啡馆',
Restaurant: '餐厅',
Pub: '酒吧',

View file

@ -8,7 +8,7 @@ import type { TFunction } from 'i18next';
*/
export type AuthErrorAction = 'switchToLogin' | null;
export type AuthContext = 'login' | 'register' | 'oauth' | 'reset';
export type AuthContext = 'login' | 'register' | 'oauth' | 'reset' | 'confirmReset';
export interface ResolvedAuthError {
/** User-facing message (already resolved to a string). */
@ -75,5 +75,17 @@ export function resolveAuthError(
return { message: t('auth.oauthFailed'), action: null };
}
// Submitting a new password from the emailed reset link. A weak password comes
// back as a field error; anything else (bad/expired/used token) is a dead link.
if (context === 'confirmReset') {
if (fieldCode(e, 'password')) {
return { message: t('auth.errorPasswordWeak'), action: null };
}
if (status === 400 || status === 404) {
return { message: t('auth.resetInvalidLink'), action: null };
}
return { message: t('auth.passwordResetFailed'), action: null };
}
return { message: t('auth.passwordResetFailed'), action: null };
}

View file

@ -1,4 +1,5 @@
import type { ViewState } from '../types';
import type { TravelTimeInitial } from '../hooks/useTravelTime';
export const INITIAL_RETRY_MS = 1000;
export const MAX_RETRY_MS = 10000;
@ -45,14 +46,32 @@ export function filterCapFor(isLoggedIn: boolean, filtersUnlimited: boolean): nu
}
/** Funnel fix (growth): a cold map open lands empty, so first-time visitors never feel the value
* or the 3-filter cap. These two high-intent filters (value for money + good secondary schools)
* are pre-seeded when the map opens with no filters in the URL, so the map is immediately useful
* and one more filter hits the cap. Deep links (OG screenshots, the SEO landing-page CTAs) carry
* their own filters and are left untouched. Unknown feature names are dropped safely by useFilters.
* Tune or empty this object to change/disable the behaviour. */
* or the 3-filter cap. On a cold open (no filters AND no travel time in the URL) we pre-seed the
* price filter plus a public-transport commute card, so the map is immediately useful and framed
* around the two highest-intent decisions: budget and commute. Deep links (OG screenshots, the
* SEO landing-page CTAs) carry their own filters/travel and are left untouched. Unknown feature
* names are dropped safely by useFilters. Tune or empty these to change/disable the behaviour. */
export const DEFAULT_DEMO_FILTERS: Record<string, [number, number]> = {
// 'Est. price per sqm': [0, 7000],
// 'Good+ secondary school catchments': [1, 11],
'Estimated current price': [0, 600000],
};
/** The public-transport commute half of the cold-open defaults. A single transit entry with no
* destination selected: it renders the "Public Transport" card (prompting the visitor to pick a
* destination) without filtering the map or touching the URL until they choose one. Mirrors what
* clicking "add public transport" produces, so it flows through useTravelTime unchanged. */
export const DEFAULT_DEMO_TRAVEL: TravelTimeInitial = {
entries: [
{
mode: 'transit',
slug: '',
label: '',
timeRange: null,
useBest: false,
noChange: false,
oneChange: false,
noBuses: false,
},
],
};
/**

View file

@ -19,6 +19,7 @@ export const POI_DISTANCE_FILTER_KEY_PREFIX = `${POI_DISTANCE_FILTER_NAME}:`;
const TRANSPORT_POI_CATEGORIES = new Set([
'Airport',
'Any station',
'Bus station',
'Bus stop',
'DLR station',

View file

@ -0,0 +1,22 @@
/**
* Postcode-component helpers, mirroring the server's `postcode_outcode` /
* `postcode_sector` (server-rs/src/utils.rs) so labels match the aggregations.
*/
/** Outward code of a postcode: the part before the space. "E14 2DG" -> "E14". */
export function postcodeOutcode(postcode: string): string | null {
const trimmed = postcode.trim().toUpperCase();
const space = trimmed.indexOf(' ');
return space > 0 ? trimmed.slice(0, space) : null;
}
/**
* Postcode sector: the outward code, the space, and the first character of the
* inward code. "E14 2DG" -> "E14 2".
*/
export function postcodeSector(postcode: string): string | null {
const trimmed = postcode.trim().toUpperCase();
const space = trimmed.indexOf(' ');
if (space <= 0 || space + 1 >= trimmed.length) return null;
return trimmed.slice(0, space + 2);
}

View file

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { parseUrlState, stateToParams } from './url-state';
import { parseUrlState, stateToParams, stripSelectedPostcodeParam } from './url-state';
import { DEFAULT_OVERLAY_IDS } from './overlays';
import { INITIAL_VIEW_STATE } from './consts';
import { createSchoolFilterKey } from './school-filter';
@ -60,6 +60,24 @@ describe('url-state', () => {
]);
});
it('strips the selected-postcode param for saved searches, keeping filters intact', () => {
const params =
'lat=51.5074&lon=-0.1278&zoom=12.5&filter=Last%20known%20price:100000:500000&pc=SW1A%201AA&poi=supermarket';
const stripped = stripSelectedPostcodeParam(params);
const result = new URLSearchParams(stripped);
expect(result.has('pc')).toBe(false);
expect(result.get('filter')).toBe('Last known price:100000:500000');
expect(result.get('poi')).toBe('supermarket');
expect(result.get('lat')).toBe('51.5074');
});
it('returns the query string unchanged when no selected-postcode param is present', () => {
const params = 'filter=Last%20known%20price:100000:500000&poi=supermarket';
expect(stripSelectedPostcodeParam(params)).toBe(params);
});
it('leaves POIs unselected when URL params are omitted', () => {
const state = parseUrlState();

View file

@ -92,6 +92,23 @@ const CRIME_TYPES_NONE_PARAM = '__none';
const OVERLAY_NONE_PARAM = '__none';
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
/** URL param holding the currently focused postcode (from a search or a map
* click). It reflects a transient selection, not search criteria, so a live or
* shared link keeps it but a saved search must not bake it in. */
export const SELECTED_POSTCODE_PARAM = 'pc';
/**
* Drop the transient selected-postcode param from a serialized query string so a
* saved search captures the filter criteria, not whichever postcode the user
* last clicked. Takes and returns a query string without the leading '?'.
*/
export function stripSelectedPostcodeParam(params: string): string {
const parsed = new URLSearchParams(params);
if (!parsed.has(SELECTED_POSTCODE_PARAM)) return params;
parsed.delete(SELECTED_POSTCODE_PARAM);
return parsed.toString();
}
export interface UrlState {
viewState: ViewState;
/** True only when the URL carried explicit lat/lon/zoom (shared/dashboard link).
@ -406,7 +423,7 @@ export function parseUrlState(): UrlState {
// Selected postcode. This is also accepted as the historical one-time
// navigate-to-postcode param used by saved-property links.
const pc = params.get('pc');
const pc = params.get(SELECTED_POSTCODE_PARAM);
if (pc) {
result.postcode = pc;
}

View file

@ -158,6 +158,15 @@ export interface ActualListing {
listing_status?: string;
listing_date_iso?: string;
features: string[];
price_history?: PriceHistoryPoint[];
}
/** One observed point on a listing's asking-price timeline, accrued across
* scrapes. `reason` is 'listed' | 'reduced' | 'increased'; `date` is YYYY-MM-DD. */
export interface PriceHistoryPoint {
date: string;
price: number;
reason: string;
}
export interface ActualListingsResponse {
@ -303,8 +312,14 @@ export interface EnumFeatureStats {
export interface PricePoint {
year: number;
price: number;
/** Sale price per square metre (sale price / EPC floor area). Absent where no
* floor area is recorded; the per-m² view drops those points. */
price_per_sqm?: number;
}
/** Which value a price-history chart plots. */
export type PriceMetric = 'price' | 'price_per_sqm';
export interface CrimeYearPoint {
year: number;
count: number;
@ -369,6 +384,12 @@ export interface HexagonStatsResponse {
numeric_features: NumericFeatureStats[];
enum_features: EnumFeatureStats[];
price_history?: PricePoint[];
/** Price history for every sale in the selection's postcode sector (e.g.
* "E14 2"), filter-independent: wider-area context for the selection's chart. */
sector_price_history?: PricePoint[];
/** Price history for every sale in the selection's outward code (e.g. "E14"),
* filter-independent. */
outcode_price_history?: PricePoint[];
/** Per-crime-type per-year counts averaged across the selection. */
crime_by_year?: CrimeYearStats[];
/**

View file

@ -7,9 +7,13 @@ from pathlib import Path
import pytest
from pipeline.download.transit_network import (
STATION_COORD_OVERRIDES,
_repair_stop_coordinate,
clean_national_rail_gtfs,
convert_high_freq_to_frequency_based,
validate_gtfs_feed,
validate_london_coverage,
validate_stop_geometry,
)
@ -231,9 +235,7 @@ def test_validate_gtfs_feed_zero_and_empty_coords(tmp_path: Path) -> None:
feed = _make_gtfs(
tmp_path / "feed.zip",
stops=(
"stop_id,stop_name,stop_lat,stop_lon\n"
"STOP_A,Nowhere,0,0\n"
"STOP_B,Blank,,\n"
"stop_id,stop_name,stop_lat,stop_lon\nSTOP_A,Nowhere,0,0\nSTOP_B,Blank,,\n"
),
)
with pytest.raises(RuntimeError, match=r"plausible UK coordinates"):
@ -281,3 +283,254 @@ def test_validate_gtfs_feed_not_a_zip(tmp_path: Path) -> None:
bogus.write_text("not a zip")
with pytest.raises(RuntimeError, match="not a valid zip"):
validate_gtfs_feed(bogus, "bogus feed", today=TODAY)
# ── _repair_stop_coordinate ───────────────────────────────────────────────────
@pytest.mark.parametrize(
"stop_id,lat,lon,expected",
[
# Known-bad stations get an authoritative override (TCR ships transposed,
# BDS ships a wrong-signed longitude; both are in STATION_COORD_OVERRIDES).
("TCR", -0.1306, 51.5163, (*STATION_COORD_OVERRIDES["TCR"], "override")),
("BDS", 51.514, 0.15, (*STATION_COORD_OVERRIDES["BDS"], "override")),
# A plausible UK coordinate is left untouched.
("ZFD", 51.5205, -0.1050, (51.5205, -0.1050, "keep")),
# An unknown station with lat/lon transposed is swapped back generically.
("ZZZ", -0.13, 51.51, (51.51, -0.13, "transpose")),
# Genuinely out-of-area garbage (Irish CIE South Atlantic) is neutralised.
("IEP", -4.172, -14.5154, (54.0, -2.0, "dump")),
# Missing coordinates are neutralised too.
("NUL", None, None, (54.0, -2.0, "dump")),
],
)
def test_repair_stop_coordinate(stop_id, lat, lon, expected) -> None:
assert _repair_stop_coordinate(stop_id, lat, lon) == expected
def test_clean_national_rail_repairs_broken_station_coords(tmp_path: Path) -> None:
"""End-to-end: the cleaner repairs the exact TCR/BDS failure modes.
TCR (transposed) and BDS (wrong-signed lon) are corrected to their override
coordinates; an unknown transposed stop is swapped back; genuine out-of-area
garbage is dumped; a good coordinate is preserved.
"""
src = tmp_path / "in.zip"
dst = tmp_path / "out.zip"
stops = (
"stop_id,stop_name,stop_lat,stop_lon\n"
"TCR,Tottenham Court Road (Elizabeth line),-0.1306,51.5163\n"
"BDS,Bond Street (Elizabeth line),51.514,0.15\n"
"ZZZ,Transposed Halt,-0.20,51.40\n"
"IEP,Cork (CIE),-4.172,-14.5154\n"
"GUD,Good Station,51.50,-0.10\n"
)
with zipfile.ZipFile(src, "w") as z:
z.writestr("stops.txt", stops)
z.writestr("routes.txt", "route_id,route_type\nR1,2\n")
z.writestr("trips.txt", "trip_id,route_id,service_id\nT1,R1,S1\n")
z.writestr(
"stop_times.txt",
"trip_id,stop_id,stop_sequence,departure_time\n"
"T1,TCR,1,06:00:00\n"
"T1,BDS,2,06:03:00\n"
"T1,GUD,3,06:06:00\n",
)
clean_national_rail_gtfs(src, dst)
with zipfile.ZipFile(dst, "r") as z:
rows = z.read("stops.txt").decode("utf-8").splitlines()
coords = {r.split(",")[0]: r.split(",")[-2:] for r in rows[1:]}
assert (
float(coords["TCR"][0]),
float(coords["TCR"][1]),
) == STATION_COORD_OVERRIDES["TCR"]
assert (
float(coords["BDS"][0]),
float(coords["BDS"][1]),
) == STATION_COORD_OVERRIDES["BDS"]
assert (float(coords["ZZZ"][0]), float(coords["ZZZ"][1])) == (51.40, -0.20)
assert (float(coords["IEP"][0]), float(coords["IEP"][1])) == (54.0, -2.0)
assert (float(coords["GUD"][0]), float(coords["GUD"][1])) == (51.50, -0.10)
# ── validate_stop_geometry ────────────────────────────────────────────────────
def _geometry_gtfs(path: Path, *, n_trips: int, b_lat: float, b_lon: float) -> Path:
"""A metro line ABC (5-minute hops) repeated across n_trips.
Displacing B far from A and C makes it a displacement outlier; n_trips sets
its service level (the hard-fail vs warn tier).
"""
routes = "route_id,route_type\nR1,1\n"
trips = "trip_id,route_id,service_id\n" + "".join(
f"T{i},R1,S1\n" for i in range(n_trips)
)
stops = (
"stop_id,stop_name,stop_lat,stop_lon\n"
"A,Aaa,51.50,-0.10\n"
f"B,Bbb,{b_lat},{b_lon}\n"
"C,Ccc,51.52,-0.10\n"
)
header = "trip_id,stop_id,stop_sequence,arrival_time,departure_time\n"
body = "".join(
f"T{i},A,0,06:00:00,06:00:00\n"
f"T{i},B,1,06:05:00,06:05:00\n"
f"T{i},C,2,06:10:00,06:10:00\n"
for i in range(n_trips)
)
with zipfile.ZipFile(path, "w") as z:
z.writestr("routes.txt", routes)
z.writestr("trips.txt", trips)
z.writestr("stops.txt", stops)
z.writestr("stop_times.txt", header + body)
return path
def test_validate_stop_geometry_fails_on_high_service_displacement(
tmp_path: Path,
) -> None:
"""A busy stop whose trains imply teleportation fails the build (TCR mode)."""
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=120, b_lat=58.0, b_lon=-2.0)
with pytest.raises(RuntimeError, match="stop-geometry validation failed"):
validate_stop_geometry(feed, "displaced feed")
def test_validate_stop_geometry_passes_when_stops_are_coherent(
tmp_path: Path,
) -> None:
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=120, b_lat=51.51, b_lon=-0.10)
validate_stop_geometry(feed, "coherent feed") # must not raise
def test_validate_stop_geometry_only_warns_on_low_service_displacement(
tmp_path: Path,
) -> None:
"""Heritage-line quirks (few trips) warn but do not block a build."""
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=6, b_lat=58.0, b_lon=-2.0)
validate_stop_geometry(feed, "heritage feed") # must not raise
# ── validate_london_coverage ──────────────────────────────────────────────────
_ALL_LU = (
"Bakerloo",
"Central",
"Circle",
"District",
"Hammersmith & City",
"Jubilee",
"Metropolitan",
"Northern",
"Piccadilly",
"Victoria",
"Waterloo & City",
)
_COVERAGE_CALENDAR = (
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
"start_date,end_date\n"
"S1,1,1,1,1,1,1,1,20260101,20271231\n"
)
def _bods_coverage(
path: Path,
*,
lu_lines: tuple[str, ...] = _ALL_LU,
include_dlr: bool = True,
include_tramlink: bool = True,
) -> Path:
routes = ["route_id,agency_id,route_short_name,route_long_name,route_type"]
trips = ["trip_id,route_id,service_id"]
n = 0
for line in lu_lines:
routes.append(f"LU{n},LU,{line},,1")
trips.append(f"T{n},LU{n},S1")
n += 1
if include_dlr:
routes.append(f"DLR{n},DLRA,DLR,Docklands Light Railway,2")
trips.append(f"T{n},DLR{n},S1")
n += 1
if include_tramlink:
routes.append(f"TR{n},TRAM,Tram,London Tramlink,0")
trips.append(f"T{n},TR{n},S1")
n += 1
with zipfile.ZipFile(path, "w") as z:
z.writestr("calendar.txt", _COVERAGE_CALENDAR)
z.writestr("routes.txt", "\n".join(routes) + "\n")
z.writestr("trips.txt", "\n".join(trips) + "\n")
return path
def _nr_coverage(
path: Path, *, include_elizabeth: bool = True, include_overground: bool = True
) -> Path:
routes = ["route_id,agency_id,route_short_name,route_long_name,route_type"]
trips = ["trip_id,route_id,service_id"]
if include_elizabeth:
routes.append("XR1,XR,XR:PAD->ABW,Elizabeth line,2")
trips.append("TX,XR1,S1")
if include_overground:
routes.append("LO1,LO,LO,London Overground,2")
trips.append("TL,LO1,S1")
with zipfile.ZipFile(path, "w") as z:
z.writestr("calendar.txt", _COVERAGE_CALENDAR)
z.writestr("routes.txt", "\n".join(routes) + "\n")
z.writestr("trips.txt", "\n".join(trips) + "\n")
return path
def test_validate_london_coverage_happy_path(tmp_path: Path) -> None:
bods = _bods_coverage(tmp_path / "bods.zip")
nr = _nr_coverage(tmp_path / "nr.zip")
validate_london_coverage(bods, nr, today=TODAY) # must not raise
def test_validate_london_coverage_missing_tube_line_fails(tmp_path: Path) -> None:
bods = _bods_coverage(
tmp_path / "bods.zip",
lu_lines=tuple(name for name in _ALL_LU if name != "Victoria"),
)
nr = _nr_coverage(tmp_path / "nr.zip")
with pytest.raises(RuntimeError, match="Victoria"):
validate_london_coverage(bods, nr, today=TODAY)
def test_validate_london_coverage_missing_dlr_fails(tmp_path: Path) -> None:
bods = _bods_coverage(tmp_path / "bods.zip", include_dlr=False)
nr = _nr_coverage(tmp_path / "nr.zip")
with pytest.raises(RuntimeError, match="DLR"):
validate_london_coverage(bods, nr, today=TODAY)
def test_validate_london_coverage_missing_elizabeth_fails(tmp_path: Path) -> None:
bods = _bods_coverage(tmp_path / "bods.zip")
nr = _nr_coverage(tmp_path / "nr.zip", include_elizabeth=False)
with pytest.raises(RuntimeError, match="Elizabeth line"):
validate_london_coverage(bods, nr, today=TODAY)
def test_validate_london_coverage_expired_service_fails(tmp_path: Path) -> None:
"""A line whose only calendar expired years ago counts as missing (present in
routes.txt but with no active service the retired-TfL-feed failure mode)."""
bods = _bods_coverage(tmp_path / "bods.zip")
nr = tmp_path / "nr.zip"
expired = (
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
"start_date,end_date\n"
"S1,1,1,1,1,1,1,1,20091201,20101224\n"
)
with zipfile.ZipFile(nr, "w") as z:
z.writestr("calendar.txt", expired)
z.writestr(
"routes.txt",
"route_id,agency_id,route_short_name,route_long_name,route_type\n"
"XR1,XR,XR:PAD->ABW,Elizabeth line,2\nLO1,LO,LO,London Overground,2\n",
)
z.writestr("trips.txt", "trip_id,route_id,service_id\nTX,XR1,S1\nTL,LO1,S1\n")
with pytest.raises(RuntimeError, match="Elizabeth line|Overground"):
validate_london_coverage(bods, nr, today=TODAY)

View file

@ -42,6 +42,7 @@ import zipfile
from collections import defaultdict
from pathlib import Path
import polars as pl
from tqdm import tqdm
from pipeline.local_temp import local_tmp_dir
@ -66,6 +67,56 @@ GTFS_MIN_VALID_STOP_FRACTION = 0.95
UK_LAT_RANGE = (49.0, 61.0)
UK_LON_RANGE = (-9.0, 2.5)
# Authoritative (lat, lon) for stops that upstream feeds ship mislocated, keyed
# by GTFS stop_id (National Rail CRS code). The National Rail CIF → GTFS export
# ships the Elizabeth-line-only core stations broken: Tottenham Court Road (TCR)
# has its lat/lon transposed and Bond Street (BDS) has a wrong-signed longitude,
# leaving them ~300km and ~20km from reality. Both then fail to link to the
# street network, so nobody can board/alight the Elizabeth line there and every
# journey through them silently reroutes (e.g. TCR→Heathrow via the Central line
# + Heathrow Express instead of one seat on the Elizabeth line). Coordinates are
# the TfL/BODS station nodes for the same physical stations. New breakages that
# these overrides don't cover are caught by validate_stop_geometry() below, which
# fails the build rather than shipping a phantom stop.
STATION_COORD_OVERRIDES = {
"TCR": (51.51643, -0.13041), # Tottenham Court Road (Elizabeth line)
"BDS": (51.51430, -0.14972), # Bond Street (Elizabeth line)
}
# validate_stop_geometry(): a served tram/metro/rail stop is a "displacement
# outlier" when, over real timetabled hops (>= GEOMETRY_MIN_HOP_SECONDS, so that
# coarse timetables where adjacent stops share a minute don't count), the implied
# in-vehicle speed to a MAJORITY of its distinct trip-neighbours exceeds
# GEOMETRY_MAX_KMH. Such a stop sits nowhere near where its trains actually run
# (the TCR failure mode). Outliers with >= GEOMETRY_HARDFAIL_MIN_TRIPS timetabled
# trips FAIL the build; rarer ones (heritage lines) only warn. Scoped to
# rail/metro/tram route_types: buses carry demand-responsive "area" stops and
# same-minute urban hops that are noisy without being real geometry errors, and
# the R5-side unlinked-stop check covers gross bus displacement anyway.
GEOMETRY_RAIL_ROUTE_TYPES = frozenset({"0", "1", "2"})
GEOMETRY_MIN_HOP_SECONDS = 120
GEOMETRY_MAX_KMH = 300.0
GEOMETRY_HARDFAIL_MIN_TRIPS = 100
# London modes/lines that must be present with active service in the combined
# network. A feed regression that silently drops one (as the retired TfL
# TransXChange feed did — see module docstring) FAILS the build instead of
# quietly degrading every affected journey. Underground lines and Tramlink/DLR
# come from BODS; the Elizabeth line and Overground come from National Rail.
LONDON_UNDERGROUND_LINES = (
"Bakerloo",
"Central",
"Circle",
"District",
"Hammersmith & City",
"Jubilee",
"Metropolitan",
"Northern",
"Piccadilly",
"Victoria",
"Waterloo & City",
)
def _download_http(
url: str, dest: Path, *, desc: str, headers: dict | None = None
@ -804,6 +855,8 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
bad_trips_removed = 0
seqs_renumbered = 0
coords_fixed = 0
coords_overridden = 0
coords_transposed = 0
route_types_fixed = 0
with (
@ -890,6 +943,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
with zin.open(info) as f:
header = f.readline()
cols = _parse_csv_line(header)
stop_id_idx = cols.index("stop_id")
lat_idx = cols.index("stop_lat")
lon_idx = cols.index("stop_lon")
@ -905,16 +959,24 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
parts = _parse_csv_line(line)
if not parts:
continue
stop_id = parts[stop_id_idx].strip('"')
try:
lat = float(parts[lat_idx])
# Fix bogus Irish CIE coordinates (South Atlantic)
if lat < 0:
# Set to a neutral UK coordinate that won't be routed to
parts[lat_idx] = "54.0"
parts[lon_idx] = "-2.0"
lon = float(parts[lon_idx])
except (ValueError, IndexError):
lat = lon = None
new_lat, new_lon, action = _repair_stop_coordinate(
stop_id, lat, lon
)
if action != "keep":
parts[lat_idx] = repr(new_lat)
parts[lon_idx] = repr(new_lon)
if action == "override":
coords_overridden += 1
elif action == "transpose":
coords_transposed += 1
else: # "dump"
coords_fixed += 1
except ValueError:
pass
tmp.write(_format_csv_row(parts))
tmp.close()
@ -1014,7 +1076,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
print(f" Orphan stop references removed: {orphan_stops_removed}")
print(f" Bad trip stop_times removed: {bad_trips_removed}")
print(f" Stop sequences renumbered: {seqs_renumbered}")
print(f" Bogus coordinates fixed: {coords_fixed}")
print(f" Coordinates overridden (known-bad stations): {coords_overridden}")
print(f" Coordinates de-transposed (lat/lon swapped): {coords_transposed}")
print(f" Bogus coordinates dumped (out-of-area): {coords_fixed}")
print(f" Route types 714→3 fixed: {route_types_fixed}")
print(f" Saved to {dst}")
@ -1139,6 +1203,410 @@ def convert_national_rail_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
return dest
def _in_uk(lat: float, lon: float) -> bool:
"""True if (lat, lon) falls inside the coarse UK routing bounding box."""
return (
UK_LAT_RANGE[0] <= lat <= UK_LAT_RANGE[1]
and UK_LON_RANGE[0] <= lon <= UK_LON_RANGE[1]
)
def _repair_stop_coordinate(
stop_id: str, lat: float | None, lon: float | None
) -> tuple[float, float, str]:
"""Repair an obviously-broken stop coordinate. Returns (lat, lon, action).
action is one of:
"override" - an authoritative coordinate was substituted for a known-bad
station (see STATION_COORD_OVERRIDES).
"transpose" - the feed shipped lat/lon swapped (the coordinate is outside
the UK but swapping lands inside it), so they are swapped
back. This is the Tottenham Court Road failure mode and is
handled generically, not just for the hard-coded stations.
"dump" - the coordinate is genuinely out of area (Irish CIE stations
at 0,0-ish South Atlantic garbage, missing coordinates) and
is moved to a neutral inland point that will not be routed
to, preserving the historical behaviour for those stops.
"keep" - already a plausible UK coordinate; left unchanged.
"""
if stop_id in STATION_COORD_OVERRIDES:
return (*STATION_COORD_OVERRIDES[stop_id], "override")
if lat is None or lon is None:
return 54.0, -2.0, "dump"
if _in_uk(lat, lon):
return lat, lon, "keep"
if _in_uk(lon, lat):
return lon, lat, "transpose"
return 54.0, -2.0, "dump"
def _secs_expr(col: str) -> pl.Expr:
"""Polars expression parsing an HH:MM:SS GTFS time to seconds since midnight."""
parts = pl.col(col).str.split(":")
return (
parts.list.get(0).cast(pl.Int64, strict=False) * 3600
+ parts.list.get(1).cast(pl.Int64, strict=False) * 60
+ parts.list.get(2).cast(pl.Int64, strict=False)
)
def _extract_member(path: Path, member: str, dest_dir: str) -> Path:
"""Stream one file out of a GTFS zip to dest_dir (avoids holding it in RAM)."""
out = Path(dest_dir) / member
with zipfile.ZipFile(path) as z, z.open(member) as src, open(out, "wb") as dst:
shutil.copyfileobj(src, dst)
return out
def validate_stop_geometry(path: Path, feed_name: str) -> None:
"""Fail if a served rail/metro/tram stop is a coordinate displacement outlier.
Guards against the Tottenham Court Road failure mode: a stop whose timetabled
trains imply teleportation (>{GEOMETRY_MAX_KMH:.0f} km/h over a real hop) to a
MAJORITY of its distinct trip-neighbours is not where the feed places it, so
it cannot link to the street network and every journey through it silently
reroutes. High-service outliers (>= {GEOMETRY_HARDFAIL_MIN_TRIPS} trips) raise;
negligible-service ones (heritage lines) only warn. Scoped to tram/metro/rail
route types; see GEOMETRY_* constants.
"""
print(f"Validating stop geometry for feed '{feed_name}'...")
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as td:
routes = pl.read_csv(
_extract_member(path, "routes.txt", td), infer_schema_length=0
).select(
pl.col("route_id").cast(pl.Utf8),
pl.col("route_type").cast(pl.Utf8),
)
rail_route_ids = routes.filter(
pl.col("route_type").is_in(list(GEOMETRY_RAIL_ROUTE_TYPES))
).select("route_id")
trips = pl.read_csv(
_extract_member(path, "trips.txt", td), infer_schema_length=0
).select(
pl.col("trip_id").cast(pl.Utf8),
pl.col("route_id").cast(pl.Utf8),
)
rail_trips = trips.join(rail_route_ids, on="route_id", how="inner").select(
"trip_id"
)
if rail_trips.height == 0:
print(" no rail/metro/tram trips in feed; nothing to check")
return
stops = pl.read_csv(
_extract_member(path, "stops.txt", td), infer_schema_length=0
).select(
pl.col("stop_id").cast(pl.Utf8),
pl.col("stop_name").cast(pl.Utf8).alias("name"),
pl.col("stop_lat").cast(pl.Float64, strict=False).alias("lat"),
pl.col("stop_lon").cast(pl.Float64, strict=False).alias("lon"),
)
# Only rail/metro/tram stop_times, ordered within each trip.
st = (
pl.scan_csv(
_extract_member(path, "stop_times.txt", td), infer_schema_length=0
)
.select(
pl.col("trip_id").cast(pl.Utf8),
pl.col("stop_id").cast(pl.Utf8),
pl.col("stop_sequence").cast(pl.Int64, strict=False).alias("seq"),
_secs_expr("departure_time").alias("dep"),
_secs_expr("arrival_time").alias("arr"),
)
.join(rail_trips.lazy(), on="trip_id", how="inner")
.join(
stops.lazy().select(["stop_id", "lat", "lon"]), on="stop_id", how="left"
)
.collect()
.sort(["trip_id", "seq"])
)
# Service level: distinct trips serving each stop (the hard-fail tier).
svc = st.group_by("stop_id").agg(pl.col("trip_id").n_unique().alias("trips"))
# Consecutive-stop hops within a trip.
st = st.with_columns(
pl.col("lat").shift(1).over("trip_id").alias("plat"),
pl.col("lon").shift(1).over("trip_id").alias("plon"),
pl.col("stop_id").shift(1).over("trip_id").alias("pid"),
pl.col("dep").shift(1).over("trip_id").alias("pdep"),
)
earth_km = 6371.0
dlat = (pl.col("lat") - pl.col("plat")).radians()
dlon = (pl.col("lon") - pl.col("plon")).radians()
hav = (dlat / 2).sin() ** 2 + pl.col("plat").radians().cos() * pl.col(
"lat"
).radians().cos() * (dlon / 2).sin() ** 2
hops = (
st.with_columns(
(2 * earth_km * hav.sqrt().arcsin()).alias("dist_km"),
(pl.col("arr") - pl.col("pdep")).alias("dt_s"),
)
.filter(
pl.col("plat").is_not_null()
& pl.col("dist_km").is_not_null()
& (pl.col("dt_s") >= GEOMETRY_MIN_HOP_SECONDS)
)
.with_columns((pl.col("dist_km") / (pl.col("dt_s") / 3600.0)).alias("kmh"))
)
if hops.height == 0:
print(" no timetabled hops long enough to assess; skipping")
return
# Undirected stop-neighbour edges, flagged if any hop teleports.
fwd = hops.select(
pl.col("stop_id").alias("a"),
pl.col("pid").alias("b"),
(pl.col("kmh") > GEOMETRY_MAX_KMH).alias("tp"),
)
rev = fwd.select(pl.col("b").alias("a"), pl.col("a").alias("b"), "tp")
edges = (
pl.concat([fwd, rev])
.group_by(["a", "b"])
.agg(pl.col("tp").max().alias("tp"))
)
per_stop = edges.group_by("a").agg(
pl.len().alias("nbrs"), pl.col("tp").sum().alias("tp_nbrs")
)
outliers = (
per_stop.filter(
(pl.col("nbrs") >= 2) & (pl.col("tp_nbrs") / pl.col("nbrs") >= 0.5)
)
.join(stops, left_on="a", right_on="stop_id", how="left")
.join(svc, left_on="a", right_on="stop_id", how="left")
.with_columns(pl.col("trips").fill_null(0))
.sort("trips", descending=True)
)
hard = outliers.filter(pl.col("trips") >= GEOMETRY_HARDFAIL_MIN_TRIPS)
soft = outliers.filter(pl.col("trips") < GEOMETRY_HARDFAIL_MIN_TRIPS)
for r in soft.iter_rows(named=True):
print(
f" WARN low-service displacement outlier: {r['a']} "
f"'{r['name']}' ({r['trips']} trips) at ({r['lat']}, {r['lon']})"
)
if hard.height > 0:
lines = [
f" {r['a']} '{r['name']}' ({r['trips']} trips) "
f"at ({r['lat']}, {r['lon']})"
for r in hard.iter_rows(named=True)
]
raise RuntimeError(
f"stop-geometry validation failed for feed '{feed_name}': "
f"{hard.height} high-service rail/metro/tram stop(s) sit nowhere "
f"near where their trains run (implied speed > {GEOMETRY_MAX_KMH:.0f} "
f"km/h to most neighbours). These cannot link to the street network "
f"and every journey through them silently reroutes. Add an entry to "
f"STATION_COORD_OVERRIDES or fix the upstream feed:\n"
+ "\n".join(lines)
)
print(
f" OK: {outliers.height} displacement outlier(s), none at or above "
f"{GEOMETRY_HARDFAIL_MIN_TRIPS} trips"
)
def _active_service_ids(path: Path, window_start: int, window_end: int) -> set[str]:
"""Service ids with at least one running day in [window_start, window_end]."""
active: set[str] = set()
weekdays = (
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
)
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
if "calendar.txt" in names:
with z.open("calendar.txt") as f:
cols = _parse_csv_line(f.readline())
sid_i = cols.index("service_id")
start_i = cols.index("start_date")
end_i = cols.index("end_date")
day_i = [cols.index(d) for d in weekdays if d in cols]
for line in f:
parts = _parse_csv_line(line)
if not parts:
continue
try:
start = int(parts[start_i].strip('"'))
end = int(parts[end_i].strip('"'))
except (ValueError, IndexError):
continue
if start > window_end or end < window_start:
continue
if day_i and not any(
parts[i].strip('"') == "1" for i in day_i if i < len(parts)
):
continue
active.add(parts[sid_i].strip('"'))
if "calendar_dates.txt" in names:
with z.open("calendar_dates.txt") as f:
cols = _parse_csv_line(f.readline())
sid_i = cols.index("service_id")
date_i = cols.index("date")
exc_i = cols.index("exception_type")
for line in f:
parts = _parse_csv_line(line)
if not parts:
continue
try:
date = int(parts[date_i].strip('"'))
except (ValueError, IndexError):
continue
if exc_i < len(parts) and parts[exc_i].strip('"') != "1":
continue
if window_start <= date <= window_end:
active.add(parts[sid_i].strip('"'))
return active
def _lines_with_active_service(
path: Path,
active_services: set[str],
*,
route_predicate,
) -> set[str]:
"""Return the set of route labels (agency_id::short_name matched by
route_predicate) that have at least one trip on an active service.
route_predicate((agency_id, route_type, short_name, long_name)) -> label|None.
A returned label marks the route as one we care about; None ignores it.
"""
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as td:
routes = pl.read_csv(
_extract_member(path, "routes.txt", td), infer_schema_length=0
)
route_label: dict[str, str] = {}
for r in routes.iter_rows(named=True):
label = route_predicate(
(
str(r.get("agency_id", "")),
str(r.get("route_type", "")),
str(r.get("route_short_name", "")),
str(r.get("route_long_name", "")),
)
)
if label is not None:
route_label[str(r["route_id"])] = label
if not route_label:
return set()
trips = pl.read_csv(
_extract_member(path, "trips.txt", td), infer_schema_length=0
).select(
pl.col("route_id").cast(pl.Utf8),
pl.col("service_id").cast(pl.Utf8),
)
present: set[str] = set()
for row in trips.iter_rows():
route_id, service_id = str(row[0]), str(row[1])
label = route_label.get(route_id)
if label is not None and service_id in active_services:
present.add(label)
return present
def validate_london_coverage(
bods_path: Path, nr_path: Path, *, today: dt.date | None = None
) -> None:
"""Fail if any must-have London line/mode lacks active service in the window.
A silent regression that drops the Underground, DLR, Tramlink, the Elizabeth
line or the Overground (as the retired TfL TransXChange feed did) would leave
the map quietly under-serving huge swaths of journeys. This turns that into a
hard build failure. See LONDON_UNDERGROUND_LINES.
"""
if today is None:
today = dt.date.today()
window_start = int(today.strftime("%Y%m%d"))
window_end = int(
(today + dt.timedelta(days=GTFS_CALENDAR_LOOKAHEAD_DAYS)).strftime("%Y%m%d")
)
print("Validating London mode/line coverage...")
bods_active = _active_service_ids(bods_path, window_start, window_end)
nr_active = _active_service_ids(nr_path, window_start, window_end)
# BODS underground lines: agency 'London Underground (TfL)', route_type=1.
def lu_pred(row):
agency_id, route_type, short, _long = row
return (
short if route_type == "1" and short in LONDON_UNDERGROUND_LINES else None
)
# DLR: metro/light-rail named DLR (agency 'London Docklands Light Railway').
def dlr_pred(row):
_agency_id, _route_type, short, long = row
text = f"{short} {long}".lower()
return "DLR" if ("dlr" in text or "docklands light" in text) else None
# London Tramlink tram service.
def tramlink_pred(row):
_agency_id, route_type, short, long = row
text = f"{short} {long}".lower()
return "Tramlink" if (route_type == "0" and "tram" in text) else None
lu_present = _lines_with_active_service(
bods_path, bods_active, route_predicate=lu_pred
)
dlr_present = _lines_with_active_service(
bods_path, bods_active, route_predicate=dlr_pred
)
tramlink_present = _lines_with_active_service(
bods_path, bods_active, route_predicate=tramlink_pred
)
# National Rail: Elizabeth line (agency_id XR), Overground (agency_id LO).
def nr_agency_pred(target_id, label):
def pred(row):
agency_id, _route_type, _short, _long = row
return label if agency_id == target_id else None
return pred
elizabeth_present = _lines_with_active_service(
nr_path, nr_active, route_predicate=nr_agency_pred("XR", "Elizabeth line")
)
overground_present = _lines_with_active_service(
nr_path, nr_active, route_predicate=nr_agency_pred("LO", "London Overground")
)
problems: list[str] = []
missing_lu = [line for line in LONDON_UNDERGROUND_LINES if line not in lu_present]
if missing_lu:
problems.append(
"London Underground lines missing/without active service: "
+ ", ".join(missing_lu)
)
if not dlr_present:
problems.append("DLR missing or without active service (BODS)")
if not tramlink_present:
problems.append("London Tramlink missing or without active service (BODS)")
if not elizabeth_present:
problems.append(
"Elizabeth line missing or without active service (National Rail, XR)"
)
if not overground_present:
problems.append(
"London Overground missing or without active service (National Rail, LO)"
)
if problems:
raise RuntimeError(
"London coverage validation failed (window "
f"{window_start}-{window_end}):\n " + "\n ".join(problems)
)
print(
f" OK: {len(lu_present)}/{len(LONDON_UNDERGROUND_LINES)} Underground lines, "
"DLR, Tramlink, Elizabeth line and Overground all present with active service"
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Download and prepare transit network data for R5 routing engine"
@ -1167,6 +1635,7 @@ def main() -> None:
bods_final = output_dir / "bods_gtfs.zip"
convert_high_freq_to_frequency_based(bods_cleaned, bods_final)
validate_gtfs_feed(bods_final, "BODS GTFS")
validate_stop_geometry(bods_final, "BODS GTFS")
# 2. National Rail CIF → GTFS. Heavy rail is mandatory: trains are how people
# reach the ~2,725 railway-station destinations, so a bus/metro-only network
@ -1183,6 +1652,11 @@ def main() -> None:
)
nr_final = convert_national_rail_to_gtfs(raw_dir, output_dir)
validate_gtfs_feed(nr_final, "National Rail GTFS")
validate_stop_geometry(nr_final, "National Rail GTFS")
# 3. Cross-feed check: every must-have London mode/line is present with
# active service. Catches a feed regression that silently drops a whole mode.
validate_london_coverage(bods_final, nr_final)
# Summary
print()

View file

@ -1018,6 +1018,14 @@ _LISTING_OVERLAY_SOURCES: tuple[tuple[str, str, pl.DataType], ...] = (
("Listing date", "_actual_listing_date", pl.Datetime("us")),
("Listing status", "_actual_listing_status", pl.Utf8),
("Listing features", "_actual_listing_features", pl.List(pl.Utf8)),
# Accrued asking-price history (oldest -> newest) from the scraper's
# forward-only store (finder/price_history.py). Carried through untouched and
# surfaced verbatim in `_finalize_listings`.
(
"price_history",
"_actual_price_history",
pl.List(pl.Struct({"date": pl.Utf8, "price": pl.Int64, "reason": pl.Utf8})),
),
("Bedrooms", "_actual_bedrooms", pl.Int32),
("Bathrooms", "_actual_bathrooms", pl.Int32),
("Price qualifier", "_actual_price_qualifier", pl.Utf8),
@ -1521,7 +1529,14 @@ def _load_listings_for_merge(listings_path: Path, arcgis_path: Path) -> pl.DataF
# treats NaN as distinct from null and the downstream `latest_price /
# total_floor_area` cast to Int32 explodes on a NaN, so we normalise floats
# to null at load time.
raw_columns = set(raw.collect_schema().names())
def _overlay_expr(src: str, dst: str, dtype: pl.DataType) -> pl.Expr:
# A listings parquet written before a column existed (e.g. price_history)
# must not crash the merge: substitute a typed-null overlay so the rest of
# the pipeline sees a well-typed, empty column instead of ColumnNotFound.
if src not in raw_columns:
return pl.lit(None, dtype=dtype).alias(dst)
expr = pl.col(src).cast(dtype, strict=False)
if dtype in (pl.Float32, pl.Float64):
expr = expr.fill_nan(None)
@ -2196,6 +2211,7 @@ def _finalize_listings(df: pl.DataFrame) -> pl.DataFrame:
pl.col("_actual_listing_date").alias("Listing date"),
pl.col("_actual_listing_status").alias("Listing status"),
pl.col("_actual_listing_features").alias("Listing features"),
pl.col("_actual_price_history").alias("price_history"),
pl.col("_actual_asking_price").alias("Asking price"),
pl.col("_actual_asking_price_per_sqm").alias("Asking price per sqm"),
pl.col("_actual_bedrooms").alias("Bedrooms"),

View file

@ -56,6 +56,22 @@ DYNAMIC_FILTER_ALL_GROUPS = {"Public Transport", "Leisure", "Health"}
DYNAMIC_FILTER_COUNT_THRESHOLD_GROUPS = {"Groceries"}
DYNAMIC_FILTER_EXCLUDED_CATEGORIES = {"Park"}
# Combined "nearest of any rail mode" distance: the distance to the closest of a
# Rail station, Tube station, DLR station, or Tram & Metro stop. Materialized as
# its own real column so the server loads it like any other per-category
# distance. min_distance_per_postcode already supports multi-category groups, so
# this is a genuine nearest-of-the-union query (identical to the per-mode
# minimum). Distance only: a combined "within Xkm" count is not meaningful, so
# it is intentionally omitted from the count groups.
COMBINED_STATION_GROUP_KEY = "poi_any_station"
COMBINED_STATION_DISPLAY_NAME = "Any station"
COMBINED_STATION_SOURCE_CATEGORIES = [
"Rail station",
"Tube station",
"DLR station",
"Tram & Metro stop",
]
def _poi_category_slug(category: str) -> str:
ascii_text = (
@ -227,10 +243,21 @@ def main():
dynamic_counts_5km = count_pois_per_postcode(
postcodes, pois, groups=poi_category_groups, radius_km=5
)
# Distances additionally include the combined "Any station" group (nearest of
# any rail mode). It is distance-only, so it is added here but not to the
# 2km/5km count groups above.
distance_groups = {
**poi_category_groups,
COMBINED_STATION_GROUP_KEY: COMBINED_STATION_SOURCE_CATEGORIES,
}
distance_display_names = {
**poi_display_names,
COMBINED_STATION_GROUP_KEY: COMBINED_STATION_DISPLAY_NAME,
}
dynamic_distances = min_distance_per_postcode(
postcodes, pois, groups=poi_category_groups
postcodes, pois, groups=distance_groups
)
dynamic_renames = _dynamic_poi_metric_renames(poi_display_names)
dynamic_renames = _dynamic_poi_metric_renames(distance_display_names)
dynamic_counts_2km = dynamic_counts_2km.rename(
{k: v for k, v in dynamic_renames.items() if k in dynamic_counts_2km.columns}
)

View file

@ -1,6 +1,9 @@
import polars as pl
from pipeline.transform.poi_proximity import (
COMBINED_STATION_DISPLAY_NAME,
COMBINED_STATION_GROUP_KEY,
COMBINED_STATION_SOURCE_CATEGORIES,
GREENSPACE_PARK_FUNCTIONS,
POI_GROUPS_2KM,
_build_poi_category_groups,
@ -8,7 +11,7 @@ from pipeline.transform.poi_proximity import (
_greenspace_count_frame,
_groceries_categories,
)
from pipeline.utils.poi_counts import count_pois_per_postcode
from pipeline.utils.poi_counts import count_pois_per_postcode, min_distance_per_postcode
def test_groceries_2km_counts_geolytix_brand_categories() -> None:
@ -84,6 +87,42 @@ def test_dynamic_poi_groups_include_requested_categories_only() -> None:
assert "poi_school" not in groups
def test_combined_station_distance_is_nearest_of_any_rail_mode() -> None:
"""The combined "Any station" distance is the nearest of Rail/Tube/DLR/Tram,
materialized as its own column so the server loads it directly (no runtime
synthesis). A far rail station and a near DLR stop: the combined distance
must follow the DLR stop."""
postcodes = pl.DataFrame(
{"postcode": ["E14 5AB"], "lat": [51.5054], "lon": [-0.0235]}
)
pois = pl.DataFrame(
{
"category": ["Rail station", "DLR station"],
"group": ["Public Transport", "Public Transport"],
"lat": [51.6000, 51.5055],
"lng": [-0.2000, -0.0236],
}
)
distance_groups = {
"poi_rail_station": ["Rail station"],
COMBINED_STATION_GROUP_KEY: COMBINED_STATION_SOURCE_CATEGORIES,
}
result = min_distance_per_postcode(postcodes, pois, groups=distance_groups)
renames = _dynamic_poi_metric_renames(
{COMBINED_STATION_GROUP_KEY: COMBINED_STATION_DISPLAY_NAME}
)
result = result.rename({k: v for k, v in renames.items() if k in result.columns})
combined = result["Distance to nearest amenity (Any station) (km)"][0]
rail_only = result["poi_rail_station_nearest_km"][0]
# The near DLR stop is within a few hundred metres; the only rail station is
# kilometres away. The combined distance must follow the DLR stop.
assert combined < 0.2
assert rail_only > 5.0
assert combined < rail_only
def test_dynamic_poi_metric_renames_support_park_count_options() -> None:
assert _dynamic_poi_metric_renames({"parks": "Park"}) == {
"parks_nearest_km": "Distance to nearest amenity (Park) (km)",

View file

@ -14,6 +14,7 @@ import com.conveyal.r5.api.util.LegMode;
import com.conveyal.r5.api.util.TransitModes;
import com.conveyal.r5.kryo.KryoNetworkSerializer;
import com.conveyal.r5.profile.StreetMode;
import com.conveyal.gtfs.model.Stop;
import com.conveyal.r5.transit.TransitLayer;
import com.conveyal.r5.transit.TransportNetwork;
import com.conveyal.r5.transit.path.RouteSequence;
@ -33,6 +34,19 @@ import java.util.List;
/** R5 routing: network loading, spatial filtering, travel time computation. */
public class Router {
// Coarse GB bounding box used to tell "a stop that should have linked to a
// street but didn't" (inside GB) from stops that legitimately never link
// (continental coach termini, neutralised out-of-area stops).
private static final double UK_MIN_LAT = 49.0;
private static final double UK_MAX_LAT = 61.0;
private static final double UK_MIN_LON = -9.0;
private static final double UK_MAX_LON = 2.5;
// If more than this fraction of transit stops fail to link to the street
// network the build is systemically broken (wrong/incomplete OSM extract, or
// corrupt coordinates) rather than the usual tiny residue of unroutable
// stops, so fail loudly instead of silently shipping a half-linked network.
private static final double MAX_UNLINKED_STOP_FRACTION = 0.10;
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
// 30-minute peak window: RAPTOR cost is linear in (toTime-fromTime)/60.
@ -168,6 +182,85 @@ public class Router {
System.err.printf(" Transit: %,d stops, %,d routes, %,d patterns, %,d services%n",
stops, routes, patterns, services);
validateStopLinkage(transitLayer);
}
/**
* Verify transit stops are connected to the walkable street network. A stop
* whose coordinate lands nowhere near a street gets streetVertexForStop == -1
* (R5's "unlinked" sentinel); it is then silently unroutable no access,
* egress, or distance table so trains pass through but nobody can board or
* alight (the Tottenham Court Road failure mode, upstream of the coordinate
* fix). A handful of stops legitimately never link (continental coach termini,
* neutralised out-of-area stops); a large fraction signals a systemic break
* (wrong OSM extract, or a whole mode's coordinates corrupt) and fails.
*
* streetVertexForStop is persisted in the network cache, so unlinked stops are
* counted on both fresh and cached loads. Per-stop coordinates come from the
* transient stopForIndex, which is only populated on a fresh fromDirectory
* build; on a cached load coordinates are unavailable and only counts are logged.
*/
private static void validateStopLinkage(TransitLayer transitLayer) {
int n = transitLayer.getStopCount();
// stopForIndex is transient: on a cached (Kryo) load it comes back empty
// (size 0), not null, so require it to be fully populated (fresh build)
// before indexing into it; otherwise fall back to counts-only.
boolean haveCoords = transitLayer.stopForIndex != null
&& transitLayer.stopForIndex.size() == n;
int unlinked = 0;
int unlinkedInGb = 0;
List<String> sample = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (transitLayer.streetVertexForStop.get(i) != -1) {
continue; // linked
}
unlinked++;
if (!haveCoords) {
continue;
}
Stop stop = transitLayer.stopForIndex.get(i);
if (stop == null) {
continue;
}
double lat = stop.stop_lat;
double lon = stop.stop_lon;
boolean inGb = lat >= UK_MIN_LAT && lat <= UK_MAX_LAT
&& lon >= UK_MIN_LON && lon <= UK_MAX_LON;
if (inGb) {
unlinkedInGb++;
if (sample.size() < 25) {
String id = transitLayer.stopIdForIndex.get(i);
String name = transitLayer.stopNames != null && i < transitLayer.stopNames.size()
? transitLayer.stopNames.get(i) : "";
sample.add(String.format("%s '%s' (%.5f, %.5f)", id, name, lat, lon));
}
}
}
double fraction = n == 0 ? 0.0 : (double) unlinked / n;
if (haveCoords) {
System.err.printf(
" Stop linkage: %,d/%,d stops NOT linked to street network (%.2f%%), %,d inside GB%n",
unlinked, n, fraction * 100.0, unlinkedInGb);
for (String s : sample) {
System.err.println(" unlinked (GB): " + s);
}
} else {
System.err.printf(
" Stop linkage: %,d/%,d stops NOT linked to street network (%.2f%%) "
+ "(coords unavailable on cached load)%n",
unlinked, n, fraction * 100.0);
}
if (fraction > MAX_UNLINKED_STOP_FRACTION) {
throw new IllegalStateException(String.format(
"R5 linked only %.1f%% of transit stops to the street network "
+ "(%,d of %,d unlinked). This indicates a systemic linking failure "
+ "(wrong/incomplete OSM extract, or corrupt stop coordinates); "
+ "unlinked stops are silently unroutable.",
(1.0 - fraction) * 100.0, unlinked, n));
}
}
static void validateTransitServices(TransportNetwork network, LocalDate date) {

63
scripts/release.sh Executable file
View file

@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
VERSION_FILE="VERSION"
BRANCH="main"
usage() {
echo "Usage: $0 patch|minor|major" >&2
exit 1
}
BUMP="${1:-}"
case "$BUMP" in
patch | minor | major) ;;
*) usage ;;
esac
if [[ "$(git rev-parse --abbrev-ref HEAD)" != "$BRANCH" ]]; then
echo "Error: releases must be cut from $BRANCH" >&2
exit 1
fi
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working tree is not clean; commit or stash first" >&2
exit 1
fi
git fetch origin "$BRANCH"
if ! git merge-base --is-ancestor "origin/$BRANCH" HEAD; then
echo "Error: $BRANCH is behind origin/$BRANCH; pull first" >&2
exit 1
fi
CURRENT="$(<"$VERSION_FILE")"
if [[ ! "$CURRENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: $VERSION_FILE contains '$CURRENT', expected X.Y.Z" >&2
exit 1
fi
IFS=. read -r MAJOR MINOR PATCH <<<"$CURRENT"
case "$BUMP" in
major) NEW="$((MAJOR + 1)).0.0" ;;
minor) NEW="$MAJOR.$((MINOR + 1)).0" ;;
patch) NEW="$MAJOR.$MINOR.$((PATCH + 1))" ;;
esac
# docker-publish.yml only runs tag builds for v* refs, so the tag needs the prefix.
TAG="v$NEW"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Error: tag $TAG already exists" >&2
exit 1
fi
printf '%s\n' "$NEW" >"$VERSION_FILE"
git add "$VERSION_FILE"
git commit -m "Release $NEW"
git tag -a "$TAG" -m "Release $NEW"
git push origin "$BRANCH" "$TAG"
echo "Released $CURRENT -> $NEW (tag $TAG)"

49
scripts/scrape-loop.sh Executable file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Every 12 hours: scrape rightmove + onthemarket via the finder container,
# then enrich the actual listings. No step is fatal; failures are logged
# loudly and the loop carries on.
#
# Run it in the background with:
# nohup scripts/scrape-loop.sh >> logs/scrape-loop.log 2>&1 &
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
INTERVAL_SECONDS=$((12 * 3600))
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}
fail_loudly() {
log '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'
log "!!! STEP FAILED: $*"
log '!!! continuing anyway'
log '!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'
}
run_step() {
local desc="$1"
shift
log "step: $desc"
if "$@"; then
log "done: $desc"
else
fail_loudly "$desc (exit code $?)"
fi
}
while true; do
log '=== starting scrape + enrich cycle ==='
cd "$REPO_DIR/finder" || fail_loudly "cd $REPO_DIR/finder"
run_step 'docker compose up' docker compose up -d
run_step 'finder scrape (rightmove, onthemarket)' \
docker compose exec -T finder uv run python main.py --source rightmove,onthemarket
cd "$REPO_DIR" || fail_loudly "cd $REPO_DIR"
run_step 'enrich actual listings' make -f Makefile.data enrich-actual-listings
log "=== cycle finished, sleeping 12h ==="
sleep "$INTERVAL_SECONDS"
done

View file

@ -22,6 +22,10 @@ pub const PLACES_LIMIT: usize = 20;
/// feature name in `features.rs`.
pub const FORMER_COUNCIL_HOUSE_FEATURE: &str = "Former council house";
pub const PRICE_HISTORY_POINTS_LIMIT: usize = 5000;
/// Downsample cap for the wider-area (sector / outcode) price-history charts.
/// Lower than the selection's own cap because these render below it: three full
/// scatters at 5000 points each would flood the DOM.
pub const AREA_PRICE_HISTORY_POINTS_LIMIT: usize = 1500;
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;

View file

@ -12,6 +12,16 @@ use crate::utils::{normalize_postcode, GridIndex, InternedColumn};
const GRID_CELL_SIZE: f32 = 0.01;
/// One observation on a listing's accruing asking-price timeline, recovered from
/// the scraper's forward-only store (finder/price_history.py). `reason` is one of
/// "listed" | "reduced" | "increased"; `date` is `YYYY-MM-DD`.
#[derive(Serialize, Clone)]
pub struct PriceHistoryPoint {
pub date: String,
pub price: i64,
pub reason: String,
}
#[derive(Serialize, Clone)]
pub struct ActualListing {
pub lat: f32,
@ -32,6 +42,10 @@ pub struct ActualListing {
pub listing_status: Option<String>,
pub listing_date_iso: Option<String>,
pub features: Vec<String>,
/// Accrued asking-price observations, oldest -> newest. Empty until the
/// scraper's forward-only store has recorded at least one run for this id.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub price_history: Vec<PriceHistoryPoint>,
}
pub struct ActualListingData {
@ -54,6 +68,8 @@ pub struct ActualListingData {
pub listing_status: InternedColumn,
pub listing_date_iso: Vec<Option<String>>,
pub features: Vec<Vec<String>>,
/// Per-row accrued asking-price series (empty when absent from the parquet).
pub price_history: Vec<Vec<PriceHistoryPoint>>,
/// Row-major feature matrix aligned with PropertyData::feature_names.
///
/// Rows start from a best-effort address/postcode join to the historical property
@ -102,6 +118,7 @@ impl ActualListingData {
let listing_status_raw = extract_opt_str(&df, "Listing status")?;
let listing_date_iso = extract_opt_datetime_iso(&df, "Listing date")?;
let features = extract_str_list(&df, "Listing features")?;
let price_history = extract_price_history(&df)?;
let postcode: Vec<String> = postcode_raw.iter().map(|s| normalize_postcode(s)).collect();
@ -146,6 +163,7 @@ impl ActualListingData {
listing_status,
listing_date_iso,
features,
price_history,
filter_feature_data,
poi_filter_feature_data,
grid,
@ -172,6 +190,7 @@ impl ActualListingData {
listing_status: opt_from_interned(&self.listing_status, row),
listing_date_iso: self.listing_date_iso[row].clone(),
features: self.features[row].clone(),
price_history: self.price_history[row].clone(),
}
}
}
@ -629,6 +648,64 @@ fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
Ok(out)
}
/// Extract `price_history: List<Struct{date: Utf8, price: Int64, reason: Utf8}>`.
///
/// Schema-gated: a parquet written before the column existed simply yields an
/// empty series per row (the column is dropped from serialized listings). A
/// null/empty list is likewise an empty series.
fn extract_price_history(df: &DataFrame) -> Result<Vec<Vec<PriceHistoryPoint>>> {
let row_count = df.height();
let Ok(column) = df.column("price_history") else {
return Ok(vec![Vec::new(); row_count]);
};
let list_ca = column
.list()
.context("price_history is not a list column")?;
let mut out: Vec<Vec<PriceHistoryPoint>> = Vec::with_capacity(row_count);
for row in 0..row_count {
let mut points = Vec::new();
if let Some(inner) = list_ca.get_as_series(row) {
if !inner.is_empty() {
let structs = inner
.struct_()
.context("price_history inner is not a struct")?;
let dates_s = structs
.field_by_name("date")
.context("Missing 'date' field in price_history struct")?;
let date_ca = dates_s.str().context("price_history.date is not a string")?;
let prices_s = structs
.field_by_name("price")
.context("Missing 'price' field in price_history struct")?;
let prices_i64 = prices_s
.cast(&DataType::Int64)
.context("price_history.price is not castable to Int64")?;
let price_ca = prices_i64.i64().context("price_history.price is not Int64")?;
let reasons_s = structs
.field_by_name("reason")
.context("Missing 'reason' field in price_history struct")?;
let reason_ca = reasons_s
.str()
.context("price_history.reason is not a string")?;
for idx in 0..inner.len() {
let (Some(date), Some(price), Some(reason)) =
(date_ca.get(idx), price_ca.get(idx), reason_ca.get(idx))
else {
continue;
};
points.push(PriceHistoryPoint {
date: date.to_string(),
price,
reason: reason.to_string(),
});
}
}
}
out.push(points);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@ -663,4 +740,30 @@ mod tests {
assert!(any_listing.lon.is_finite());
assert!(!any_listing.listing_url.is_empty());
}
#[test]
fn extracts_price_history_from_parquet() {
let path = PathBuf::from("src/data/testdata/listings_price_history.parquet");
if !path.exists() {
eprintln!("price_history fixture not present; skipping");
return;
}
let data = ActualListingData::load_inner(&path, None).expect("listings load");
assert_eq!(data.price_history.len(), data.lat.len());
// Row 0: two points, listed -> reduced, oldest first.
let r0 = data.listing_at(0);
assert_eq!(r0.price_history.len(), 2);
assert_eq!(r0.price_history[0].date, "2026-07-01");
assert_eq!(r0.price_history[0].price, 500_000);
assert_eq!(r0.price_history[0].reason, "listed");
assert_eq!(r0.price_history[1].price, 480_000);
assert_eq!(r0.price_history[1].reason, "reduced");
// Row 1: a single "listed" point.
assert_eq!(data.listing_at(1).price_history.len(), 1);
// Row 2 (empty list) and row 3 (null) carry no points.
assert!(data.listing_at(2).price_history.is_empty());
assert!(data.listing_at(3).price_history.is_empty());
}
}

View file

@ -903,6 +903,40 @@ impl PropertyData {
for rows in postcode_row_index.values_mut() {
rows.shrink_to_fit();
}
// Group postcode keys by sector ("E14 2") and outward code ("E14") so the
// wider-area price-history charts can enumerate a sector's / outcode's
// properties. Storing keys (one per postcode) rather than rows keeps this
// an order of magnitude smaller than the row index.
let mut sector_postcode_index: FxHashMap<String, Vec<lasso::Spur>> = FxHashMap::default();
let mut outcode_postcode_index: FxHashMap<String, Vec<lasso::Spur>> = FxHashMap::default();
for &key in postcode_row_index.keys() {
let postcode = postcode_rodeo.resolve(&key);
if let Some(sector) = crate::utils::postcode_sector(postcode) {
sector_postcode_index
.entry(sector.to_string())
.or_default()
.push(key);
}
if let Some(outcode) = crate::utils::postcode_outcode(postcode) {
outcode_postcode_index
.entry(outcode.to_string())
.or_default()
.push(key);
}
}
for keys in sector_postcode_index.values_mut() {
keys.shrink_to_fit();
}
for keys in outcode_postcode_index.values_mut() {
keys.shrink_to_fit();
}
tracing::info!(
sectors = sector_postcode_index.len(),
outcodes = outcode_postcode_index.len(),
"Sector/outcode postcode indexes built"
);
let postcode_interner = postcode_rodeo.into_reader();
let row_to_poi_metric_idx: Vec<u32> = if poi_metrics.is_empty() {
@ -1122,6 +1156,8 @@ impl PropertyData {
postcode_interner,
postcode_keys,
postcode_row_index,
sector_postcode_index,
outcode_postcode_index,
address_token_index,
address_prefix_index,
address_search_interner,

View file

@ -90,6 +90,14 @@ pub struct PropertyData {
postcode_keys: SpillVec<lasso::Spur>,
/// Rows for each postcode, keyed by the interned postcode key.
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
/// Postcode keys for each postcode sector (e.g. "E14 2"). Stores interned
/// keys, not rows, so the wider-area price-history charts can enumerate a
/// sector's properties (via `postcode_row_index`) for ~1/13th the memory of
/// duplicating the row ids.
sector_postcode_index: FxHashMap<String, Vec<lasso::Spur>>,
/// Postcode keys for each outward code (e.g. "E14"). Same rationale as
/// `sector_postcode_index`.
outcode_postcode_index: FxHashMap<String, Vec<lasso::Spur>>,
/// Inverted index from address tokens to property rows.
address_token_index: FxHashMap<String, Vec<u32>>,
/// Prefix lookup from typed address-token prefix to indexed full address tokens.
@ -156,6 +164,31 @@ impl PropertyData {
.unwrap_or(&[])
}
/// All property rows in a postcode sector (e.g. "E14 2"), gathered across the
/// sector's postcodes. Empty if the sector is unknown. Materializes a new Vec
/// since the rows live in per-postcode buckets; callers downsample it.
pub fn rows_for_sector(&self, sector: &str) -> Vec<u32> {
self.rows_for_area(self.sector_postcode_index.get(sector))
}
/// All property rows in an outward code (e.g. "E14"). See `rows_for_sector`.
pub fn rows_for_outcode(&self, outcode: &str) -> Vec<u32> {
self.rows_for_area(self.outcode_postcode_index.get(outcode))
}
fn rows_for_area(&self, postcode_keys: Option<&Vec<lasso::Spur>>) -> Vec<u32> {
let Some(keys) = postcode_keys else {
return Vec::new();
};
let mut rows = Vec::new();
for key in keys {
if let Some(bucket) = self.postcode_row_index.get(key) {
rows.extend_from_slice(bucket);
}
}
rows
}
/// Get the is_approx_build_date flag for a given row (bit-packed).
pub fn is_approx_build_date(&self, row: usize) -> bool {
let byte = self.approx_build_date_bits[row / 8];
@ -253,6 +286,8 @@ impl PropertyData {
postcode_interner: lasso::Rodeo::default().into_reader(),
postcode_keys: SpillVec::owned(Vec::new()),
postcode_row_index: FxHashMap::default(),
sector_postcode_index: FxHashMap::default(),
outcode_postcode_index: FxHashMap::default(),
address_token_index: FxHashMap::default(),
address_prefix_index: FxHashMap::default(),
address_search_interner: lasso::Rodeo::default().into_resolver(),

Binary file not shown.

View file

@ -685,6 +685,14 @@ async fn main() -> anyhow::Result<()> {
&cli.google_oauth_client_secret,
)
.await?;
pocketbase::ensure_password_reset_template(
&http_client,
&cli.pocketbase_url,
&cli.pocketbase_admin_email,
&cli.pocketbase_admin_password,
&cli.public_url,
)
.await?;
info!("Gemini configured (model: {})", cli.gemini_model);
let tt_path = &cli.travel_times;
if !tt_path.exists() {

View file

@ -171,6 +171,15 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
indexable: false,
}),
// The password-reset email links here (see pocketbase::ensure_password_reset_template).
// Without this entry should_return_404 would 404 the SPA route and the emailed link
// would dead-end, which is the whole bug this page exists to fix.
"/reset-password" => Some(SeoPage {
canonical_path: "/reset-password",
title: "Reset your password | Perfect Postcode",
description: "Choose a new password for your Perfect Postcode account.",
indexable: false,
}),
_ if path.starts_with("/invite/") => Some(SeoPage {
canonical_path: "/invite",
title: "You're invited to Perfect Postcode",
@ -508,6 +517,14 @@ mod tests {
assert!(should_return_404("/definitely-not-a-real-page"));
}
#[test]
fn reset_password_page_resolves_and_is_not_404() {
// The PocketBase reset email links to this SPA route (the middleware sees only the
// path, not the ?token= query), so it must render rather than 404.
assert!(seo_page_for_path("/reset-password").is_some());
assert!(!should_return_404("/reset-password"));
}
#[test]
fn indexable_pages_emit_hreflang() {
let page = seo_page_for_path("/terms").expect("terms page");

View file

@ -1445,6 +1445,87 @@ pub async fn ensure_oauth_providers(
Ok(())
}
/// Point the password-reset email at the SPA's own `/reset-password` page.
///
/// PocketBase's default reset template links to `{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}`,
/// i.e. its superuser UI under `/pb/_/`. `meta.appURL` is `{public_url}/pb` (set in
/// `ensure_oauth_providers` for OAuth redirects), and the `/pb` proxy allowlist deliberately
/// rejects `/_/` (see `routes/pb_proxy.rs`), so the emailed link 404s. We therefore bake the raw
/// `public_url` (NOT `appURL`, which carries the `/pb` prefix) straight into the template body and
/// keep PocketBase's `{TOKEN}` placeholder for it to substitute at send time. The SPA route reads
/// `?token=` and calls `confirmPasswordReset`.
pub async fn ensure_password_reset_template(
client: &Client,
base_url: &str,
admin_email: &str,
admin_password: &str,
public_url: &str,
) -> anyhow::Result<()> {
let base_url = base_url.trim_end_matches('/');
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
let site = public_url.trim_end_matches('/');
// `{{TOKEN}}` renders to the literal `{TOKEN}` placeholder that PocketBase fills in.
let action_url = format!("{site}/reset-password?token={{TOKEN}}");
let body = format!(
"<p>Hello,</p>\n\
<p>Click the button below to choose a new password for your Perfect Postcode account.</p>\n\
<p><a class=\"btn\" href=\"{action_url}\" target=\"_blank\" rel=\"noopener\">Reset password</a></p>\n\
<p>If you did not request this, you can safely ignore this email.</p>\n\
<p>Thanks,<br/>The Perfect Postcode team</p>"
);
// PocketBase 0.23+: email templates are per-collection. GET the users collection, set the
// reset template, and PATCH it back (mirrors the OAuth config flow above).
let collection_url = format!("{base_url}/api/collections/users");
let resp = client
.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 users collection for reset template ({status}): {text}");
}
let mut collection: serde_json::Value = resp.json().await?;
// The field is an object `{subject, body}`; preserve any sibling keys PocketBase may add. If a
// future PocketBase version renames or moves it, warn and skip rather than blocking server
// startup over an email template: a broken reset link is far less bad than a server that won't boot.
let Some(template) = collection
.get_mut("resetPasswordTemplate")
.and_then(|v| v.as_object_mut())
else {
warn!(
"users collection has no resetPasswordTemplate object; \
leaving the reset email on PocketBase's default (which 404s via /pb/_/)"
);
return Ok(());
};
template.insert(
"subject".to_string(),
serde_json::json!("Reset your Perfect Postcode password"),
);
template.insert("body".to_string(), serde_json::json!(body));
let reset_template = collection["resetPasswordTemplate"].clone();
let patch_resp = client
.patch(&collection_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "resetPasswordTemplate": reset_template }))
.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 set password-reset template ({status}): {text}");
}
info!("PocketBase password-reset email template set to {action_url}");
Ok(())
}
/// Spawn a background task that polls PocketBase every 60 seconds for collection counts
/// and exposes them as Prometheus gauges.
pub fn start_metrics_poller(shared: Arc<crate::state::SharedState>) {

View file

@ -20,6 +20,10 @@ const FILTER_GROUP_ORDER: &[&str] = &[
const LAST_FILTER_GROUPS: &[&str] = &["Area development"];
const POI_DISTANCE_SLIDER_MIN_KM: f32 = 0.0;
const POI_DISTANCE_SLIDER_MAX_KM: f32 = 5.0;
/// Category name of the pipeline-derived "nearest of any rail mode" distance.
/// Matches `COMBINED_STATION_DISPLAY_NAME` in `pipeline/transform/poi_proximity.py`;
/// used only to give the column friendlier filter copy.
const COMBINED_STATION_CATEGORY: &str = "Any station";
fn is_empty(val: &str) -> bool {
val.is_empty()
@ -169,6 +173,7 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
if let Some(category) = features::dynamic_poi_distance_category(name) {
let stats = &data.poi_metrics.feature_stats[feat_idx];
let is_park = category.eq_ignore_ascii_case("park");
let is_any_station = category == COMBINED_STATION_CATEGORY;
dynamic_poi_features.push(FeatureInfo::Numeric {
name: name.clone(),
min: POI_DISTANCE_SLIDER_MIN_KM,
@ -177,11 +182,15 @@ pub fn build_features_response(data: &PropertyData) -> FeaturesResponse {
histogram: stats.histogram.clone(),
description: if is_park {
"Distance to the closest park or green space".to_string()
} else if is_any_station {
"Distance to the closest rail, tube, tram, or DLR station".to_string()
} else {
format!("Distance to the closest {category} amenity")
},
detail: if is_park {
"Straight-line distance in kilometres from the postcode to the nearest park entrance. Covers public parks, gardens, playing fields, and play spaces. Uses access point locations from the OS Open Greenspace dataset, so properties bordering a large park correctly show a short distance.".to_string()
} else if is_any_station {
"Straight-line distance in kilometres from the postcode to the nearest station of any rail mode: National Rail, London Underground, DLR, or tram/metro.".to_string()
} else {
format!(
"Straight-line distance in kilometres from the postcode to the nearest {category} amenity in the amenities dataset."

View file

@ -61,6 +61,11 @@ pub struct EnumFeatureStats {
pub struct PricePoint {
pub year: f32,
pub price: f32,
/// Sale price divided by the property's EPC floor area (the "Price per sqm"
/// feature). Absent where no floor area is recorded, so the per-m² view drops
/// those points.
#[serde(skip_serializing_if = "Option::is_none")]
pub price_per_sqm: Option<f32>,
}
#[derive(Serialize)]
@ -143,6 +148,15 @@ pub struct HexagonStatsResponse {
pub enum_features: Vec<EnumFeatureStats>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub price_history: Vec<PricePoint>,
/// Price history for every sale in the selection's postcode sector (e.g.
/// "E14 2"), independent of the active filters: a wider-area reference for
/// the selection's own chart.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sector_price_history: Vec<PricePoint>,
/// Price history for every sale in the selection's outward code (e.g. "E14"),
/// independent of the active filters.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub outcode_price_history: Vec<PricePoint>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub crime_by_year: Vec<CrimeYearStats>,
/// Latest year in the crime dataset as a whole. When a selection's series
@ -642,6 +656,12 @@ pub async fn get_hexagon_stats(
let price_history =
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
// Sector/outcode price histories are a postcode-selection feature only: a
// hexagon straddles arbitrary postcodes, so its central postcode's
// sector/outcode is not a meaningful aggregation unit. The frontend hides
// them for hexagons, so skip the (potentially large) outcode scan here.
let sector_price_history: Vec<PricePoint> = Vec::new();
let outcode_price_history: Vec<PricePoint> = Vec::new();
let crime_by_year = stats::compute_crime_by_year(
&matching_rows,
@ -733,6 +753,8 @@ pub async fn get_hexagon_stats(
numeric_features,
enum_features: enum_features_out,
price_history,
sector_price_history,
outcode_price_history,
crime_by_year,
crime_latest_year,
crime_outcode,

Some files were not shown because too many files have changed in this diff Show more