Compare commits
9 commits
badab57438
...
cf348c3ea4
| Author | SHA1 | Date | |
|---|---|---|---|
| cf348c3ea4 | |||
| f14981afee | |||
| 61114833b7 | |||
| f4d1b58bd8 | |||
| d7f844d566 | |||
| ca771a7edf | |||
| 9e4e65fa2a | |||
| 6df2812a4e | |||
| f948efc06c |
105 changed files with 3261 additions and 435 deletions
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.0.2
|
||||
8
check.sh
8
check.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
120
finder/price_history.py
Normal 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})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
77
finder/test_price_history.py
Normal file
77
finder/test_price_history.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
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)
BIN
frontend/public/video/ad-01-say-it.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-02-twenty-minute-map.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-02-twenty-minute-map.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-02-twenty-minute-map.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-02-twenty-minute-map.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-03-postcode-files.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-03-postcode-files.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-03-postcode-files.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-03-postcode-files.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-04-quiet-streets.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-04-quiet-streets.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-04-quiet-streets.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-04-quiet-streets.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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)
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)
BIN
frontend/public/video/ad-05-school-run.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-06-waitrose-test.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-06-waitrose-test.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-06-waitrose-test.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-06-waitrose-test.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-07-renters-map.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-07-renters-map.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-07-renters-map.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-07-renters-map.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
53
frontend/src/components/account/ResetPasswordPage.test.tsx
Normal file
53
frontend/src/components/account/ResetPasswordPage.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
137
frontend/src/components/account/ResetPasswordPage.tsx
Normal file
137
frontend/src/components/account/ResetPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
{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>
|
||||
) : null;
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})()}
|
||||
{displayFeatureGroups.map((group) => {
|
||||
const showNearbyStations =
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
97
frontend/src/components/map/ListingPopups.test.tsx
Normal file
97
frontend/src/components/map/ListingPopups.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
63
frontend/src/components/ui/AuthModal.test.tsx
Normal file
63
frontend/src/components/ui/AuthModal.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
<div className="relative">
|
||||
<input
|
||||
id={passwordInputId}
|
||||
name="password"
|
||||
type="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 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"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
frontend/src/components/ui/icons/EyeOffIcon.tsx
Normal file
22
frontend/src/components/ui/icons/EyeOffIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 l’inscription',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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: 'पब',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '酒吧',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
22
frontend/src/lib/postcode.ts
Normal file
22
frontend/src/lib/postcode.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 A–B–C (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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
63
scripts/release.sh
Executable 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
49
scripts/scrape-loop.sh
Executable 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
BIN
server-rs/src/data/testdata/listings_price_history.parquet
vendored
Normal file
BIN
server-rs/src/data/testdata/listings_price_history.parquet
vendored
Normal file
Binary file not shown.
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue