diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 5b13a93..4e2827c 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 00b8193..3fc8f5e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,5 @@ video/auth.* r5-java/tmp property-data property-data-snapshot -property-data-snapshot2 +property-data-snapshot* video/.audit* diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..4e379d2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.2 diff --git a/check.sh b/check.sh index 5ebe2b9..45c81a3 100755 --- a/check.sh +++ b/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 diff --git a/docker-compose.yml b/docker-compose.yml index a0bcbe4..4f90327 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/finder/price_history.py b/finder/price_history.py new file mode 100644 index 0000000..4b0683a --- /dev/null +++ b/finder/price_history.py @@ -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 " 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: + + {"": [{"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 "); 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}) diff --git a/finder/scraper.py b/finder/scraper.py index 6130de8..a9c7f60 100644 --- a/finder/scraper.py +++ b/finder/scraper.py @@ -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() diff --git a/finder/storage.py b/finder/storage.py index a7b051a..c885160 100644 --- a/finder/storage.py +++ b/finder/storage.py @@ -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} + ) + ), }, ) diff --git a/finder/test_price_history.py b/finder/test_price_history.py new file mode 100644 index 0000000..7dbbd96 --- /dev/null +++ b/finder/test_price_history.py @@ -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 diff --git a/finder/test_scraper_concurrency.py b/finder/test_scraper_concurrency.py index 24df360..140ed60 100644 --- a/finder/test_scraper_concurrency.py +++ b/finder/test_scraper_concurrency.py @@ -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( diff --git a/finder/transform.py b/finder/transform.py index 2a05dd4..a0adc05 100644 --- a/finder/transform.py +++ b/finder/transform.py @@ -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, } diff --git a/frontend/public/video/ad-01-say-it.jpg b/frontend/public/video/ad-01-say-it.jpg index 25222b1..1f0342f 100644 --- a/frontend/public/video/ad-01-say-it.jpg +++ b/frontend/public/video/ad-01-say-it.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aced213df65f5898a3197ac304ef1efcacc9c27e050232e07721db00d29eef18 -size 264644 +oid sha256:6207c933479db1854f8d5f5c061463ca169eed114f73561e0e31b782a51f1f4e +size 379334 diff --git a/frontend/public/video/ad-01-say-it.mp4 b/frontend/public/video/ad-01-say-it.mp4 index 3823735..2f5a638 100644 --- a/frontend/public/video/ad-01-say-it.mp4 +++ b/frontend/public/video/ad-01-say-it.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5786a2453528d8693f2da1262ac65684209c70afb19f1c2ed620e429d911a2ab -size 16952899 +oid sha256:0b9834f15c24b055fe20358989476c8cae802c669f0c6e9ec9bd361707a587d8 +size 19984203 diff --git a/frontend/public/video/ad-01-say-it.vtt b/frontend/public/video/ad-01-say-it.vtt index 38c8e4a..2fe21f3 100644 --- a/frontend/public/video/ad-01-say-it.vtt +++ b/frontend/public/video/ad-01-say-it.vtt @@ -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. diff --git a/frontend/public/video/ad-02-twenty-minute-map.jpg b/frontend/public/video/ad-02-twenty-minute-map.jpg index d281150..80f55cf 100644 --- a/frontend/public/video/ad-02-twenty-minute-map.jpg +++ b/frontend/public/video/ad-02-twenty-minute-map.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:952fbf18c3698dd4d659b51802addef8d1790380f26e47039bd0784fc37fecdf -size 257591 +oid sha256:90bfdc9c9fe201700f297d2b5b19a5fced7807598a7401b6596fc1467fc24ca2 +size 259560 diff --git a/frontend/public/video/ad-02-twenty-minute-map.mp4 b/frontend/public/video/ad-02-twenty-minute-map.mp4 index 6f4fa5c..33c7d46 100644 --- a/frontend/public/video/ad-02-twenty-minute-map.mp4 +++ b/frontend/public/video/ad-02-twenty-minute-map.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e694a22b1470742e177d3bfcbe8990364812da6313cd0da67a6acb732dbe7321 -size 21701110 +oid sha256:89b167f57545b1aa3d3d2ffb096951ad2c4a6a39a489da7bd2ec27f8ac983fda +size 21360105 diff --git a/frontend/public/video/ad-02-twenty-minute-map.vtt b/frontend/public/video/ad-02-twenty-minute-map.vtt index 610f10c..2385118 100644 --- a/frontend/public/video/ad-02-twenty-minute-map.vtt +++ b/frontend/public/video/ad-02-twenty-minute-map.vtt @@ -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. diff --git a/frontend/public/video/ad-03-postcode-files.jpg b/frontend/public/video/ad-03-postcode-files.jpg index eda25aa..2b20f1c 100644 --- a/frontend/public/video/ad-03-postcode-files.jpg +++ b/frontend/public/video/ad-03-postcode-files.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:730aef459e371324a263a248be6db784fefb012b0e9982b4fa1ec4a7b1ba509e -size 236198 +oid sha256:5d06b39f0d2f17239ec6f233865040579f7c21e5c8aa04ffa2271018a988184b +size 220909 diff --git a/frontend/public/video/ad-03-postcode-files.mp4 b/frontend/public/video/ad-03-postcode-files.mp4 index 6726fdf..fdaea09 100644 --- a/frontend/public/video/ad-03-postcode-files.mp4 +++ b/frontend/public/video/ad-03-postcode-files.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b53e1dd6748d3c9cc9b6cf299c02fe07f1a031f370461586fc1ec21a3db10876 -size 17090775 +oid sha256:84046dd7c66352fad21f3172a2b959943f1adc291eb6319cc73c8bdca468eb3b +size 17541046 diff --git a/frontend/public/video/ad-03-postcode-files.vtt b/frontend/public/video/ad-03-postcode-files.vtt index 2b34312..1d4dcec 100644 --- a/frontend/public/video/ad-03-postcode-files.vtt +++ b/frontend/public/video/ad-03-postcode-files.vtt @@ -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. diff --git a/frontend/public/video/ad-04-quiet-streets.jpg b/frontend/public/video/ad-04-quiet-streets.jpg index ae281e8..c56c2e7 100644 --- a/frontend/public/video/ad-04-quiet-streets.jpg +++ b/frontend/public/video/ad-04-quiet-streets.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4d551f400ea58b6d91534ad0549adc01bc7f85c422ae1db5a39e06babbc3674 -size 260265 +oid sha256:f86afc805c3b2d69b7f8c4461f975df14ccd8f0c0129831c881dec2faa734edc +size 397949 diff --git a/frontend/public/video/ad-04-quiet-streets.mp4 b/frontend/public/video/ad-04-quiet-streets.mp4 index 651ca1a..63fb9b6 100644 --- a/frontend/public/video/ad-04-quiet-streets.mp4 +++ b/frontend/public/video/ad-04-quiet-streets.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b0c5ad0a710ba94666f25f5c8fe704de2319eef3a1b826fc7cfaf0667b38421 -size 16146529 +oid sha256:a448098f9fdbcf288f1e52467e208e549ba5bad585e6f68cea4cda697ce2431d +size 19070674 diff --git a/frontend/public/video/ad-04-quiet-streets.vtt b/frontend/public/video/ad-04-quiet-streets.vtt index f263c9b..deeb78b 100644 --- a/frontend/public/video/ad-04-quiet-streets.vtt +++ b/frontend/public/video/ad-04-quiet-streets.vtt @@ -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. diff --git a/frontend/public/video/ad-05-school-run.jpg b/frontend/public/video/ad-05-school-run.jpg index b51be70..415eace 100644 --- a/frontend/public/video/ad-05-school-run.jpg +++ b/frontend/public/video/ad-05-school-run.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:170f6b39128233c14454100b8f535bbc787700f1baf950a38c8d3cc88a6a71e4 -size 214073 +oid sha256:1c9c315eb6fec4211747d1e3536221db632ff27ed0f14d7a1708bec3ec529c7c +size 276673 diff --git a/frontend/public/video/ad-05-school-run.mp4 b/frontend/public/video/ad-05-school-run.mp4 index bae5768..978eedf 100644 --- a/frontend/public/video/ad-05-school-run.mp4 +++ b/frontend/public/video/ad-05-school-run.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe819309968f44f77cb37cf80c85d5b54b4b2bc67f93b9c554632ffb79bb082a -size 18251768 +oid sha256:b3e8b62b7ef53a91c16d2ef9883b75da2799b7c24d00566c5c01e4e97e6574f9 +size 21658410 diff --git a/frontend/public/video/ad-05-school-run.vtt b/frontend/public/video/ad-05-school-run.vtt index b45f582..9d1726a 100644 --- a/frontend/public/video/ad-05-school-run.vtt +++ b/frontend/public/video/ad-05-school-run.vtt @@ -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. diff --git a/frontend/public/video/ad-06-waitrose-test.jpg b/frontend/public/video/ad-06-waitrose-test.jpg index 421cf86..14e3d75 100644 --- a/frontend/public/video/ad-06-waitrose-test.jpg +++ b/frontend/public/video/ad-06-waitrose-test.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8d8a8245edb2b260b181137e0c7ed4714985fec06a46446e1183d0c9002a60c0 -size 262604 +oid sha256:1fc4f83fb1d2337608f23e55dc401dd3b04f58e593e999401af6bb2a9a2ec2a5 +size 214299 diff --git a/frontend/public/video/ad-06-waitrose-test.mp4 b/frontend/public/video/ad-06-waitrose-test.mp4 index 0e773fa..22650e6 100644 --- a/frontend/public/video/ad-06-waitrose-test.mp4 +++ b/frontend/public/video/ad-06-waitrose-test.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e3da72ea95676b8b1c9eee3fef27c84516964361594b1c85dc509fadf390d4a4 -size 15512217 +oid sha256:9f0639319bbe38e190c9a0c209b61fedcb0cc32c8d7ec24b78c55ef8b1781d22 +size 16456134 diff --git a/frontend/public/video/ad-06-waitrose-test.vtt b/frontend/public/video/ad-06-waitrose-test.vtt index 91e2df4..408e4b0 100644 --- a/frontend/public/video/ad-06-waitrose-test.vtt +++ b/frontend/public/video/ad-06-waitrose-test.vtt @@ -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. diff --git a/frontend/public/video/ad-07-renters-map.jpg b/frontend/public/video/ad-07-renters-map.jpg index f12da12..e7e59c0 100644 --- a/frontend/public/video/ad-07-renters-map.jpg +++ b/frontend/public/video/ad-07-renters-map.jpg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4d44198496cf8ed08e4eca1c36ba9cc8b4f8b08f0aecc943b1a9364c1391e5a4 -size 194734 +oid sha256:ea8949a1a8145ba41e9770b6f49fb91d6cb97a680543228df08522ed37285b97 +size 228736 diff --git a/frontend/public/video/ad-07-renters-map.mp4 b/frontend/public/video/ad-07-renters-map.mp4 index d3bea37..d077e97 100644 --- a/frontend/public/video/ad-07-renters-map.mp4 +++ b/frontend/public/video/ad-07-renters-map.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4b18ff5179d719648f6f9475201c29d6d04de426686dde04d0f2a423b1080ab -size 15048624 +oid sha256:c61192a74616958d1b71f1ac779d92400b89b29c4f890cffb3b797138a49c5e0 +size 15608690 diff --git a/frontend/public/video/ad-07-renters-map.vtt b/frontend/public/video/ad-07-renters-map.vtt index 0c859e4..b5537e4 100644 --- a/frontend/public/video/ad-07-renters-map.vtt +++ b/frontend/public/video/ad-07-renters-map.vtt @@ -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. diff --git a/frontend/public/video/ad-08-value.vtt b/frontend/public/video/ad-08-value.vtt index 31ee71d..41f82cb 100644 --- a/frontend/public/video/ad-08-value.vtt +++ b/frontend/public/video/ad-08-value.vtt @@ -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. diff --git a/frontend/public/video/twin-beckenham-croydon.vtt b/frontend/public/video/twin-beckenham-croydon.vtt index b75b2fe..a3dd665 100644 --- a/frontend/public/video/twin-beckenham-croydon.vtt +++ b/frontend/public/video/twin-beckenham-croydon.vtt @@ -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. diff --git a/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt b/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt index 1ecb5bb..e8eac77 100644 --- a/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt +++ b/frontend/public/video/twin-ha7-2-vs-ha3-0.vtt @@ -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. diff --git a/frontend/public/video/twin-l16-7-vs-l14-6.vtt b/frontend/public/video/twin-l16-7-vs-l14-6.vtt index 1ce3afc..4cd7759 100644 --- a/frontend/public/video/twin-l16-7-vs-l14-6.vtt +++ b/frontend/public/video/twin-l16-7-vs-l14-6.vtt @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f3bfd1f..898bde6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); const [poiCategoryGroups, setPOICategoryGroups] = useState([]); @@ -283,6 +298,7 @@ export default function App() { loginWithOAuth, logout, requestPasswordReset, + confirmPasswordReset, refreshAuth, clearError, } = useAuth(); @@ -708,6 +724,17 @@ export default function App() { ) : activePage === 'terms' || activePage === 'privacy' ? ( + ) : activePage === 'reset-password' ? ( + { + navigateTo('home'); + openAuthModal('login'); + }} + loading={authLoading} + error={authError} + onClearError={clearError} + /> ) : isSeoLandingPage(activePage) ? ( 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={() => { diff --git a/frontend/src/components/account/ResetPasswordPage.test.tsx b/frontend/src/components/account/ResetPasswordPage.test.tsx new file mode 100644 index 0000000..0b0d17d --- /dev/null +++ b/frontend/src/components/account/ResetPasswordPage.test.tsx @@ -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()), + 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( + {}} + /> + ); + 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'); + }); +}); diff --git a/frontend/src/components/account/ResetPasswordPage.tsx b/frontend/src/components/account/ResetPasswordPage.tsx new file mode 100644 index 0000000..012fd06 --- /dev/null +++ b/frontend/src/components/account/ResetPasswordPage.tsx @@ -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; + 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 ( +
+
+

+ {t('auth.resetPassword')} +

+ + {!token ? ( +
+

{t('auth.resetMissingToken')}

+ +
+ ) : done ? ( +
+

{t('auth.resetSuccessBody')}

+ +
+ ) : ( +
+
+ +
+ 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')} + /> + +
+
+ + {error &&

{error}

} + + + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx index fabb9e7..a472e1f 100644 --- a/frontend/src/components/map/AiFilterInput.tsx +++ b/frontend/src/components/map/AiFilterInput.tsx @@ -143,11 +143,11 @@ export default memo(function AiFilterInput({ if (!expanded) { return ( -
+
+ +
+ )} +
+ {charts.map((chart) => ( +
+ {charts.length > 1 && ( + + + {chart.label} + + )} + +
+ ))} - ) : null; + ); })()} {displayFeatureGroups.map((group) => { const showNearbyStations = diff --git a/frontend/src/components/map/FeatureBrowser.tsx b/frontend/src/components/map/FeatureBrowser.tsx index 66e59dc..5804d77 100644 --- a/frontend/src/components/map/FeatureBrowser.tsx +++ b/frontend/src/components/map/FeatureBrowser.tsx @@ -105,7 +105,7 @@ export default function FeatureBrowser({ return ( <> -
+
{group.features.length + @@ -141,7 +141,7 @@ export default function FeatureBrowser({ return (
diff --git a/frontend/src/components/map/ListingPopups.test.tsx b/frontend/src/components/map/ListingPopups.test.tsx new file mode 100644 index 0000000..25c38a8 --- /dev/null +++ b/frontend/src/components/map/ListingPopups.test.tsx @@ -0,0 +1,97 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; + +import { ListingPopupSingleContent } from './ListingPopups'; +import type { ActualListing } from '../../types'; + +// No global RTL setup file registers auto-cleanup, so unmount between cases to +// keep each render isolated in the shared jsdom document. +afterEach(cleanup); + +// Minimal react-i18next stub: t() echoes a readable label per key; i18n.language +// is fixed so date formatting is deterministic. +vi.mock('react-i18next', () => { + const labels: Record = { + 'listing.priceHistory': 'Price history', + 'listing.priceListed': 'Listed', + 'listing.priceReduced': 'Reduced', + 'listing.priceIncreased': 'Increased', + 'listing.openListing': 'Open listing', + 'listing.viewed': 'Viewed', + }; + return { + useTranslation: () => ({ + t: (key: string, opts?: Record) => + labels[key] ?? (opts ? `${key} ${JSON.stringify(opts)}` : key), + i18n: { language: 'en-GB' }, + }), + }; +}); + +function baseListing(overrides: Partial = {}): ActualListing { + return { + lat: 51.5, + lon: -0.1, + postcode: 'SW9 0HD', + listing_url: 'https://www.rightmove.co.uk/properties/1', + asking_price: 490000, + features: [], + ...overrides, + }; +} + +describe('ListingPopupSingleContent price history', () => { + it('renders points newest-first with signed deltas and reason labels', () => { + const listing = baseListing({ + price_history: [ + { date: '2026-07-01', price: 500000, reason: 'listed' }, + { date: '2026-07-19', price: 480000, reason: 'reduced' }, + { date: '2026-07-26', price: 490000, reason: 'increased' }, + ], + }); + const { getByText, container } = render( + {}} /> + ); + + getByText('Price history'); + // Newest first: increased row shows +£10,000 vs the £480k point before it. + const items = Array.from(container.querySelectorAll('ol li')); + expect(items).toHaveLength(3); + expect(items[0].textContent).toContain('£490,000'); + expect(items[0].textContent).toContain('+£10,000'); + expect(items[0].textContent).toContain('Increased'); + // Middle: the reduction from £500k -> £480k. + expect(items[1].textContent).toContain('£480,000'); + expect(items[1].textContent).toContain('−£20,000'); + expect(items[1].textContent).toContain('Reduced'); + // Oldest: the initial listing, no delta. + expect(items[2].textContent).toContain('£500,000'); + expect(items[2].textContent).toContain('Listed'); + expect(items[2].textContent).not.toContain('£0'); + // UTC-stable date: "2026-07-01" must read as 1 Jul regardless of runner TZ. + expect(items[2].textContent).toContain('1 Jul 2026'); + }); + + it('renders a single listed point with no delta', () => { + const listing = baseListing({ + price_history: [{ date: '2026-06-15', price: 325000, reason: 'listed' }], + }); + const { getByText, container } = render( + {}} /> + ); + getByText('Price history'); + expect(container.querySelectorAll('ol li')).toHaveLength(1); + }); + + it('omits the section entirely when there is no history', () => { + const { queryByText } = render( + {}} + /> + ); + expect(queryByText('Price history')).toBeNull(); + }); +}); diff --git a/frontend/src/components/map/ListingPopups.tsx b/frontend/src/components/map/ListingPopups.tsx index dda406b..6115255 100644 --- a/frontend/src/components/map/ListingPopups.tsx +++ b/frontend/src/components/map/ListingPopups.tsx @@ -2,12 +2,89 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; -import type { ActualListing } from '../../types'; +import type { ActualListing, PriceHistoryPoint } from '../../types'; function formatListingPrice(price: number): string { return `£${price.toLocaleString()}`; } +function formatHistoryDate(iso: string, locale: string): string { + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return iso; + // `iso` is a UTC calendar date ("YYYY-MM-DD"), parsed as UTC midnight. Format + // in UTC too, or a viewer west of UTC would see every date shifted a day back. + return parsed.toLocaleDateString(locale, { + day: 'numeric', + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); +} + +function reasonLabel(reason: string, t: TFunction): string { + switch (reason) { + case 'reduced': + return t('listing.priceReduced'); + case 'increased': + return t('listing.priceIncreased'); + default: + return t('listing.priceListed'); + } +} + +/** Compact asking-price timeline for the hover card. Renders most-recent first, + * with the £ change from the prior point on each reduction/increase. Accrues from + * the scraper's forward-only store, so a fresh listing shows a single point. */ +function ListingPriceHistory({ history }: { history: PriceHistoryPoint[] }) { + const { t, i18n } = useTranslation(); + if (history.length === 0) return null; + // history is oldest -> newest; show up to the 5 most recent, newest first. + const shown = history.slice(-5); + const baseIndex = history.length - shown.length; + const rows = shown + .map((point, idx) => { + const globalIdx = baseIndex + idx; + const prev = globalIdx > 0 ? history[globalIdx - 1] : null; + const delta = prev ? point.price - prev.price : 0; + return { point, delta }; + }) + .reverse(); + + return ( +
+
+ {t('listing.priceHistory')} +
+
    + {rows.map(({ point, delta }, idx) => { + const isReduction = point.reason === 'reduced'; + const isIncrease = point.reason === 'increased'; + const accent = isReduction + ? 'text-emerald-600 dark:text-emerald-400' + : isIncrease + ? 'text-red-600 dark:text-red-400' + : 'text-warm-600 dark:text-warm-300'; + return ( +
  1. + + {formatListingPrice(point.price)} + {delta !== 0 && ( + + {delta < 0 ? '−' : '+'}£{Math.abs(delta).toLocaleString()} + + )} + + + {reasonLabel(point.reason, t)} · {formatHistoryDate(point.date, i18n.language)} + +
  2. + ); + })} +
+
+ ); +} + function formatListingHeadline(listing: ActualListing, t: TFunction): string | null { const parts: string[] = []; if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms })); @@ -78,6 +155,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent ))} )} + {listing.price_history && listing.price_history.length > 0 && ( + + )}
{visited && ( ✓ {t('listing.viewed')} diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index 543d5c0..e82c5cc 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -710,6 +710,11 @@ export default function MapPage({ const shareAndSaveView = isMobile ? (mapData.currentVisibleView ?? mapData.currentView) : mapData.currentView; + // Params for share/save/checkout-return/last-session, deliberately WITHOUT the + // selected postcode: the lat/lon/zoom already convey the location, so focusing + // a postcode adds nothing to a shared link and shouldn't be baked into a saved + // search. The live URL (useUrlSync above) still carries `pc` so a reload + // re-opens the selection. const dashboardParams = useMemo( () => stateToParams( @@ -723,7 +728,7 @@ export default function MapPage({ activeOverlays, basemap, crimeTypes, - selectedPostcodeParam, + undefined, colorOpacity, listingsMode ).toString(), @@ -738,7 +743,6 @@ export default function MapPage({ listingsMode, rightPaneTab, selectedPOICategories, - selectedPostcodeParam, shareCode, shareAndSaveView, ] @@ -894,6 +898,9 @@ export default function MapPage({ total={propertiesTotal} loading={loadingProperties} hexagonId={selectedHexagon?.id || null} + statsUseFilters={areaStatsUseFilters} + onStatsUseFiltersChange={setAreaStatsUseFilters} + filtersActive={Object.keys(filters).length + activeEntries.length > 0} onLoadMore={handleLoadMoreProperties} scrollTopRef={propertiesPaneScrollTopRef} scrollRestoreKey={ @@ -903,7 +910,17 @@ export default function MapPage({ /> ), - [handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon] + [ + activeEntries, + areaStatsUseFilters, + filters, + handleLoadMoreProperties, + loadingProperties, + properties, + propertiesTotal, + selectedHexagon, + setAreaStatsUseFilters, + ] ); const poiPane = useMemo( diff --git a/frontend/src/components/map/PriceHistoryChart.tsx b/frontend/src/components/map/PriceHistoryChart.tsx index 846de2a..9eacfd5 100644 --- a/frontend/src/components/map/PriceHistoryChart.tsx +++ b/frontend/src/components/map/PriceHistoryChart.tsx @@ -1,9 +1,11 @@ import { useMemo, useRef, useState, useEffect } from 'react'; -import type { PricePoint } from '../../types'; +import type { PriceMetric, PricePoint } from '../../types'; import { formatValue } from '../../lib/format'; interface PriceHistoryChartProps { points: PricePoint[]; + /** Which value to plot. Defaults to the absolute sale price. */ + metric?: PriceMetric; } const PADDING = { top: 8, right: 24, bottom: 20, left: 48 }; @@ -22,7 +24,7 @@ interface PriceScale { ticks: number[]; } -export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { +export default function PriceHistoryChart({ points, metric = 'price' }: PriceHistoryChartProps) { const containerRef = useRef(null); const [width, setWidth] = useState(0); @@ -37,7 +39,18 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { return () => observer.disconnect(); }, []); + // Project each point onto the chosen metric as a plain {year, price} so the + // rest of the chart is metric-agnostic. In per-m² mode, points without a + // recorded floor area drop out. + const plotPoints = useMemo(() => { + if (metric === 'price') return points; + return points + .filter((p) => Number.isFinite(p.price_per_sqm)) + .map((p) => ({ year: p.year, price: p.price_per_sqm as number })); + }, [points, metric]); + const { yearMin, yearMax, priceScale, medians } = useMemo(() => { + const points = plotPoints; let yMin = Infinity, yMax = -Infinity; for (const p of points) { @@ -83,7 +96,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { priceScale: getPriceScale(points), medians: meds, }; - }, [points]); + }, [plotPoints]); const plotW = width - PADDING.left - PADDING.right; const plotH = HEIGHT - PADDING.top - PADDING.bottom; @@ -104,7 +117,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { return (
- {width > 0 && ( + {width > 0 && plotPoints.length > 0 && ( {/* Grid lines */} {priceScale.ticks.map((tick) => ( @@ -120,7 +133,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { ))} {/* Dots */} - {points.map((p, i) => ( + {plotPoints.map((p, i) => ( void; + filtersActive: boolean; onLoadMore: () => void; onNavigateToSource?: (slug: string) => void; scrollTopRef?: MutableRefObject; @@ -35,6 +38,9 @@ export function PropertiesPane({ total, loading, hexagonId, + statsUseFilters, + onStatsUseFiltersChange, + filtersActive, onLoadMore, onNavigateToSource, scrollTopRef, @@ -106,13 +112,41 @@ export function PropertiesPane({ )} -
+
+ {filtersActive && ( +
+ + +
+ )}
diff --git a/frontend/src/components/map/filters/ActiveFilterList.tsx b/frontend/src/components/map/filters/ActiveFilterList.tsx index ef9882b..403b792 100644 --- a/frontend/src/components/map/filters/ActiveFilterList.tsx +++ b/frontend/src/components/map/filters/ActiveFilterList.tsx @@ -448,12 +448,12 @@ export function ActiveFilterList({ onToggleGroup(group.name); onGroupToggle?.(group.name, !expanded); }} - className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800" + className="sticky top-0 z-30 px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800" > {count} {expanded && ( -
+
{group.name === TRANSPORT_GROUP && travelCards} {group.features.map((feature) => renderFeatureCard(feature))}
diff --git a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx index a9e9b29..807d92a 100644 --- a/frontend/src/components/map/filters/ActiveFiltersPanel.tsx +++ b/frontend/src/components/map/filters/ActiveFiltersPanel.tsx @@ -127,7 +127,7 @@ export function ActiveFiltersPanel({ > diff --git a/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx b/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx index 739ea39..2516f4b 100644 --- a/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx +++ b/frontend/src/components/map/filters/ElectionVoteShareFilterCard.tsx @@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({ return (
-