Compare commits

...
Sign in to create a new pull request.

27 commits

Author SHA1 Message Date
acacd5982a Release 0.1.1
All checks were successful
Build and publish Docker image / build-and-push (push) Successful in 5m22s
CI / Check (push) Successful in 8m11s
2026-07-16 07:57:15 +01:00
4428d6c50a Migrate video 2026-07-16 07:56:38 +01:00
75c18a8eb4 Fix tenure listing 2026-07-16 07:56:22 +01:00
4cc588cac4 Fix purple 2026-07-16 07:55:55 +01:00
362b96b8ee Better voices 2026-07-15 22:42:27 +01:00
df02ffd9c0 sticky boy 2026-07-15 22:42:16 +01:00
b47c9ba1ec Small 2026-07-15 22:00:36 +01:00
bce34b73de Hide POIs, show overlay state, remove zoom button, rotate vpn 2026-07-15 21:58:05 +01:00
35276d34fa Release 0.1.0
All checks were successful
Build and publish Docker image / build-and-push (push) Successful in 2m41s
CI / Check (push) Successful in 14m14s
2026-07-14 20:13:50 +01:00
a03e9633df Pretty bad 2026-07-14 20:13:19 +01:00
bf3f09aeb6 Fix render 2026-07-14 19:28:09 +01:00
68097386df Fix validation
All checks were successful
Build and publish Docker image / build-and-push (push) Successful in 1m17s
CI / Check (push) Successful in 9m20s
2026-07-14 16:59:59 +01:00
988c01c4f7 migrate to chatterbox 2026-07-14 16:59:48 +01:00
716e42a57d Improve vide 2026-07-14 16:22:23 +01:00
e544b946c9 fix tests
All checks were successful
Build and publish Docker image / build-and-push (push) Successful in 5m12s
CI / Check (push) Successful in 9m1s
2026-07-14 14:11:59 +01:00
56b5d9f7df fix flaky crime download 2026-07-14 14:11:39 +01:00
d78a301028 fix bin silliness 2026-07-14 14:10:47 +01:00
d1faad314b fmt
Some checks failed
CI / Check (push) Failing after 4m14s
Build and publish Docker image / build-and-push (push) Successful in 4m52s
2026-07-14 14:04:44 +01:00
cf348c3ea4 new storyboard
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 8m50s
CI / Check (push) Failing after 10m22s
2026-07-13 21:03:38 +01:00
f14981afee scrape loop 2026-07-13 21:03:14 +01:00
61114833b7 Rerender 2026-07-12 22:16:52 +01:00
f4d1b58bd8 release script 2026-07-12 21:36:50 +01:00
d7f844d566 Better 2026-07-12 21:31:13 +01:00
ca771a7edf fine 2 2026-07-12 20:37:39 +01:00
9e4e65fa2a fine 2026-07-12 20:30:19 +01:00
6df2812a4e Rename and purple instant 2026-07-12 15:31:05 +01:00
f948efc06c Refactor and show password 2026-07-12 15:23:29 +01:00
158 changed files with 6275 additions and 2757 deletions

View file

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

2
.gitignore vendored
View file

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

1
VERSION Normal file
View file

@ -0,0 +1 @@
0.1.1

View file

@ -18,7 +18,6 @@ from __future__ import annotations
import html import html
import json import json
import urllib.parse
from pathlib import Path from pathlib import Path
ROOT = Path(".") ROOT = Path(".")

View file

@ -74,7 +74,7 @@ def twin_kit(f: dict) -> str:
] ]
captions = [ captions = [
f"{pn} vs {wn}", f"{pn} vs {wn}",
f"Same station. Same schools.", "Same station. Same schools.",
f"{money} cheaper", f"{money} cheaper",
f"Same {typ}, ~{s['build_year']}", f"Same {typ}, ~{s['build_year']}",
f"{gap} less per m²", f"{gap} less per m²",
@ -135,7 +135,7 @@ def twin_kit(f: dict) -> str:
"", "",
f"**Page:** {SITE}{f['page_path']} · **Format:** faceless screen-record, ~4560s long + a 9:16 Short cut", f"**Page:** {SITE}{f['page_path']} · **Format:** faceless screen-record, ~4560s long + a 9:16 Short cut",
"", "",
f"## 🎬 Map URL to record (open this, hit record)", "## 🎬 Map URL to record (open this, hit record)",
f"`{url}`", f"`{url}`",
"*(filters are pre-applied so the value is on screen immediately)*", "*(filters are pre-applied so the value is on screen immediately)*",
"", "",

View file

@ -139,9 +139,6 @@ def curate(tw: pl.DataFrame) -> list[dict]:
def national_findings(idx: pl.DataFrame, facts: dict) -> list[dict]: def national_findings(idx: pl.DataFrame, facts: dict) -> list[dict]:
named = idx.with_columns(
pl.col("Sector").str.extract(r"^([A-Z]+[0-9][A-Z0-9]?)", 1).alias("outward")
)
best = facts["best_value_sector"] best = facts["best_value_sector"]
dear = facts["dearest_sector"] dear = facts["dearest_sector"]
return [ return [

View file

@ -315,7 +315,8 @@
"ax1.plot(yr, piv[\"existing\"].to_numpy(), marker=\"o\", label=\"Existing houses\", color=\"#0f766e\")\n", "ax1.plot(yr, piv[\"existing\"].to_numpy(), marker=\"o\", label=\"Existing houses\", color=\"#0f766e\")\n",
"ax1.plot(yr, piv[\"new\"].to_numpy(), marker=\"s\", label=\"New-build houses\", color=\"#d97706\")\n", "ax1.plot(yr, piv[\"new\"].to_numpy(), marker=\"s\", label=\"New-build houses\", color=\"#d97706\")\n",
"ax1.set_title(\"Median £/sqm by sale year\")\n", "ax1.set_title(\"Median £/sqm by sale year\")\n",
"ax1.set_xlabel(\"Year\"); ax1.set_ylabel(\"£/sqm\")\n", "ax1.set_xlabel(\"Year\")\n",
"ax1.set_ylabel(\"£/sqm\")\n",
"ax1.yaxis.set_major_formatter(mticker.StrMethodFormatter(\"£{x:,.0f}\"))\n", "ax1.yaxis.set_major_formatter(mticker.StrMethodFormatter(\"£{x:,.0f}\"))\n",
"ax1.legend()\n", "ax1.legend()\n",
"\n", "\n",
@ -323,9 +324,11 @@
"ax2.bar(yr, piv[\"premium_pct\"].to_numpy(), color=colors)\n", "ax2.bar(yr, piv[\"premium_pct\"].to_numpy(), color=colors)\n",
"ax2.axhline(0, color=\"black\", lw=0.8)\n", "ax2.axhline(0, color=\"black\", lw=0.8)\n",
"ax2.set_title(\"New-build premium (median £/sqm, new vs existing)\")\n", "ax2.set_title(\"New-build premium (median £/sqm, new vs existing)\")\n",
"ax2.set_xlabel(\"Year\"); ax2.set_ylabel(\"Premium %\")\n", "ax2.set_xlabel(\"Year\")\n",
"ax2.set_ylabel(\"Premium %\")\n",
"ax2.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n", "ax2.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
"plt.tight_layout(); plt.show()\n", "plt.tight_layout()\n",
"plt.show()\n",
"\n", "\n",
"piv.select(\"year\", \"existing\", \"new\", \"premium_pct\", \"n_new\", \"n_existing\")" "piv.select(\"year\", \"existing\", \"new\", \"premium_pct\", \"n_new\", \"n_existing\")"
] ]
@ -426,7 +429,8 @@
"for y, v, nnew in zip(range(len(labels)), vals, area_prem[\"n_new\"].to_list()):\n", "for y, v, nnew in zip(range(len(labels)), vals, area_prem[\"n_new\"].to_list()):\n",
" ax.text(v + (0.4 if v >= 0 else -0.4), y, f\"n={nnew}\", va=\"center\",\n", " ax.text(v + (0.4 if v >= 0 else -0.4), y, f\"n={nnew}\", va=\"center\",\n",
" ha=\"left\" if v >= 0 else \"right\", fontsize=8, color=\"#555\")\n", " ha=\"left\" if v >= 0 else \"right\", fontsize=8, color=\"#555\")\n",
"plt.tight_layout(); plt.show()\n", "plt.tight_layout()\n",
"plt.show()\n",
"area_prem" "area_prem"
] ]
}, },
@ -523,10 +527,12 @@
"ax.axvline(np.median(old_cagr), color=\"#0f766e\", ls=\"--\", lw=1.2)\n", "ax.axvline(np.median(old_cagr), color=\"#0f766e\", ls=\"--\", lw=1.2)\n",
"ax.axvline(np.median(new_cagr), color=\"#d97706\", ls=\"--\", lw=1.2)\n", "ax.axvline(np.median(new_cagr), color=\"#d97706\", ls=\"--\", lw=1.2)\n",
"ax.set_title(\"Distribution of annualised resale growth (CAGR)\")\n", "ax.set_title(\"Distribution of annualised resale growth (CAGR)\")\n",
"ax.set_xlabel(\"CAGR % per year\"); ax.set_ylabel(\"density\")\n", "ax.set_xlabel(\"CAGR % per year\")\n",
"ax.set_ylabel(\"density\")\n",
"ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n", "ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
"ax.legend()\n", "ax.legend()\n",
"plt.tight_layout(); plt.show()" "plt.tight_layout()\n",
"plt.show()"
] ]
}, },
{ {
@ -603,16 +609,19 @@
"\n", "\n",
"area_cagr = cagr_by(\"area\")\n", "area_cagr = cagr_by(\"area\")\n",
"labels = area_cagr[\"area\"].to_list()\n", "labels = area_cagr[\"area\"].to_list()\n",
"x = np.arange(len(labels)); w = 0.4\n", "x = np.arange(len(labels))\n",
"w = 0.4\n",
"fig, ax = plt.subplots(figsize=(10, 4.5))\n", "fig, ax = plt.subplots(figsize=(10, 4.5))\n",
"ax.bar(x - w/2, area_cagr[\"existing_cagr\"].to_numpy(), w, label=\"Existing\", color=\"#0f766e\")\n", "ax.bar(x - w/2, area_cagr[\"existing_cagr\"].to_numpy(), w, label=\"Existing\", color=\"#0f766e\")\n",
"ax.bar(x + w/2, area_cagr[\"newbuild_cagr\"].to_numpy(), w, label=\"New-build\", color=\"#d97706\")\n", "ax.bar(x + w/2, area_cagr[\"newbuild_cagr\"].to_numpy(), w, label=\"New-build\", color=\"#d97706\")\n",
"ax.set_xticks(x); ax.set_xticklabels(labels)\n", "ax.set_xticks(x)\n",
"ax.set_xticklabels(labels)\n",
"ax.set_title(\"Median resale CAGR by postal area\")\n", "ax.set_title(\"Median resale CAGR by postal area\")\n",
"ax.set_ylabel(\"CAGR % per year\")\n", "ax.set_ylabel(\"CAGR % per year\")\n",
"ax.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n", "ax.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
"ax.legend()\n", "ax.legend()\n",
"plt.tight_layout(); plt.show()\n", "plt.tight_layout()\n",
"plt.show()\n",
"area_cagr" "area_cagr"
] ]
}, },
@ -699,22 +708,25 @@
" area_cagr.select(\"area\", \"cagr_gap\", \"newbuild_cagr\", \"existing_cagr\"), on=\"area\", how=\"inner\"\n", " area_cagr.select(\"area\", \"cagr_gap\", \"newbuild_cagr\", \"existing_cagr\"), on=\"area\", how=\"inner\"\n",
")\n", ")\n",
"fig, ax = plt.subplots(figsize=(8, 6))\n", "fig, ax = plt.subplots(figsize=(8, 6))\n",
"xs = combined[\"premium_pct\"].to_numpy(); ys = combined[\"cagr_gap\"].to_numpy()\n", "xs = combined[\"premium_pct\"].to_numpy()\n",
"ys = combined[\"cagr_gap\"].to_numpy()\n",
"ax.scatter(xs, ys, s=70, color=\"#d97706\", zorder=3)\n", "ax.scatter(xs, ys, s=70, color=\"#d97706\", zorder=3)\n",
"for a, xv, yv in zip(combined[\"area\"].to_list(), xs, ys):\n", "for a, xv, yv in zip(combined[\"area\"].to_list(), xs, ys):\n",
" ax.annotate(a, (xv, yv), textcoords=\"offset points\", xytext=(6, 4), fontsize=10)\n", " ax.annotate(a, (xv, yv), textcoords=\"offset points\", xytext=(6, 4), fontsize=10)\n",
"ax.axhline(0, color=\"black\", lw=0.8); ax.axvline(0, color=\"black\", lw=0.8)\n", "ax.axhline(0, color=\"black\", lw=0.8)\n",
"ax.axvline(0, color=\"black\", lw=0.8)\n",
"ax.set_xlabel(\"New-build £/sqm premium at purchase\")\n", "ax.set_xlabel(\"New-build £/sqm premium at purchase\")\n",
"ax.set_ylabel(\"New-build CAGR minus existing CAGR (pp/yr)\")\n", "ax.set_ylabel(\"New-build CAGR minus existing CAGR (pp/yr)\")\n",
"ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n", "ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
"ax.set_title(\"Premium paid vs appreciation gap, by London postal area\")\n", "ax.set_title(\"Premium paid vs appreciation gap, by London postal area\")\n",
"plt.tight_layout(); plt.show()\n", "plt.tight_layout()\n",
"plt.show()\n",
"\n", "\n",
"# Headline numbers for the writeup.\n", "# Headline numbers for the writeup.\n",
"hl = summary.to_dicts()\n", "hl = summary.to_dicts()\n",
"new_row = next(r for r in hl if r[\"is_newbuild_prop\"])\n", "new_row = next(r for r in hl if r[\"is_newbuild_prop\"])\n",
"old_row = next(r for r in hl if not r[\"is_newbuild_prop\"])\n", "old_row = next(r for r in hl if not r[\"is_newbuild_prop\"])\n",
"print(f\"London terraced/detached, repeat sales:\")\n", "print(\"London terraced/detached, repeat sales:\")\n",
"print(f\" existing median CAGR: {old_row['median_cagr_pct']}%/yr (n={old_row['n']:,})\")\n", "print(f\" existing median CAGR: {old_row['median_cagr_pct']}%/yr (n={old_row['n']:,})\")\n",
"print(f\" new-build median CAGR: {new_row['median_cagr_pct']}%/yr (n={new_row['n']:,})\")\n", "print(f\" new-build median CAGR: {new_row['median_cagr_pct']}%/yr (n={new_row['n']:,})\")\n",
"print(f\" appreciation gap: {round(new_row['median_cagr_pct']-old_row['median_cagr_pct'],2)} pp/yr\")\n", "print(f\" appreciation gap: {round(new_row['median_cagr_pct']-old_row['median_cagr_pct'],2)} pp/yr\")\n",

View file

@ -14,6 +14,14 @@ step "Python lint: ruff" uv run ruff check .
step "Python dependency lint: deptry" uv run deptry . step "Python dependency lint: deptry" uv run deptry .
step "Python unit tests" uv run pytest pipeline 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" cd "$ROOT_DIR/frontend"
step "Frontend lint: ESLint" npm run lint step "Frontend lint: ESLint" npm run lint

View file

@ -11,7 +11,7 @@ services:
command: > command: >
bash -c " bash -c "
cargo install cargo-watch && cargo install cargo-watch &&
cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data/properties.parquet --postcode-features /app/property-data/postcode.parquet --pois /app/property-data/filtered_uk_pois.parquet --places /app/property-data/places.parquet --tiles /app/property-data/uk.pmtiles --postcodes /app/property-data/postcode_boundaries --travel-times /app/property-data/travel-times --satellite-tiles /app/property-data/satellite.pmtiles --satellite-highres-tiles /app/property-data/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data/property_borders.pmtiles --crime-by-year-path /app/property-data/crime_by_postcode_by_year.parquet --crime-records-path /app/property-data/crime_records.parquet --area-crime-averages-path /app/property-data/area_crime_averages.parquet --population-path /app/property-data/population_by_postcode.parquet' cargo watch --poll -i logs/ -x 'run -- --properties /app/property-data-snapshot-2026-07-14/properties.parquet --postcode-features /app/property-data-snapshot-2026-07-14/postcode.parquet --pois /app/property-data-snapshot-2026-07-14/filtered_uk_pois.parquet --places /app/property-data-snapshot-2026-07-14/places.parquet --tiles /app/property-data-snapshot-2026-07-14/uk.pmtiles --postcodes /app/property-data-snapshot-2026-07-14/postcode_boundaries --travel-times /app/property-data-snapshot-2026-07-14/travel-times --satellite-tiles /app/property-data-snapshot-2026-07-14/satellite.pmtiles --satellite-highres-tiles /app/property-data-snapshot-2026-07-14/satellite_highres.pmtiles --noise-overlay-tiles /app/property-data-snapshot-2026-07-14/noise_lden_10m.pmtiles --crime-hotspot-tiles /app/property-data-snapshot-2026-07-14/crime_hotspots.pmtiles --tree-overlay-tiles /app/property-data-snapshot-2026-07-14/trees_outside_woodlands.pmtiles --property-border-tiles /app/property-data-snapshot-2026-07-14/property_borders.pmtiles --crime-by-year-path /app/property-data-snapshot-2026-07-14/crime_by_postcode_by_year.parquet --crime-records-path /app/property-data-snapshot-2026-07-14/crime_records.parquet --area-crime-averages-path /app/property-data-snapshot-2026-07-14/area_crime_averages.parquet --population-path /app/property-data-snapshot-2026-07-14/population_by_postcode.parquet'
" "
ports: ports:
- "8001:8001" - "8001:8001"
@ -30,6 +30,13 @@ services:
- cargo-home:/usr/local/cargo - cargo-home:/usr/local/cargo
- cargo-target:/app/server-rs/target - cargo-target:/app/server-rs/target
environment: 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 # Fallback only: the binary uses jemalloc as its global allocator
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas. # (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
MALLOC_ARENA_MAX: "2" MALLOC_ARENA_MAX: "2"
@ -57,7 +64,7 @@ services:
BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development} BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development}
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-} BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
ACTUAL_LISTINGS_PATH: /app/finder/data/online_listings_buy_enriched.parquet ACTUAL_LISTINGS_PATH: /app/finder/data/online_listings_buy_enriched.parquet
DEVELOPMENTS_PATH: /app/property-data/development_sites.parquet DEVELOPMENTS_PATH: /app/property-data-snapshot-2026-07-14/development_sites.parquet
BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false} BUGSINK_SEND_DEFAULT_PII: ${BUGSINK_SEND_DEFAULT_PII:-false}
depends_on: depends_on:
screenshot: screenshot:

View file

@ -187,6 +187,9 @@ Environment variables (override the defaults in `constants.py`):
| `ZOOPLA_OUTCODE_TIMEOUT_SECONDS` | `300` | Per-outcode wall-clock budget for Zoopla. | | `ZOOPLA_OUTCODE_TIMEOUT_SECONDS` | `300` | Per-outcode wall-clock budget for Zoopla. |
| `DETAIL_FETCH_CONCURRENCY` | `8` | Parallel detail fetches (Rightmove/OTM). | | `DETAIL_FETCH_CONCURRENCY` | `8` | Parallel detail fetches (Rightmove/OTM). |
| `REQUESTS_PER_SECOND` | `10` | Global request-rate cap. Lower it if you see `429`/`403`. | | `REQUESTS_PER_SECOND` | `10` | Global request-rate cap. Lower it if you see `429`/`403`. |
| `BLOCK_403_THRESHOLD` | `5` | Consecutive 403s from one host before the egress IP is rotated. |
| `BLOCK_MAX_ROTATIONS` | `3` | Egress-IP rotations allowed per run. `0` disables rotation. |
| `MAX_ROW_DROP_RATIO` | `0.25` | Reject the run if the merged total falls this far below the previous parquet. |
| `RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS` | `1` | Inert today (see note above). | | `RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS` | `1` | Inert today (see note above). |
Non-env code constants worth knowing (`constants.py` / `onthemarket.py`): Non-env code constants worth knowing (`constants.py` / `onthemarket.py`):
@ -216,7 +219,8 @@ uv run --with pytest pytest -q
| `scraper.py` | Orchestration: per-source runners, provider parallelism, cache load/save, merge + write. | | `scraper.py` | Orchestration: per-source runners, provider parallelism, cache load/save, merge + write. |
| `rightmove.py` / `onthemarket.py` / `zoopla.py` | Per-portal search + detail scraping and parsing. | | `rightmove.py` / `onthemarket.py` / `zoopla.py` | Per-portal search + detail scraping and parsing. |
| `transform.py` | Raw listing → output schema; postcode trust rules. | | `transform.py` | Raw listing → output schema; postcode trust rules. |
| `http_client.py` | Shared httpx client, retry/backoff, and the global `RATE_LIMITER`. | | `http_client.py` | Shared httpx client, retry/backoff, the global `RATE_LIMITER`, and egress-block detection (`BlockedError`). |
| `gluetun.py` | Gluetun control-API client: reads the public IP and rotates the (shared) VPN tunnel. |
| `postcode_cache.py` | Persistent (cross-run) detail-cache load/save. | | `postcode_cache.py` | Persistent (cross-run) detail-cache load/save. |
| `spatial.py` | Grid spatial index for coordinate → nearest postcode. | | `spatial.py` | Grid spatial index for coordinate → nearest postcode. |
| `storage.py` | Parquet writer (server-ready column names). | | `storage.py` | Parquet writer (server-ready column names). |

View file

@ -25,6 +25,32 @@ RETRY_BASE_DELAY = 2.0
# down if the portals start returning 429/403. # down if the portals start returning 429/403.
DETAIL_FETCH_CONCURRENCY = int(os.environ.get("DETAIL_FETCH_CONCURRENCY", "8")) DETAIL_FETCH_CONCURRENCY = int(os.environ.get("DETAIL_FETCH_CONCURRENCY", "8"))
REQUESTS_PER_SECOND = float(os.environ.get("REQUESTS_PER_SECOND", "10")) REQUESTS_PER_SECOND = float(os.environ.get("REQUESTS_PER_SECOND", "10"))
# Egress-block detection. A portal that 403s one URL is a quirk; a portal that
# 403s a whole HOST in a row is refusing our egress IP, which no amount of
# per-request retrying fixes. On 2026-07-15 Rightmove's Fastly edge did exactly
# this and every one of the 366 outcodes 403'd on the typeahead call, which the
# scraper silently recorded as "no such outcode" and published a Rightmove-less
# dataset. So: after this many consecutive 403s from one host, rotate the egress
# IP (finder/gluetun.py) and retry; once the rotation budget is spent and the
# host still 403s, raise http_client.BlockedError and fail the run loudly.
#
# Rotation is DISRUPTIVE (see gluetun.py: it restarts the tunnel shared with the
# media stack), so the budget is deliberately small. It is per-run, not per-host.
BLOCK_403_THRESHOLD = int(os.environ.get("BLOCK_403_THRESHOLD", "5"))
BLOCK_MAX_ROTATIONS = int(os.environ.get("BLOCK_MAX_ROTATIONS", "3"))
# Sanity gates on a finished scrape, checked before the parquet is overwritten
# (finder/scraper.py). A selected source yielding nothing, or the merged total
# collapsing against the previous run, means something is broken upstream rather
# than the market having moved; publishing that would quietly gut production.
#
# 10% is loose against real movement and tight against breakage: consecutive
# healthy cycles land within ~0.15% of each other (103,087 / 103,142 / 103,008),
# so a 10% fall is already a ~65x anomaly. It is deliberately BELOW the
# 2026-07-15 incident's own 18.7% drop (103,008 -> 83,785), which a 25% gate
# would have waved through.
MAX_ROW_DROP_RATIO = float(os.environ.get("MAX_ROW_DROP_RATIO", "0.10"))
GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index
MAX_BEDROOMS = 20 # sanity cap: values above this are almost certainly parsing errors MAX_BEDROOMS = 20 # sanity cap: values above this are almost certainly parsing errors

130
finder/gluetun.py Normal file
View file

@ -0,0 +1,130 @@
"""Gluetun VPN control-server client, shared by every scraper.
The scrapers egress through a Gluetun container's network namespace
(docker-compose.yml, network_mode "container:media_gluetun"), so when a portal
starts refusing our egress IP the cheapest unblocker is to make Gluetun
reconnect to a different VPN server. This module wraps Gluetun's HTTP control
API so both callers share one implementation:
* http_client.py rotates when a portal 403s every request (an egress block).
* zoopla.py rotates when Cloudflare Turnstile fires.
Rotation is SHARED and DISRUPTIVE: every container joined to Gluetun's netns
(here also qbittorrent/sonarr/radarr/prowlarr/seerr/jellyfin) loses
connectivity for the few seconds the tunnel takes to come back. It is therefore
serialised behind a module lock, so N blocked worker threads trigger ONE
rotation rather than N, and callers must budget rotations rather than retry
them freely.
"""
import logging
import threading
import time
import httpx
from constants import GLUETUN_API_KEY, GLUETUN_CONTROL_URL
log = logging.getLogger("rightmove")
# Serialises rotation across worker threads: a rotation tears down the shared
# tunnel, so two concurrent ones would fight (and needlessly double the outage).
_ROTATION_LOCK = threading.Lock()
def _base_url() -> str:
return GLUETUN_CONTROL_URL.rstrip("/")
def _client() -> httpx.Client:
# Talks to the control server directly (not through the VPN proxy).
headers = {}
if GLUETUN_API_KEY:
headers["X-API-Key"] = GLUETUN_API_KEY
return httpx.Client(headers=headers)
def public_ip(client: httpx.Client) -> str | None:
try:
resp = client.get(f"{_base_url()}/v1/publicip/ip", timeout=5.0)
if resp.status_code != 200:
return None
data = resp.json()
except (httpx.HTTPError, ValueError):
return None
return data.get("public_ip") or data.get("ip")
def _set_vpn_status(client: httpx.Client, status: str) -> bool:
"""PUT /v1/vpn/status with {'status': status}. Returns True on 2xx."""
try:
resp = client.put(
f"{_base_url()}/v1/vpn/status",
json={"status": status},
timeout=15.0,
)
except httpx.HTTPError as exc:
log.warning("Gluetun vpn/status %s failed: %s", status, exc)
return False
if resp.status_code == 401:
log.warning(
"Gluetun vpn/status %s: 401 Unauthorized. The API key must be "
"authorised for 'PUT /v1/vpn/status' in Gluetun's auth config.toml",
status,
)
return False
if resp.status_code >= 400:
log.warning(
"Gluetun vpn/status %s returned HTTP %d: %s",
status,
resp.status_code,
resp.text[:200],
)
return False
return True
def rotate_ip(wait_seconds: int = 45) -> bool:
"""Restart Gluetun's VPN and wait for the public IP to change.
Returns True if a new IP was observed within ``wait_seconds``. Serialised:
while one thread rotates, others block here and then see the already-rotated
IP, so a 403 storm across many threads costs one tunnel restart. A failed
rotation always attempts to bring the tunnel back up, because leaving it
stopped would strand every container sharing the netns.
"""
with _ROTATION_LOCK, _client() as client:
old_ip = public_ip(client)
log.info("Requesting Gluetun IP rotation (current IP: %s)", old_ip or "unknown")
stop_attempted = False
restart_confirmed = False
try:
stop_attempted = True
if not _set_vpn_status(client, "stopped"):
return False
time.sleep(2)
restart_confirmed = _set_vpn_status(client, "running")
if not restart_confirmed:
return False
deadline = time.monotonic() + wait_seconds
while time.monotonic() < deadline:
time.sleep(2)
new_ip = public_ip(client)
if new_ip and new_ip != old_ip:
log.info("Gluetun rotated IP: %s -> %s", old_ip or "?", new_ip)
return True
finally:
if stop_attempted and not restart_confirmed:
log.warning(
"Gluetun VPN may be stopped after failed rotation; "
"attempting recovery start"
)
if not _set_vpn_status(client, "running"):
log.error(
"Gluetun VPN recovery start failed; manual intervention required"
)
log.warning("Gluetun IP did not change within %ds", wait_seconds)
return False

View file

@ -2,12 +2,16 @@ import logging
import random import random
import threading import threading
import time import time
from urllib.parse import urlsplit
import httpx import httpx
from fake_useragent import UserAgent from fake_useragent import UserAgent
import gluetun
import shutdown import shutdown
from constants import ( from constants import (
BLOCK_403_THRESHOLD,
BLOCK_MAX_ROTATIONS,
GLUETUN_PROXY, GLUETUN_PROXY,
MAX_RETRIES, MAX_RETRIES,
REQUESTS_PER_SECOND, REQUESTS_PER_SECOND,
@ -17,6 +21,16 @@ from constants import (
log = logging.getLogger("rightmove") log = logging.getLogger("rightmove")
class BlockedError(RuntimeError):
"""A host is refusing our egress IP and rotating away from it did not help.
Raised rather than returned so a block can never be mistaken for "this query
has no results": that conflation is precisely what turned Rightmove's
2026-07-15 edge block into a silent 0-listing run (see BLOCK_403_THRESHOLD).
Callers should abandon the source, not the outcode.
"""
class RateLimiter: class RateLimiter:
"""Thread-safe global limiter: spaces request starts by a minimum interval. """Thread-safe global limiter: spaces request starts by a minimum interval.
@ -51,6 +65,74 @@ class RateLimiter:
# providers). Spacing is global, so politeness is decoupled from concurrency. # providers). Spacing is global, so politeness is decoupled from concurrency.
RATE_LIMITER = RateLimiter(REQUESTS_PER_SECOND) RATE_LIMITER = RateLimiter(REQUESTS_PER_SECOND)
class _EgressBlockTracker:
"""Escalates a run of 403s from one host: rotate the egress IP, then give up.
Counts CONSECUTIVE 403s per host, reset by any 200 from that host, so one
forbidden URL never trips it while a refused egress IP does within a couple
of calls. The rotation budget is per-run and shared across hosts and
threads, because the tunnel being rotated is shared too.
"""
def __init__(self, threshold: int, max_rotations: int):
self._threshold = max(threshold, 1)
self._max_rotations = max(max_rotations, 0)
self._lock = threading.Lock()
self._consecutive: dict[str, int] = {}
self._rotations = 0
def record_success(self, host: str) -> None:
with self._lock:
self._consecutive.pop(host, None)
def record_403(self, host: str) -> str:
"""Register a 403 and decide what the caller should do next.
Returns "retry" (below the threshold, back off normally), "rotated"
(the egress IP changed, so the call deserves a fresh retry budget) or
"blocked" (the rotation budget is spent, or rotating failed).
Rotation runs under the lock: concurrent threads caught in the same 403
storm queue behind ONE tunnel restart and then re-read the reset
counters, rather than each spending a slice of the budget.
"""
with self._lock:
count = self._consecutive.get(host, 0) + 1
self._consecutive[host] = count
if count < self._threshold:
return "retry"
if self._rotations >= self._max_rotations:
return "blocked"
self._rotations += 1
log.warning(
"%d consecutive 403s from %s; rotating egress IP (rotation %d/%d)",
count,
host,
self._rotations,
self._max_rotations,
)
rotated = gluetun.rotate_ip()
# Either way the counters restart: on success the next 403s are
# evidence about the NEW IP, and on failure we must not re-enter
# rotation on the very next 403.
self._consecutive.clear()
if not rotated:
log.error("Egress IP rotation failed; treating %s as blocked", host)
return "blocked"
return "rotated"
def reset(self) -> None:
"""Forget all counters and refund the rotation budget (tests only)."""
with self._lock:
self._consecutive.clear()
self._rotations = 0
BLOCK_TRACKER = _EgressBlockTracker(BLOCK_403_THRESHOLD, BLOCK_MAX_ROTATIONS)
_ua = UserAgent( _ua = UserAgent(
browsers=["Chrome", "Edge"], os=["Windows", "Mac OS X"], min_version=120.0 browsers=["Chrome", "Edge"], os=["Windows", "Mac OS X"], min_version=120.0
) )
@ -69,22 +151,48 @@ def make_client() -> httpx.Client:
def fetch_with_retry( def fetch_with_retry(
client: httpx.Client, url: str, params: dict | None = None, on_403: bool = True client: httpx.Client, url: str, params: dict | None = None, on_403: bool = True
) -> dict | None: ) -> dict | None:
"""GET JSON with retries on 429/5xx/connection errors. """GET JSON with retries on 403/429/5xx/connection errors.
Returns None on permanent failure. The on_403 argument is kept for Returns None on permanent failure, EXCEPT when the host is refusing our
compatibility with older callers; 403 is now treated as non-retryable. egress IP, which raises BlockedError instead. A 403 from a shared CDN edge
is usually transient and is retried with backoff; once one host has 403'd
BLOCK_403_THRESHOLD times in a row the egress IP is rotated (and this call
gets a fresh retry budget on the new IP), and once the rotation budget is
spent the block is raised rather than degraded to a None the caller would
read as "no results". ``on_403=False`` opts out for callers where a 403 is
an expected per-URL answer rather than a verdict on our IP.
""" """
for attempt in range(MAX_RETRIES): host = urlsplit(url).netloc
attempt = 0
while attempt < MAX_RETRIES:
if shutdown.stop_requested(): if shutdown.stop_requested():
return None return None
try: try:
RATE_LIMITER.acquire() RATE_LIMITER.acquire()
resp = client.get(url, params=params) resp = client.get(url, params=params)
if resp.status_code == 200: if resp.status_code == 200:
BLOCK_TRACKER.record_success(host)
return resp.json() return resp.json()
if resp.status_code == 403 and on_403: if resp.status_code == 403 and on_403:
log.error("HTTP 403 from %s (forbidden)", url) action = BLOCK_TRACKER.record_403(host)
return None if action == "blocked":
raise BlockedError(
f"HTTP 403 from {url}: {host} is refusing this egress IP "
f"and rotating away from it did not help"
)
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
log.warning(
"HTTP 403 from %s, retry %d/%d in %.1fs",
url,
attempt + 1,
MAX_RETRIES,
delay,
)
# A rotation means a different egress IP, so the failures this
# call already accrued say nothing about the new one.
attempt = 0 if action == "rotated" else attempt + 1
shutdown.sleep(delay)
continue
if resp.status_code in (429, 500, 502, 503, 504): if resp.status_code in (429, 500, 502, 503, 504):
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1) delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
log.warning( log.warning(
@ -95,6 +203,7 @@ def fetch_with_retry(
MAX_RETRIES, MAX_RETRIES,
delay, delay,
) )
attempt += 1
shutdown.sleep(delay) shutdown.sleep(delay)
continue continue
log.error("HTTP %d from %s (non-retryable)", resp.status_code, url) log.error("HTTP %d from %s (non-retryable)", resp.status_code, url)
@ -114,6 +223,7 @@ def fetch_with_retry(
MAX_RETRIES, MAX_RETRIES,
delay, delay,
) )
attempt += 1
shutdown.sleep(delay) shutdown.sleep(delay)
log.error("All %d retries exhausted for %s", MAX_RETRIES, url) log.error("All %d retries exhausted for %s", MAX_RETRIES, url)
return None return None

View file

@ -218,6 +218,13 @@ def main() -> int:
return 130 # 128 + SIGINT, the conventional Ctrl+C exit code. return 130 # 128 + SIGINT, the conventional Ctrl+C exit code.
log.info("Scrape finished in %.1fs", elapsed) log.info("Scrape finished in %.1fs", elapsed)
log.info("Result: %s", result) log.info("Result: %s", result)
if result.get("failures"):
# Nothing was written, so the previous parquet is still the good one.
# Exiting non-zero keeps the caller (scripts/scrape-loop.sh) from
# enriching and publishing on top of a rejected run.
for failure in result["failures"]:
log.error("Scrape rejected: %s", failure)
return 1
if args.test and result.get("errors"): if args.test and result.get("errors"):
raise SystemExit("Test scrape failed; see errors in the result above.") raise SystemExit("Test scrape failed; see errors in the result above.")
return 0 return 0

120
finder/price_history.py Normal file
View file

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

View file

@ -5,6 +5,7 @@ import signal
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Callable, Iterable from typing import Callable, Iterable
@ -18,6 +19,7 @@ from constants import (
DATA_DIR, DATA_DIR,
DELAY_BETWEEN_OUTCODES, DELAY_BETWEEN_OUTCODES,
LONDON_OUTCODE_PREFIXES, LONDON_OUTCODE_PREFIXES,
MAX_ROW_DROP_RATIO,
ZOOPLA_DETAIL_BUDGET_FRACTION, ZOOPLA_DETAIL_BUDGET_FRACTION,
ZOOPLA_FETCH_DETAILS, ZOOPLA_FETCH_DETAILS,
ZOOPLA_FETCHER, ZOOPLA_FETCHER,
@ -28,12 +30,13 @@ import onthemarket
import rightmove import rightmove
import shutdown import shutdown
import zoopla import zoopla
from http_client import make_client from http_client import BlockedError, make_client
from onthemarket import search_outcode as onthemarket_search_outcode from onthemarket import search_outcode as onthemarket_search_outcode
from postcode_cache import load_cache, save_cache from postcode_cache import load_cache, save_cache
from rightmove import resolve_outcode_id from rightmove import resolve_outcode_id
from rightmove import search_outcode as rightmove_search_outcode from rightmove import search_outcode as rightmove_search_outcode
from spatial import PostcodeSpatialIndex from spatial import PostcodeSpatialIndex
from price_history import load_history, save_history, update_history
from storage import write_parquet from storage import write_parquet
from zoopla import TurnstileError from zoopla import TurnstileError
from zoopla import launch_browser as launch_zoopla_browser from zoopla import launch_browser as launch_zoopla_browser
@ -314,6 +317,72 @@ def _record_error(
log.warning(message) log.warning(message)
def _previous_row_count(path: Path) -> int | None:
"""Rows in the parquet we are about to replace, or None if there isn't one.
Reads footer metadata only, so it costs nothing on a 30MB file."""
if not path.exists():
return None
try:
return pl.scan_parquet(path).select(pl.len()).collect().item()
except Exception as exc: # noqa: BLE001 - an unreadable baseline must not fail the run
log.warning("Could not read row count from %s: %s", path, exc)
return None
def _sanity_failures(
results: dict[str, list[dict]],
selected_sources: list[str],
merged: list[dict],
output_path: Path,
abandoned: set[str],
) -> list[str]:
"""Reasons this run's data is too broken to publish, empty when it's fine.
Catches upstream breakage that the per-outcode error path cannot see, via
three gates, because no one of them is sufficient:
* ABANDONED. A source that hit an egress block stopped partway, so its
listings cover only the outcodes it reached. This gate is what catches a
block that starts MID-RUN: the source is then non-empty, so the zero-yield
gate below would wave it through on a single banked listing.
* ZERO YIELD. A selected source that returned nothing at all, whatever the
cause (403 storm from the first outcode, markup change, DNS).
* COLLAPSE. The merged total against the previous run, for degradations that
are neither clean-zero nor a raised block.
Cross-source dedup is why the merged total alone cannot be trusted: when
Rightmove vanished on 2026-07-15, OnTheMarket stopped losing dedup ties to
it and backfilled the total from ~9k unique to ~84k, so losing an entire
source showed up as an 18.7% dip.
"""
failures = []
for source in selected_sources:
if source in abandoned:
failures.append(
f"{source} was abandoned mid-run after an egress block, so its "
f"{_source_total(results, source)} listings cover only part of "
f"the outcode list"
)
elif _source_total(results, source) == 0:
failures.append(
f"{source} yielded 0 listings; it was selected for this run, so "
f"treat this as a failure rather than an empty market"
)
if not merged:
failures.append("no listings survived the merge")
previous = _previous_row_count(output_path)
if previous and merged:
drop = (previous - len(merged)) / previous
if drop > MAX_ROW_DROP_RATIO:
failures.append(
f"merged total collapsed from {previous} to {len(merged)} "
f"({drop:.0%} drop, limit {MAX_ROW_DROP_RATIO:.0%})"
)
return failures
def _scrape_rightmove( def _scrape_rightmove(
outcodes: list[str], outcodes: list[str],
pc_index: PostcodeSpatialIndex, pc_index: PostcodeSpatialIndex,
@ -333,6 +402,12 @@ def _scrape_rightmove(
try: try:
outcode_id = resolve_outcode_id(client, outcode) outcode_id = resolve_outcode_id(client, outcode)
except BlockedError:
# Our egress IP is refused, not this outcode. Grinding through
# the remaining outcodes would just collect 403s, so abandon the
# source and let _run_sources mark it abandoned, which fails the
# run however many outcodes we got through first.
raise
except Exception as exc: except Exception as exc:
_record_error(errors, "rightmove", outcode, exc) _record_error(errors, "rightmove", outcode, exc)
shutdown.sleep(DELAY_BETWEEN_OUTCODES) shutdown.sleep(DELAY_BETWEEN_OUTCODES)
@ -366,6 +441,8 @@ def _scrape_rightmove(
max_properties_per_source, max_properties_per_source,
) )
log.info("Rightmove %s: +%d", outcode, added) log.info("Rightmove %s: +%d", outcode, added)
except BlockedError:
raise
except Exception as exc: except Exception as exc:
_record_error(errors, "rightmove", outcode, exc) _record_error(errors, "rightmove", outcode, exc)
@ -394,6 +471,8 @@ def _scrape_rightmove(
max_properties_per_source, max_properties_per_source,
) )
log.info("Rightmove %s new-homes: +%d", outcode, added_new) log.info("Rightmove %s new-homes: +%d", outcode, added_new)
except BlockedError:
raise
except Exception as exc: except Exception as exc:
_record_error(errors, "rightmove", outcode, exc) _record_error(errors, "rightmove", outcode, exc)
@ -729,6 +808,7 @@ def _run_sources(
run_zoopla: Callable[[], None] | None, run_zoopla: Callable[[], None] | None,
background_runners: list[tuple[str, Callable[[], None]]], background_runners: list[tuple[str, Callable[[], None]]],
errors: list[str], errors: list[str],
abandoned: set[str],
) -> None: ) -> None:
"""Run the HTTP-based providers concurrently while Zoopla runs inline. """Run the HTTP-based providers concurrently while Zoopla runs inline.
@ -738,17 +818,28 @@ def _run_sources(
writes only its own ``results[source]`` list and appends to the shared writes only its own ``results[source]`` list and appends to the shared
``errors`` list (atomic under the GIL), so there is no cross-source data ``errors`` list (atomic under the GIL), so there is no cross-source data
race. One source raising never kills the others: each failure is recorded race. One source raising never kills the others: each failure is recorded
and the remaining sources still finish.""" and the remaining sources still finish.
A source that hit an egress block is named in ``abandoned`` as well as
``errors``: it stopped partway, so whatever it did collect covers only the
outcodes it reached, and _sanity_failures must reject the run even though
the source is not empty."""
with ThreadPoolExecutor(max_workers=max(1, len(background_runners))) as pool: with ThreadPoolExecutor(max_workers=max(1, len(background_runners))) as pool:
futures = {pool.submit(fn): name for name, fn in background_runners} futures = {pool.submit(fn): name for name, fn in background_runners}
if run_zoopla is not None: if run_zoopla is not None:
try: try:
run_zoopla() run_zoopla()
except BlockedError as exc:
_record_error(errors, "zoopla", "*", exc)
abandoned.add("zoopla")
except Exception as exc: # noqa: BLE001 - one source must not kill the run except Exception as exc: # noqa: BLE001 - one source must not kill the run
_record_error(errors, "zoopla", "*", exc) _record_error(errors, "zoopla", "*", exc)
for future, name in futures.items(): for future, name in futures.items():
try: try:
future.result() future.result()
except BlockedError as exc:
_record_error(errors, name, "*", exc)
abandoned.add(name)
except Exception as exc: # noqa: BLE001 - one source must not kill the run except Exception as exc: # noqa: BLE001 - one source must not kill the run
_record_error(errors, name, "*", exc) _record_error(errors, name, "*", exc)
@ -773,6 +864,10 @@ def run_scrape(
output_base.mkdir(parents=True, exist_ok=True) output_base.mkdir(parents=True, exist_ok=True)
errors: list[str] = [] errors: list[str] = []
# Sources that stopped partway because their egress was blocked. Tracked
# separately from `errors`: a partial source is a reason to reject the run,
# not just something to log.
abandoned: set[str] = set()
results = {source: [] for source in SOURCE_ORDER} results = {source: [] for source in SOURCE_ORDER}
started_at = time.time() started_at = time.time()
@ -832,18 +927,37 @@ def run_scrape(
) )
try: try:
_run_sources(run_zoopla, background_runners, errors) _run_sources(run_zoopla, background_runners, errors, abandoned)
finally: finally:
_save_detail_caches(selected_sources, cache_dir) _save_detail_caches(selected_sources, cache_dir)
merged, source_counts, deduped = _merge_properties(results) merged, source_counts, deduped = _merge_properties(results)
output_path = output_base / "online_listings_buy.parquet" output_path = output_base / "online_listings_buy.parquet"
if merged: failures = _sanity_failures(
write_parquet(merged, output_path) results, selected_sources, merged, output_path, abandoned
)
if failures:
# Publishing here is worse than publishing nothing: downstream (the
# enrich step, then the server) has no way to tell a gutted dataset from
# a real one, so the previous parquet stays and the run exits non-zero.
# The asking-price history is left alone too, since it is forward-only
# and would bake this run's gaps in permanently.
for failure in failures:
log.error("Sanity check failed: %s", failure)
log.error("Refusing to overwrite %s; keeping the previous data", output_path)
else: else:
if output_path.exists(): # Accrue the per-listing asking-price history before writing: load the
output_path.unlink() # persistent store, append this run's price moves, dump it, then embed
log.warning("No London-ish properties to write to %s", output_path) # 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)
counts = { counts = {
"total": len(merged), "total": len(merged),
@ -868,6 +982,10 @@ def run_scrape(
}, },
"counts": counts, "counts": counts,
"path": str(output_path), "path": str(output_path),
# `errors` are per-outcode and survivable; `failures` mean the run's
# output was rejected and nothing was written. Only the latter is fatal.
"errors": errors, "errors": errors,
"failures": failures,
"written": not failures,
"elapsed_seconds": round(time.time() - started_at, 3), "elapsed_seconds": round(time.time() - started_at, 3),
} }

View file

@ -10,8 +10,16 @@ from transform import map_property_type, normalize_postcode
log = logging.getLogger("rightmove") log = logging.getLogger("rightmove")
def write_parquet(properties: list[dict], path: Path) -> None: def write_parquet(
"""Write sale properties list to parquet with server-ready column names.""" 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: if not properties:
log.warning("No properties to write to %s", path) log.warning("No properties to write to %s", path)
return 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] asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
listing_statuses = ["For sale"] * len(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( df = pl.DataFrame(
{ {
"Bedrooms": [p["Bedrooms"] for p in properties], "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 date": listing_dates,
"Listing status": listing_statuses, "Listing status": listing_statuses,
"Asking price": asking_prices, "Asking price": asking_prices,
"price_history": price_history_col,
}, },
schema={ schema={
"Bedrooms": pl.Int32, "Bedrooms": pl.Int32,
@ -169,6 +186,11 @@ def write_parquet(properties: list[dict], path: Path) -> None:
"Listing date": pl.Datetime("us"), "Listing date": pl.Datetime("us"),
"Listing status": pl.Utf8, "Listing status": pl.Utf8,
"Asking price": pl.Int64, "Asking price": pl.Int64,
"price_history": pl.List(
pl.Struct(
{"date": pl.Utf8, "price": pl.Int64, "reason": pl.Utf8}
)
),
}, },
) )

View file

@ -0,0 +1,321 @@
"""Egress-block detection: 403 storms must rotate the IP, then fail the run.
Regression cover for 2026-07-15, when Rightmove's edge 403'd every typeahead
call, fetch_with_retry returned None, resolve_outcode_id read that as "no such
outcode" for all 366 outcodes, and the run published a Rightmove-less parquet
with errors: [] and exit 0.
"""
import httpx
import pytest
import http_client
import scraper
from http_client import BlockedError, _EgressBlockTracker
class _StubResponse:
def __init__(self, status_code: int, payload: dict | None = None):
self.status_code = status_code
self._payload = payload or {}
def json(self) -> dict:
return self._payload
class _StubClient:
"""Replays a fixed status sequence, recording the URLs it was asked for."""
def __init__(self, statuses: list[int], payload: dict | None = None):
self._statuses = list(statuses)
self._payload = payload or {"matches": []}
self.calls: list[str] = []
def get(self, url, params=None, headers=None):
self.calls.append(url)
status = self._statuses.pop(0) if self._statuses else 200
return _StubResponse(status, self._payload)
@pytest.fixture(autouse=True)
def _no_sleeping(monkeypatch):
# Backoff delays would otherwise make this suite take minutes.
monkeypatch.setattr(http_client.shutdown, "sleep", lambda _s: None)
monkeypatch.setattr(http_client.RATE_LIMITER, "acquire", lambda: None)
@pytest.fixture(autouse=True)
def _fresh_tracker(monkeypatch):
tracker = _EgressBlockTracker(threshold=3, max_rotations=1)
monkeypatch.setattr(http_client, "BLOCK_TRACKER", tracker)
return tracker
def test_403_is_retried_not_swallowed(monkeypatch):
"""A transient 403 must not end the call: this is what broke last time."""
rotations = []
monkeypatch.setattr(http_client.gluetun, "rotate_ip", lambda: rotations.append(1))
client = _StubClient([403, 200], payload={"matches": ["ok"]})
result = http_client.fetch_with_retry(client, "https://los.example.com/typeahead")
assert result == {"matches": ["ok"]}
assert len(client.calls) == 2, "the 403 should have been retried"
assert rotations == [], "one 403 is not a block; rotating would be disruptive"
def test_sustained_403s_rotate_the_egress_ip(monkeypatch):
"""Threshold consecutive 403s from one host trigger exactly one rotation."""
rotations = []
def _rotate():
rotations.append(1)
return True
monkeypatch.setattr(http_client.gluetun, "rotate_ip", _rotate)
# 3 x 403 hits the threshold and rotates; the retry budget resets and the
# next call succeeds on the "new IP".
client = _StubClient([403, 403, 403, 200], payload={"matches": ["ok"]})
result = http_client.fetch_with_retry(client, "https://los.example.com/typeahead")
assert result == {"matches": ["ok"]}
assert rotations == [1], "expected exactly one rotation"
def test_403s_surviving_rotation_raise_blocked_error(monkeypatch):
"""Once the rotation budget is spent, a block must raise, never return None.
Returning None is what let resolve_outcode_id read a site-wide block as
"this outcode does not exist" and silently produce zero listings.
"""
monkeypatch.setattr(http_client.gluetun, "rotate_ip", lambda: True)
client = _StubClient([403] * 20)
with pytest.raises(BlockedError, match="refusing this egress IP"):
http_client.fetch_with_retry(client, "https://los.example.com/typeahead")
def test_failed_rotation_is_treated_as_blocked(monkeypatch):
"""If we cannot rotate away from a blocked IP, we are blocked. Say so."""
monkeypatch.setattr(http_client.gluetun, "rotate_ip", lambda: False)
client = _StubClient([403] * 10)
with pytest.raises(BlockedError):
http_client.fetch_with_retry(client, "https://los.example.com/typeahead")
def test_success_resets_the_consecutive_count(monkeypatch):
"""Interleaved successes mean the IP is fine, so 403s must not accumulate."""
rotations = []
monkeypatch.setattr(
http_client.gluetun, "rotate_ip", lambda: rotations.append(1) or True
)
# 403,200 repeated: never 3 consecutive, so never a rotation.
for _ in range(5):
client = _StubClient([403, 200], payload={"matches": ["ok"]})
http_client.fetch_with_retry(client, "https://los.example.com/typeahead")
assert rotations == []
def test_on_403_false_opts_out_of_block_detection(monkeypatch):
monkeypatch.setattr(http_client.gluetun, "rotate_ip", lambda: True)
client = _StubClient([403])
assert (
http_client.fetch_with_retry(client, "https://x.example.com/y", on_403=False)
is None
)
def test_403_tracking_is_per_host(monkeypatch):
"""A block on one host must not be charged against another."""
rotations = []
monkeypatch.setattr(
http_client.gluetun, "rotate_ip", lambda: rotations.append(1) or True
)
for host in ("a.example.com", "b.example.com", "c.example.com"):
client = _StubClient([403, 200], payload={"matches": []})
http_client.fetch_with_retry(client, f"https://{host}/typeahead")
assert rotations == [], "one 403 each across three hosts is not a block"
def test_connection_errors_still_retry(monkeypatch):
"""The rewritten loop must not regress non-403 retry behaviour."""
class _FlakyClient:
def __init__(self):
self.calls = 0
def get(self, url, params=None, headers=None):
self.calls += 1
if self.calls == 1:
raise httpx.ConnectError("boom")
return _StubResponse(200, {"matches": ["ok"]})
client = _FlakyClient()
assert http_client.fetch_with_retry(client, "https://x.example.com/y") == {
"matches": ["ok"]
}
assert client.calls == 2
# ---------------------------------------------------------------------------
# Run-level sanity gates
# ---------------------------------------------------------------------------
def test_zero_yield_from_a_selected_source_is_a_failure(tmp_path):
results = {"rightmove": [], "onthemarket": [{"a": 1}], "zoopla": []}
failures = scraper._sanity_failures(
results,
["rightmove", "onthemarket"],
merged=[{"a": 1}],
output_path=tmp_path / "missing.parquet",
abandoned=set(),
)
assert len(failures) == 1
assert "rightmove yielded 0 listings" in failures[0]
def test_healthy_run_has_no_failures(tmp_path):
results = {"rightmove": [{"a": 1}], "onthemarket": [{"b": 2}], "zoopla": []}
assert (
scraper._sanity_failures(
results,
["rightmove", "onthemarket"],
merged=[{"a": 1}, {"b": 2}],
output_path=tmp_path / "missing.parquet",
abandoned=set(),
)
== []
)
def test_unselected_source_yielding_zero_is_fine(tmp_path):
"""zoopla is not scheduled in production; its 0 must not fail the run."""
results = {"rightmove": [{"a": 1}], "onthemarket": [{"b": 2}], "zoopla": []}
assert (
scraper._sanity_failures(
results,
["rightmove", "onthemarket"],
merged=[{"a": 1}],
output_path=tmp_path / "missing.parquet",
abandoned=set(),
)
== []
)
def test_source_abandoned_mid_run_is_a_failure_even_with_listings(tmp_path):
"""A block that starts mid-run must fail even though the source is non-empty.
Without this gate a single banked listing defeats the zero-yield check: the
source stops at outcode 300 of 366, keeps its 60k listings, OnTheMarket
backfills the merged total to within the drop limit, and the partial
dataset publishes with exit 0. That is the original incident with one
outcode's head start.
"""
import polars as pl
path = tmp_path / "online_listings_buy.parquet"
pl.DataFrame({"x": range(103008)}).write_parquet(path)
results = {
"rightmove": [{"a": 1}] * 60000,
"onthemarket": [{"b": 2}] * 83785,
"zoopla": [],
}
failures = scraper._sanity_failures(
results,
["rightmove", "onthemarket"],
merged=[{"a": 1}] * 99000, # only a 4% dip: both other gates pass
output_path=path,
abandoned={"rightmove"},
)
assert len(failures) == 1
assert "abandoned mid-run" in failures[0]
assert "60000 listings cover only part" in failures[0]
def test_the_real_incident_drop_now_trips_the_collapse_gate(tmp_path):
"""103,008 -> 83,785 is an 18.7% drop, which the original 25% limit missed."""
import polars as pl
path = tmp_path / "online_listings_buy.parquet"
pl.DataFrame({"x": range(103008)}).write_parquet(path)
# Pretend rightmove banked listings so only the collapse gate can fire.
results = {
"rightmove": [{"a": 1}] * 100,
"onthemarket": [{"b": 2}] * 83785,
"zoopla": [],
}
failures = scraper._sanity_failures(
results,
["rightmove", "onthemarket"],
merged=[{"b": 2}] * 83785,
output_path=path,
abandoned=set(),
)
assert len(failures) == 1
assert "collapsed from 103008 to 83785" in failures[0]
def test_row_count_collapse_is_a_failure(tmp_path):
"""Backstop for degradation that is neither a clean zero nor a raised block.
Dedup masks a lost source (OnTheMarket backfilled the keys Rightmove used to
win), so a partial block can still look plausible per-source.
"""
import polars as pl
path = tmp_path / "online_listings_buy.parquet"
pl.DataFrame({"x": range(1000)}).write_parquet(path)
results = {"rightmove": [{"a": 1}], "onthemarket": [{"b": 2}], "zoopla": []}
failures = scraper._sanity_failures(
results,
["rightmove", "onthemarket"],
merged=[{"a": 1}] * 700, # 30% drop, over the 10% limit
output_path=path,
abandoned=set(),
)
assert len(failures) == 1
assert "collapsed from 1000 to 700" in failures[0]
def test_normal_market_movement_is_not_a_failure(tmp_path):
import polars as pl
path = tmp_path / "online_listings_buy.parquet"
pl.DataFrame({"x": range(1000)}).write_parquet(path)
results = {"rightmove": [{"a": 1}], "onthemarket": [{"b": 2}], "zoopla": []}
assert (
scraper._sanity_failures(
results,
["rightmove", "onthemarket"],
merged=[{"a": 1}] * 970, # 3% drop, within normal movement
output_path=path,
abandoned=set(),
)
== []
)
def test_first_ever_run_has_no_baseline_to_compare(tmp_path):
results = {"rightmove": [{"a": 1}], "onthemarket": [{"b": 2}], "zoopla": []}
assert (
scraper._sanity_failures(
results,
["rightmove", "onthemarket"],
merged=[{"a": 1}],
output_path=tmp_path / "does-not-exist.parquet",
abandoned=set(),
)
== []
)

View file

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

View file

@ -31,7 +31,7 @@ def test_run_sources_runs_every_source_and_isolates_failures():
order.append("zoo") order.append("zoo")
errors: list[str] = [] errors: list[str] = []
scraper._run_sources(zoo, [("rightmove", rm), ("onthemarket", otm)], errors) scraper._run_sources(zoo, [("rightmove", rm), ("onthemarket", otm)], errors, set())
assert set(order) == {"rm", "otm", "zoo"} assert set(order) == {"rm", "otm", "zoo"}
# The failing source is recorded but did not stop the others. # The failing source is recorded but did not stop the others.
@ -45,14 +45,14 @@ def test_run_sources_records_zoopla_failure():
def zoo(): def zoo():
raise ValueError("zoo down") raise ValueError("zoo down")
scraper._run_sources(zoo, [], errors) scraper._run_sources(zoo, [], errors, set())
assert any("zoopla" in e and "zoo down" in e for e in errors) assert any("zoopla" in e and "zoo down" in e for e in errors)
def test_run_sources_handles_no_background_runners(): def test_run_sources_handles_no_background_runners():
ran = [] ran = []
errors: list[str] = [] errors: list[str] = []
scraper._run_sources(lambda: ran.append("z"), [], errors) scraper._run_sources(lambda: ran.append("z"), [], errors, set())
assert ran == ["z"] assert ran == ["z"]
assert errors == [] assert errors == []
@ -60,7 +60,7 @@ def test_run_sources_handles_no_background_runners():
def test_run_sources_handles_zoopla_absent(): def test_run_sources_handles_zoopla_absent():
ran = [] ran = []
errors: list[str] = [] errors: list[str] = []
scraper._run_sources(None, [("rightmove", lambda: ran.append("rm"))], errors) scraper._run_sources(None, [("rightmove", lambda: ran.append("rm"))], errors, set())
assert ran == ["rm"] assert ran == ["rm"]
assert errors == [] assert errors == []
@ -125,7 +125,9 @@ def test_run_scrape_runs_all_sources_merges_and_persists_caches(tmp_path, monkey
monkeypatch.setattr( monkeypatch.setattr(
scraper, scraper,
"write_parquet", "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( result = scraper.run_scrape(

View file

@ -385,6 +385,16 @@ def transform_property(
if not listing_id: if not listing_id:
return None 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 { return {
"id": listing_id, "id": listing_id,
"Bedrooms": bedrooms, "Bedrooms": bedrooms,
@ -411,4 +421,7 @@ def transform_property(
"Listing URL": build_listing_url(property_url, bool(prop.get("development"))), "Listing URL": build_listing_url(property_url, bool(prop.get("development"))),
"Listing features": key_features, "Listing features": key_features,
"first_visible_date": prop.get("firstVisibleDate", ""), "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,
} }

View file

@ -27,14 +27,11 @@ import time
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
import httpx import gluetun
import shutdown import shutdown
from constants import ( from constants import (
DATA_DIR, DATA_DIR,
DELAY_BETWEEN_PAGES, DELAY_BETWEEN_PAGES,
GLUETUN_API_KEY,
GLUETUN_CONTROL_URL,
GLUETUN_MAX_ROTATIONS, GLUETUN_MAX_ROTATIONS,
GLUETUN_PROXY, GLUETUN_PROXY,
MAX_BEDROOMS, MAX_BEDROOMS,
@ -472,110 +469,16 @@ def _challenge_timeout_seconds() -> int:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# #
# When Cloudflare Turnstile fires mid-scrape, the cheapest unblocker is to # When Cloudflare Turnstile fires mid-scrape, the cheapest unblocker is to
# swap the egress IP via Gluetun's HTTP control server. We stop and re-start # swap the egress IP via Gluetun's HTTP control server, then drop the stale
# the VPN, poll until the public IP changes, drop the stale cf_clearance # cf_clearance cookies (bound to the previous IP) and re-check the challenge.
# cookies (bound to the previous IP), then reload and re-check the challenge. # The control-server plumbing itself lives in gluetun.py, shared with
# http_client.py's egress-block detection.
def _gluetun_base_url() -> str:
return GLUETUN_CONTROL_URL.rstrip("/")
def _gluetun_api_key() -> str | None:
return GLUETUN_API_KEY
def _gluetun_max_rotations() -> int: def _gluetun_max_rotations() -> int:
return max(GLUETUN_MAX_ROTATIONS, 0) return max(GLUETUN_MAX_ROTATIONS, 0)
def _gluetun_client() -> httpx.Client:
# Talks to the control server directly (not through the VPN proxy).
headers = {}
api_key = _gluetun_api_key()
if api_key:
headers["X-API-Key"] = api_key
return httpx.Client(headers=headers)
def _gluetun_public_ip(client: httpx.Client) -> str | None:
try:
resp = client.get(f"{_gluetun_base_url()}/v1/publicip/ip", timeout=5.0)
if resp.status_code != 200:
return None
data = resp.json()
except (httpx.HTTPError, ValueError):
return None
return data.get("public_ip") or data.get("ip")
def _gluetun_set_vpn_status(client: httpx.Client, status: str) -> bool:
"""PUT /v1/vpn/status with {'status': status}. Returns True on 2xx."""
try:
resp = client.put(
f"{_gluetun_base_url()}/v1/vpn/status",
json={"status": status},
timeout=15.0,
)
except httpx.HTTPError as exc:
log.warning("Gluetun vpn/status %s failed: %s", status, exc)
return False
if resp.status_code == 401:
log.warning(
"Gluetun vpn/status %s: 401 Unauthorized. The API key must be "
"authorised for 'PUT /v1/vpn/status' in Gluetun's auth config.toml",
status,
)
return False
if resp.status_code >= 400:
log.warning(
"Gluetun vpn/status %s returned HTTP %d: %s",
status, resp.status_code, resp.text[:200],
)
return False
return True
def _rotate_gluetun_ip(wait_seconds: int = 45) -> bool:
"""Restart Gluetun's VPN and wait for the public IP to change.
Returns True if a new IP was observed within wait_seconds."""
with _gluetun_client() as client:
old_ip = _gluetun_public_ip(client)
log.info("Requesting Gluetun IP rotation (current IP: %s)", old_ip or "unknown")
stop_attempted = False
restart_confirmed = False
try:
stop_attempted = True
if not _gluetun_set_vpn_status(client, "stopped"):
return False
time.sleep(2)
restart_confirmed = _gluetun_set_vpn_status(client, "running")
if not restart_confirmed:
return False
deadline = time.monotonic() + wait_seconds
while time.monotonic() < deadline:
time.sleep(2)
new_ip = _gluetun_public_ip(client)
if new_ip and new_ip != old_ip:
log.info("Gluetun rotated IP: %s -> %s", old_ip or "?", new_ip)
return True
finally:
if stop_attempted and not restart_confirmed:
log.warning(
"Gluetun VPN may be stopped after failed rotation; attempting recovery start"
)
if not _gluetun_set_vpn_status(client, "running"):
log.error(
"Gluetun VPN recovery start failed; manual intervention required"
)
log.warning("Gluetun IP did not change within %ds", wait_seconds)
return False
def _clear_cloudflare_cookies(page) -> None: def _clear_cloudflare_cookies(page) -> None:
"""Drop cf_clearance / __cf_bm which are bound to the previous egress IP.""" """Drop cf_clearance / __cf_bm which are bound to the previous egress IP."""
try: try:
@ -596,7 +499,7 @@ def _rotate_and_retry_challenge(page, max_rotations: int) -> bool:
"Cloudflare Turnstile challenge, rotating Gluetun IP (attempt %d/%d)", "Cloudflare Turnstile challenge, rotating Gluetun IP (attempt %d/%d)",
attempt, max_rotations, attempt, max_rotations,
) )
if not _rotate_gluetun_ip(): if not gluetun.rotate_ip():
continue continue
_clear_cloudflare_cookies(page) _clear_cloudflare_cookies(page)

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

Binary file not shown.

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

Binary file not shown.

View file

@ -1,14 +1,14 @@
WEBVTT WEBVTT
00:00:00.201 --> 00:00:03.961 00:00:00.202 --> 00:00:03.802
My whole house brief is one plain sentence. My whole house brief is one plain sentence.
00:00:04.361 --> 00:00:08.841 00:00:04.102 --> 00:00:08.022
Every postcode in England that fits, sorted by value. Every postcode in England that fits, sorted by value.
00:00:10.141 --> 00:00:14.941 00:00:09.181 --> 00:00:13.501
Same schools, same commute, the price quietly drops nearby. Same schools, same commute, the price quietly drops nearby.
00:00:15.591 --> 00:00:19.511 00:00:14.001 --> 00:00:17.921
The underpriced twin is on this map. Find it. The underpriced twin is on this map. Find it.

Binary file not shown.

Binary file not shown.

View file

@ -1,14 +1,14 @@
WEBVTT WEBVTT
00:00:02.121 --> 00:00:05.961 00:00:00.242 --> 00:00:04.082
Every colour on this map is a commute to central London. Every colour on this map is a commute to central London.
00:00:06.361 --> 00:00:09.801 00:00:04.412 --> 00:00:07.852
Here's what twenty minutes actually leaves you. Here's what twenty minutes actually leaves you.
00:00:11.301 --> 00:00:16.741 00:00:08.832 --> 00:00:12.912
Same twenty minutes wherever it's lit, but not the same price. Same twenty minutes wherever it's lit, but not the same price.
00:00:17.391 --> 00:00:21.151 00:00:13.412 --> 00:00:17.092
The commute is priced in. The bargain is not. The commute is priced in. The bargain is not.

Binary file not shown.

Binary file not shown.

View file

@ -1,14 +1,14 @@
WEBVTT WEBVTT
00:00:01.021 --> 00:00:05.341 00:00:00.241 --> 00:00:05.121
One name costs more. The block next door scores the same. One name costs more. The block next door scores the same.
00:00:05.741 --> 00:00:11.581 00:00:05.451 --> 00:00:11.291
Sold prices, schools, crime, even Street View, for this exact postcode. Sold prices, schools, crime, even Street View, for this exact postcode.
00:00:12.031 --> 00:00:17.071 00:00:11.671 --> 00:00:16.231
The same evidence a pricey postcode has, sitting quietly cheaper here. The same evidence a pricey postcode has, sitting quietly cheaper here.
00:00:17.671 --> 00:00:22.071 00:00:16.731 --> 00:00:21.931
Every postcode, proven on the numbers, not its reputation. Every postcode, proven on the numbers, not its reputation.

Binary file not shown.

Binary file not shown.

View file

@ -1,14 +1,14 @@
WEBVTT WEBVTT
00:00:00.201 --> 00:00:04.441 00:00:00.202 --> 00:00:04.442
Listing photos are silent. Main roads are not. Listing photos are silent. Main roads are not.
00:00:04.841 --> 00:00:07.881 00:00:04.772 --> 00:00:07.812
This is London under fifty-five decibels. This is London under fifty-five decibels.
00:00:09.181 --> 00:00:13.021 00:00:08.892 --> 00:00:12.492
Nearby, just as quiet, and overlooked. Nearby, just as quiet, and overlooked.
00:00:13.671 --> 00:00:16.071 00:00:12.992 --> 00:00:15.392
The quiet street nobody bid up. The quiet street nobody bid up.

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

Binary file not shown.

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

Binary file not shown.

View file

@ -3,12 +3,12 @@ WEBVTT
00:00:00.201 --> 00:00:06.521 00:00:00.201 --> 00:00:06.521
Good primary, low crime, under three hundred and fifty. The actual family brief. 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. 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. 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. The schools are real. The premium is optional.

Binary file not shown.

Binary file not shown.

View file

@ -1,14 +1,14 @@
WEBVTT 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. 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. London, narrowed to the postcodes that fit the way you live.
00:00:11.504 --> 00:00:14.704 00:00:11.212 --> 00:00:14.252
Same amenities, lower price nearby. Same amenities, lower price nearby.
00:00:15.354 --> 00:00:19.354 00:00:14.752 --> 00:00:18.992
Same Waitrose. Same tube. Cheaper postcode. Same Waitrose. Same tube. Cheaper postcode.

Binary file not shown.

Binary file not shown.

View file

@ -1,14 +1,14 @@
WEBVTT WEBVTT
00:00:00.201 --> 00:00:05.561 00:00:00.202 --> 00:00:05.002
Rent under sixteen hundred, half an hour to central, on a quiet street. Rent under two grand, half an hour to central, on a quiet street.
00:00:05.961 --> 00:00:10.201 00:00:05.332 --> 00:00:09.892
Letting sites rank flats. This ranks the streets around them. Letting sites rank flats. This ranks the streets around them.
00:00:11.501 --> 00:00:15.821 00:00:10.972 --> 00:00:14.892
Same commute, same quiet, lower rent nearby. Same commute, same quiet, lower rent nearby.
00:00:16.471 --> 00:00:19.831 00:00:16.797 --> 00:00:19.997
The name costs more. The street does not. The name costs more. The street does not.

View file

@ -1,14 +1,14 @@
WEBVTT WEBVTT
00:00:00.201 --> 00:00:05.641 00:00:00.202 --> 00:00:05.722
Two streets, same schools, same commute, priced tens of thousands apart. Two streets, same schools, same commute, priced tens of thousands apart.
00:00:06.041 --> 00:00:10.201 00:00:06.052 --> 00:00:10.772
You pay for the postcode's name; the value sits a postcode away. You pay for the postcode's name; the value sits a postcode away.
00:00:11.501 --> 00:00:15.581 00:00:11.852 --> 00:00:16.412
Pay once, find the bargain, stop overpaying for the name. Pay once, find the bargain, stop overpaying for the name.
00:00:16.231 --> 00:00:19.351 00:00:16.912 --> 00:00:19.552
Buy value, not reputation. Buy value, not reputation.

BIN
frontend/public/video/recording-mobile.jpg (Stored with Git LFS)

Binary file not shown.

BIN
frontend/public/video/recording-mobile.mp4 (Stored with Git LFS)

Binary file not shown.

View file

@ -1,26 +1,26 @@
WEBVTT WEBVTT
00:00:00.263 --> 00:00:04.663 00:00:00.241 --> 00:00:04.121
A postcode's reputation is priced in. Its value isn't. A postcode's reputation is priced in. Its value isn't.
00:00:05.013 --> 00:00:11.413 00:00:04.471 --> 00:00:10.911
So start with the brief. Budget, commute, schools, even how quiet the street is. So start with the brief. Budget, commute, schools, even how quiet the street is.
00:00:11.783 --> 00:00:16.023 00:00:11.281 --> 00:00:16.361
One brief, and England narrows to the postcodes worth your money. One brief, and England narrows to the postcodes worth your money.
00:00:16.573 --> 00:00:20.813 00:00:16.911 --> 00:00:21.151
Tighten the commute, and the keepers narrow further in seconds. Tighten the commute, and the keepers narrow further in seconds.
00:00:21.413 --> 00:00:25.733 00:00:21.751 --> 00:00:25.791
Down at street level, the strongest streets start to stand out. Down at street level, the strongest streets start to stand out.
00:00:26.418 --> 00:00:35.218 00:00:27.033 --> 00:00:34.073
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband. Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
00:00:37.418 --> 00:00:42.698 00:00:36.278 --> 00:00:41.958
Keep the best-value few, export them, and scout where it actually counts. Keep the best-value few, export them, and scout where it actually counts.
00:00:43.822 --> 00:00:46.942 00:00:42.958 --> 00:00:46.158
Stop paying for the name. Find the value. Stop paying for the name. Find the value.

BIN
frontend/public/video/recording.jpg (Stored with Git LFS)

Binary file not shown.

BIN
frontend/public/video/recording.mp4 (Stored with Git LFS)

Binary file not shown.

View file

@ -1,26 +1,26 @@
WEBVTT WEBVTT
00:00:00.249 --> 00:00:04.649 00:00:00.241 --> 00:00:04.121
A postcode's reputation is priced in. Its value isn't. A postcode's reputation is priced in. Its value isn't.
00:00:04.999 --> 00:00:11.399 00:00:04.471 --> 00:00:10.911
So start with the brief. Budget, commute, schools, even how quiet the street is. So start with the brief. Budget, commute, schools, even how quiet the street is.
00:00:11.769 --> 00:00:16.009 00:00:11.281 --> 00:00:16.361
One brief, and England narrows to the postcodes worth your money. One brief, and England narrows to the postcodes worth your money.
00:00:16.559 --> 00:00:20.799 00:00:16.911 --> 00:00:21.151
Tighten the commute, and the keepers narrow further in seconds. Tighten the commute, and the keepers narrow further in seconds.
00:00:21.399 --> 00:00:25.719 00:00:21.751 --> 00:00:25.791
Down at street level, the strongest streets start to stand out. Down at street level, the strongest streets start to stand out.
00:00:27.010 --> 00:00:35.810 00:00:28.039 --> 00:00:35.079
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband. Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
00:00:37.910 --> 00:00:43.190 00:00:37.517 --> 00:00:43.197
Keep the best-value few, export them, and scout where it actually counts. Keep the best-value few, export them, and scout where it actually counts.
00:00:44.190 --> 00:00:47.310 00:00:44.197 --> 00:00:47.397
Stop paying for the name. Find the value. Stop paying for the name. Find the value.

View file

@ -1,14 +1,17 @@
WEBVTT WEBVTT
00:00:00.203 --> 00:00:06.283 00:00:00.206 --> 00:00:07.246
Beckenham and Croydon sit side by side: same trains, same school catchment. Beckenham and Croydon sit two kilometres apart, on the same trains, in the same school catchments.
00:00:06.683 --> 00:00:10.523 00:00:07.546 --> 00:00:12.586
Rank them by what each pound of floor space actually buys. Now colour every postcode by what a square metre of home actually costs there.
00:00:11.823 --> 00:00:16.943 00:00:13.666 --> 00:00:19.826
One postcode over, the same home quietly costs about a third less. 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.256 --> 00:00:24.096
That's over two hundred thousand pounds back on an average home.
00:00:24.696 --> 00:00:30.696
Beckenham's cheaper twin is on this map. Find yours, free. Beckenham's cheaper twin is on this map. Find yours, free.

View file

@ -1,14 +1,17 @@
WEBVTT WEBVTT
00:00:00.202 --> 00:00:05.882 00:00:00.201 --> 00:00:07.001
Stanmore and Kenton sit right next door, with the same schools and transport links. Stanmore and Kenton sit about two and a half kilometres apart, with the same schools and transport links.
00:00:06.282 --> 00:00:10.842 00:00:07.301 --> 00:00:12.341
Rank every postcode by what each pound of floor space actually buys. Now colour every postcode by what a square metre of home actually costs there.
00:00:12.142 --> 00:00:17.422 00:00:13.421 --> 00:00:19.421
One postcode over, the same home quietly costs about a sixth less. 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.851 --> 00:00:23.531
That's more than a hundred thousand pounds back on an average home.
00:00:24.131 --> 00:00:29.171
Stanmore's cheaper twin is on this map. Find yours, free. Stanmore's cheaper twin is on this map. Find yours, free.

View file

@ -1,14 +1,17 @@
WEBVTT WEBVTT
00:00:00.203 --> 00:00:06.843 00:00:00.201 --> 00:00:07.081
Childwall and Broadgreen sit right next door, with the same schools and transport links. Childwall and Broadgreen sit under two kilometres apart, with the same schools and transport links.
00:00:07.243 --> 00:00:11.803 00:00:07.381 --> 00:00:12.421
Rank every postcode by what each pound of floor space actually buys. Now colour every postcode by what a square metre of home actually costs there.
00:00:13.103 --> 00:00:18.223 00:00:13.501 --> 00:00:19.901
One postcode over, the same home quietly costs about a third less. 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. Childwall's cheaper twin is on this map. Find yours, free.

View file

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

View file

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

View file

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

View file

@ -476,7 +476,6 @@ export default function HomePage({
<div className="home-hero-container relative z-10 mx-auto flex w-full max-w-[104rem] flex-1 flex-col px-6 pb-8 pt-6 backdrop-blur-[2px] md:px-10 md:py-10"> <div className="home-hero-container relative z-10 mx-auto flex w-full max-w-[104rem] flex-1 flex-col px-6 pb-8 pt-6 backdrop-blur-[2px] md:px-10 md:py-10">
<div className="home-hero-layout hero-roomy-lift grid flex-1 items-center gap-x-8 gap-y-6"> <div className="home-hero-layout hero-roomy-lift grid flex-1 items-center gap-x-8 gap-y-6">
<div className="home-hero-copy min-w-0 max-w-4xl"> <div className="home-hero-copy min-w-0 max-w-4xl">
<p className="text-sm font-semibold text-teal-300 mb-3">{t('home.heroEyebrow')}</p>
<h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1]"> <h1 className="text-3xl md:text-5xl font-extrabold text-white mb-4 leading-[1.1]">
{t('home.heroTitle1')}{' '} {t('home.heroTitle1')}{' '}
<span className="text-teal-400">{t('home.heroTitle2')}</span>. <span className="text-teal-400">{t('home.heroTitle2')}</span>.
@ -511,8 +510,7 @@ export default function HomePage({
</div> </div>
<p className="text-sm font-medium text-teal-200 mb-4">{t('home.freeToExplore')}</p> <p className="text-sm font-medium text-teal-200 mb-4">{t('home.freeToExplore')}</p>
<PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} /> <PriceStrip onOpenPricing={onOpenPricing} hidePricing={hidePricing} />
<p className="text-sm text-warm-400 mb-6">{t('home.coverageNote')}</p> <div className="home-hero-stats flex flex-wrap mt-6 pt-3 border-t border-white/10">
<div className="home-hero-stats flex flex-wrap pt-3 border-t border-white/10">
<div className="home-hero-stat"> <div className="home-hero-stat">
<div className="home-hero-stat-value tabular-nums"> <div className="home-hero-stat-value tabular-nums">
<TickerValue text="13M" active={statsActive} /> <TickerValue text="13M" active={statsActive} />

View file

@ -143,11 +143,11 @@ export default memo(function AiFilterInput({
if (!expanded) { if (!expanded) {
return ( 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 <button
type="button" type="button"
onClick={() => setExpanded(true)} 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" /> <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"> <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 ( 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">
<div className="flex items-center gap-1.5 mb-1.5"> <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" /> <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"> <span className="text-xs font-medium text-teal-700 dark:text-teal-300">
{t('aiFilter.aiSearch')} {t('aiFilter.aiSearch')}

View file

@ -8,7 +8,10 @@ import type {
FeatureMeta, FeatureMeta,
FilterExclusion, FilterExclusion,
HexagonStatsResponse, HexagonStatsResponse,
PriceMetric,
PricePoint,
} from '../../types'; } from '../../types';
import { postcodeOutcode, postcodeSector } from '../../lib/postcode';
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime'; import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search'; import type { HexagonLocation } from '../../lib/external-search';
import { import {
@ -289,6 +292,7 @@ export default function AreaPane({
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups]; return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
}, [paneFeatureGroups, hexagonLocation]); }, [paneFeatureGroups, hexagonLocation]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null); const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [priceMetric, setPriceMetric] = useState<PriceMetric>('price');
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({ const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
restoreKey: scrollRestoreKey ?? hexagonId, restoreKey: scrollRestoreKey ?? hexagonId,
scrollTopRef, scrollTopRef,
@ -548,14 +552,105 @@ export default function AreaPane({
{stats.price_history && {stats.price_history &&
(() => { (() => {
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year))); 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="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300"> <div className="mb-1 flex items-center justify-between gap-2">
{t('areaPane.priceHistory')} <span className="text-xs text-warm-700 dark:text-warm-300">
</span> {t('areaPane.priceHistory')}
<PriceHistoryChart points={stats.price_history} /> </span>
{hasPerSqm && (
<div
className="grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700"
role="radiogroup"
aria-label={t('areaPane.priceMetric')}
>
<button
type="button"
role="radio"
aria-checked={metric === 'price'}
onClick={() => setPriceMetric('price')}
className={optionClass(metric === 'price')}
>
{t('areaPane.priceMetricTotal')}
</button>
<button
type="button"
role="radio"
aria-checked={metric === 'price_per_sqm'}
onClick={() => setPriceMetric('price_per_sqm')}
className={optionClass(metric === 'price_per_sqm')}
>
{t('areaPane.priceMetricPerSqm')}
</button>
</div>
)}
</div>
{charts.map((chart) => (
<div key={chart.key} className={chart.key === 'area' ? '' : 'mt-1.5'}>
{charts.length > 1 && (
<span className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-warm-500 dark:text-warm-400">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${chart.dot}`}
/>
{chart.label}
</span>
)}
<PriceHistoryChart points={chart.points} metric={metric} />
</div>
))}
</div> </div>
) : null; );
})()} })()}
{displayFeatureGroups.map((group) => { {displayFeatureGroups.map((group) => {
const showNearbyStations = const showNearbyStations =

View file

@ -105,7 +105,7 @@ export default function FeatureBrowser({
return ( 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 <SearchInput
value={search} value={search}
onChange={setSearch} onChange={setSearch}
@ -125,7 +125,7 @@ export default function FeatureBrowser({
toggleGroup(group.name); toggleGroup(group.name);
onGroupToggle?.(group.name, willExpand); 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"> <span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length + {group.features.length +
@ -141,7 +141,7 @@ export default function FeatureBrowser({
return ( return (
<div <div
key={mode} 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 <div
className="flex items-center gap-2 min-w-0" className="flex items-center gap-2 min-w-0"
@ -184,7 +184,7 @@ export default function FeatureBrowser({
return ( return (
<div <div
key={f.name} 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"> <div className="min-w-0 mr-2">
<FeatureLabel feature={f} size="sm" description={f.description} /> <FeatureLabel feature={f} size="sm" description={f.description} />

View file

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

View file

@ -2,12 +2,89 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import type { ActualListing } from '../../types'; import type { ActualListing, PriceHistoryPoint } from '../../types';
function formatListingPrice(price: number): string { function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`; 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 { function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
const parts: string[] = []; const parts: string[] = [];
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms })); if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
@ -78,6 +155,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
))} ))}
</ul> </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"> <div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
{visited && ( {visited && (
<span className="text-violet-600 dark:text-violet-400"> {t('listing.viewed')}</span> <span className="text-violet-600 dark:text-violet-400"> {t('listing.viewed')}</span>

View file

@ -1,7 +1,7 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react'; import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Map as MapGL, NavigationControl, ScaleControl } from 'react-map-gl/maplibre'; import { Map as MapGL, ScaleControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre'; import type { MapRef } from 'react-map-gl/maplibre';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import type { import type {
@ -350,6 +350,16 @@ export default memo(function Map({
}, [screenshotMode]); }, [screenshotMode]);
const handleLoad = useCallback(() => { const handleLoad = useCallback(() => {
// touchZoomRotate is a single handler for pinch-zoom AND pinch-rotate, so
// leaving it enabled for the zoom would also let touch users rotate the map
// off north with no compass to reset it. Split them: keep the zoom, drop the
// rotate, matching dragRotate/pitchWithRotate being off.
const map = mapRef.current?.getMap();
map?.touchZoomRotate.disableRotation();
// Same split for the keyboard. Its disableRotation docstring claims it kills
// panning too, but the handler only zeroes the bearing/pitch deltas, so plain
// arrow-key panning survives and Shift+arrow rotate/tilt does not.
map?.keyboard.disableRotation();
setMapReady(true); setMapReady(true);
}, []); }, []);
@ -550,9 +560,6 @@ export default memo(function Map({
zoom={viewState.zoom} zoom={viewState.zoom}
/> />
<DeckOverlay key={deckOverlayKey} layers={layers} getTooltip={null} /> <DeckOverlay key={deckOverlayKey} layers={layers} getTooltip={null} />
{!screenshotMode && (
<NavigationControl position="bottom-left" showZoom showCompass visualizePitch={false} />
)}
{!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />} {!screenshotMode && <ScaleControl position="bottom-left" maxWidth={100} unit="metric" />}
</MapGL> </MapGL>
{basemap === 'satellite' && ( {basemap === 'satellite' && (

View file

@ -31,6 +31,7 @@ import {
REGISTERED_MAX_FILTERS, REGISTERED_MAX_FILTERS,
INITIAL_VIEW_STATE, INITIAL_VIEW_STATE,
POSTCODE_ZOOM_THRESHOLD, POSTCODE_ZOOM_THRESHOLD,
POI_ZOOM_THRESHOLD,
} from '../../lib/consts'; } from '../../lib/consts';
import { boundsToCenterZoom } from '../../lib/fit-bounds'; import { boundsToCenterZoom } from '../../lib/fit-bounds';
import type { OverlayId } from '../../lib/overlays'; import type { OverlayId } from '../../lib/overlays';
@ -76,6 +77,7 @@ import {
useScreenshotReadySignal, useScreenshotReadySignal,
} from './map-page/effects'; } from './map-page/effects';
import { useMobileDrawer } from './map-page/useMobileDrawer'; import { useMobileDrawer } from './map-page/useMobileDrawer';
import type { MapControlState } from './map-page/MapControlButton';
import type { MapFlyTo, MapPageProps } from './map-page/types'; import type { MapFlyTo, MapPageProps } from './map-page/types';
export type { ExportState } from './map-page/types'; export type { ExportState } from './map-page/types';
@ -586,12 +588,26 @@ export default function MapPage({
[handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo] [handleCurrentLocationSearch, isMobile, openMobileDrawer, queueCurrentLocationFlyTo]
); );
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories); const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD;
const poisZoomedIn = (mapData.currentView?.zoom ?? 0) >= POI_ZOOM_THRESHOLD;
// Zoomed out POIs aren't drawn, so skip the fetch rather than pull a
// country-wide result set nothing will use.
const fetchedPois = usePOIData(mapData.bounds, selectedPOICategories, poisZoomedIn);
// Disabling POIs (clearing every category) must remove all POI cards/markers // Disabling POIs (clearing every category) must remove all POI cards/markers
// immediately, not on the next fetch tick. Gate on the selection itself so a // immediately, not on the next fetch tick. Gate on the selection itself so a
// stale fetch result can never keep cards on screen. // stale fetch result can never keep cards on screen.
const pois: POI[] = selectedPOICategories.size > 0 ? fetchedPois : EMPTY_POIS; const pois: POI[] = selectedPOICategories.size > 0 ? fetchedPois : EMPTY_POIS;
const overlaysZoomedIn = (mapData.currentView?.zoom ?? 0) >= POSTCODE_ZOOM_THRESHOLD; // Tell the toolbar buttons whether their selection is actually on the map, so
// a selection hidden by the zoom limit is visible as an amber dot rather than
// looking like the map simply has nothing on it.
const overlayControl: MapControlState = {
status: activeOverlays.size === 0 ? 'idle' : overlaysZoomedIn ? 'active' : 'hidden',
hiddenHint: t('overlays.zoomWarning', { count: activeOverlays.size }),
};
const poiControl: MapControlState = {
status: selectedPOICategories.size === 0 ? 'idle' : poisZoomedIn ? 'active' : 'hidden',
hiddenHint: t('poiPane.zoomWarning', { count: selectedPOICategories.size }),
};
const actualListingsFilterParam = useMemo( const actualListingsFilterParam = useMemo(
() => buildFilterString(filters, features), () => buildFilterString(filters, features),
[filters, features] [filters, features]
@ -710,6 +726,11 @@ export default function MapPage({
const shareAndSaveView = isMobile const shareAndSaveView = isMobile
? (mapData.currentVisibleView ?? mapData.currentView) ? (mapData.currentVisibleView ?? mapData.currentView)
: 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( const dashboardParams = useMemo(
() => () =>
stateToParams( stateToParams(
@ -723,7 +744,7 @@ export default function MapPage({
activeOverlays, activeOverlays,
basemap, basemap,
crimeTypes, crimeTypes,
selectedPostcodeParam, undefined,
colorOpacity, colorOpacity,
listingsMode listingsMode
).toString(), ).toString(),
@ -738,7 +759,6 @@ export default function MapPage({
listingsMode, listingsMode,
rightPaneTab, rightPaneTab,
selectedPOICategories, selectedPOICategories,
selectedPostcodeParam,
shareCode, shareCode,
shareAndSaveView, shareAndSaveView,
] ]
@ -894,6 +914,9 @@ export default function MapPage({
total={propertiesTotal} total={propertiesTotal}
loading={loadingProperties} loading={loadingProperties}
hexagonId={selectedHexagon?.id || null} hexagonId={selectedHexagon?.id || null}
statsUseFilters={areaStatsUseFilters}
onStatsUseFiltersChange={setAreaStatsUseFilters}
filtersActive={Object.keys(filters).length + activeEntries.length > 0}
onLoadMore={handleLoadMoreProperties} onLoadMore={handleLoadMoreProperties}
scrollTopRef={propertiesPaneScrollTopRef} scrollTopRef={propertiesPaneScrollTopRef}
scrollRestoreKey={ scrollRestoreKey={
@ -903,7 +926,17 @@ export default function MapPage({
/> />
</Suspense> </Suspense>
), ),
[handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon] [
activeEntries,
areaStatsUseFilters,
filters,
handleLoadMoreProperties,
loadingProperties,
properties,
propertiesTotal,
selectedHexagon,
setAreaStatsUseFilters,
]
); );
const poiPane = useMemo( const poiPane = useMemo(
@ -914,11 +947,12 @@ export default function MapPage({
selectedCategories={selectedPOICategories} selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories} onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length} poiCount={pois.length}
zoomedIn={poisZoomedIn}
onClose={handleClosePoiPane} onClose={handleClosePoiPane}
/> />
</Suspense> </Suspense>
), ),
[handleClosePoiPane, poiCategoryGroups, pois.length, selectedPOICategories] [handleClosePoiPane, poisZoomedIn, poiCategoryGroups, pois.length, selectedPOICategories]
); );
const overlayPane = useMemo( const overlayPane = useMemo(
@ -1222,9 +1256,11 @@ export default function MapPage({
onTogglePoiPane={handleTogglePoiPane} onTogglePoiPane={handleTogglePoiPane}
poiButtonLabel={t('poiPane.pointsOfInterest')} poiButtonLabel={t('poiPane.pointsOfInterest')}
poiPane={poiPane} poiPane={poiPane}
poiControl={poiControl}
overlayPaneOpen={overlayPaneOpen} overlayPaneOpen={overlayPaneOpen}
onToggleOverlayPane={handleToggleOverlayPane} onToggleOverlayPane={handleToggleOverlayPane}
overlayPane={overlayPane} overlayPane={overlayPane}
overlayControl={overlayControl}
filtersPane={filtersPane} filtersPane={filtersPane}
mobileLegend={mobileLegend} mobileLegend={mobileLegend}
renderAreaPane={renderAreaPane} renderAreaPane={renderAreaPane}
@ -1279,9 +1315,11 @@ export default function MapPage({
poiPaneOpen={poiPaneOpen} poiPaneOpen={poiPaneOpen}
onTogglePoiPane={handleTogglePoiPane} onTogglePoiPane={handleTogglePoiPane}
poiPane={poiPane} poiPane={poiPane}
poiControl={poiControl}
overlayPaneOpen={overlayPaneOpen} overlayPaneOpen={overlayPaneOpen}
onToggleOverlayPane={handleToggleOverlayPane} onToggleOverlayPane={handleToggleOverlayPane}
overlayPane={overlayPane} overlayPane={overlayPane}
overlayControl={overlayControl}
showSelectionPane={!!selectedHexagon} showSelectionPane={!!selectedHexagon}
rightPaneWidth={rightPaneWidth} rightPaneWidth={rightPaneWidth}
rightPaneHandlers={rightPaneHandlers} rightPaneHandlers={rightPaneHandlers}

View file

@ -17,6 +17,7 @@ interface POIPaneProps {
selectedCategories: Set<string>; selectedCategories: Set<string>;
onCategoriesChange: (categories: Set<string>) => void; onCategoriesChange: (categories: Set<string>) => void;
poiCount: number; poiCount: number;
zoomedIn: boolean;
onNavigateToSource?: (slug: string) => void; onNavigateToSource?: (slug: string) => void;
onClose?: () => void; onClose?: () => void;
} }
@ -26,6 +27,7 @@ export default function POIPane({
selectedCategories, selectedCategories,
onCategoriesChange, onCategoriesChange,
poiCount: _poiCount, poiCount: _poiCount,
zoomedIn,
onNavigateToSource, onNavigateToSource,
onClose, onClose,
}: POIPaneProps) { }: POIPaneProps) {
@ -148,6 +150,15 @@ export default function POIPane({
</p> </p>
</InfoPopup> </InfoPopup>
)} )}
{!zoomedIn && selectedCount > 0 && (
<div
role="alert"
className="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-xs text-amber-800 dark:border-amber-700/60 dark:bg-amber-900/30 dark:text-amber-200"
>
{t('poiPane.zoomWarning', { count: selectedCount })}
</div>
)}
</div> </div>
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 dark:border-warm-700"> <div className="flex-1 min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 dark:border-warm-700">

View file

@ -1,9 +1,11 @@
import { useMemo, useRef, useState, useEffect } from 'react'; import { useMemo, useRef, useState, useEffect } from 'react';
import type { PricePoint } from '../../types'; import type { PriceMetric, PricePoint } from '../../types';
import { formatValue } from '../../lib/format'; import { formatValue } from '../../lib/format';
interface PriceHistoryChartProps { interface PriceHistoryChartProps {
points: PricePoint[]; points: PricePoint[];
/** Which value to plot. Defaults to the absolute sale price. */
metric?: PriceMetric;
} }
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 }; const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
@ -22,7 +24,7 @@ interface PriceScale {
ticks: number[]; ticks: number[];
} }
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) { export default function PriceHistoryChart({ points, metric = 'price' }: PriceHistoryChartProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0); const [width, setWidth] = useState(0);
@ -37,7 +39,18 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
return () => observer.disconnect(); 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 { yearMin, yearMax, priceScale, medians } = useMemo(() => {
const points = plotPoints;
let yMin = Infinity, let yMin = Infinity,
yMax = -Infinity; yMax = -Infinity;
for (const p of points) { for (const p of points) {
@ -83,7 +96,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
priceScale: getPriceScale(points), priceScale: getPriceScale(points),
medians: meds, medians: meds,
}; };
}, [points]); }, [plotPoints]);
const plotW = width - PADDING.left - PADDING.right; const plotW = width - PADDING.left - PADDING.right;
const plotH = HEIGHT - PADDING.top - PADDING.bottom; const plotH = HEIGHT - PADDING.top - PADDING.bottom;
@ -104,7 +117,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
return ( return (
<div ref={containerRef} style={{ height: HEIGHT }}> <div ref={containerRef} style={{ height: HEIGHT }}>
{width > 0 && ( {width > 0 && plotPoints.length > 0 && (
<svg width={width} height={HEIGHT}> <svg width={width} height={HEIGHT}>
{/* Grid lines */} {/* Grid lines */}
{priceScale.ticks.map((tick) => ( {priceScale.ticks.map((tick) => (
@ -120,7 +133,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
))} ))}
{/* Dots */} {/* Dots */}
{points.map((p, i) => ( {plotPoints.map((p, i) => (
<circle <circle
key={i} key={i}
cx={scaleX(p.year)} cx={scaleX(p.year)}

View file

@ -23,6 +23,9 @@ interface PropertiesPaneProps {
total: number; total: number;
loading: boolean; loading: boolean;
hexagonId: string | null; hexagonId: string | null;
statsUseFilters: boolean;
onStatsUseFiltersChange: (useFilters: boolean) => void;
filtersActive: boolean;
onLoadMore: () => void; onLoadMore: () => void;
onNavigateToSource?: (slug: string) => void; onNavigateToSource?: (slug: string) => void;
scrollTopRef?: MutableRefObject<number>; scrollTopRef?: MutableRefObject<number>;
@ -35,6 +38,9 @@ export function PropertiesPane({
total, total,
loading, loading,
hexagonId, hexagonId,
statsUseFilters,
onStatsUseFiltersChange,
filtersActive,
onLoadMore, onLoadMore,
onNavigateToSource, onNavigateToSource,
scrollTopRef, scrollTopRef,
@ -106,13 +112,41 @@ export function PropertiesPane({
</InfoPopup> </InfoPopup>
)} )}
<div className="p-2"> <div className="p-2 space-y-2">
<SearchInput <SearchInput
value={search} value={search}
onChange={setSearch} onChange={setSearch}
placeholder={t('propertyCard.searchPlaceholder')} placeholder={t('propertyCard.searchPlaceholder')}
className="p-2" 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>
<div> <div>

View file

@ -307,7 +307,9 @@ export function ActiveFilterList({
dragValue={dragValue} dragValue={dragValue}
pinnedFeature={pinnedFeature} pinnedFeature={pinnedFeature}
filterImpact={councilBackendName ? filterImpacts?.[councilBackendName] : undefined} filterImpact={councilBackendName ? filterImpacts?.[councilBackendName] : undefined}
percentileScale={councilBackendName ? percentileScales.get(councilBackendName) : undefined} percentileScale={
councilBackendName ? percentileScales.get(councilBackendName) : undefined
}
onFilterChange={onFilterChange} onFilterChange={onFilterChange}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragChange={onDragChange} onDragChange={onDragChange}
@ -448,12 +450,12 @@ export function ActiveFilterList({
onToggleGroup(group.name); onToggleGroup(group.name);
onGroupToggle?.(group.name, !expanded); 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> <span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
</CollapsibleGroupHeader> </CollapsibleGroupHeader>
{expanded && ( {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.name === TRANSPORT_GROUP && travelCards}
{group.features.map((feature) => renderFeatureCard(feature))} {group.features.map((feature) => renderFeatureCard(feature))}
</div> </div>

View file

@ -127,7 +127,7 @@ export function ActiveFiltersPanel({
> >
<button <button
onClick={onToggleCollapsed} 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="sticky top-0 z-40 md:static 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"> <div className="flex items-center gap-2">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100"> <span className="text-sm font-bold text-navy-950 dark:text-warm-100">
@ -202,7 +202,7 @@ export function ActiveFiltersPanel({
onLoginRequired={onLoginRequired} onLoginRequired={onLoginRequired}
/> />
{enabledFeatureList.length === 0 && activeEntryCount === 0 && ( {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')} {t('filters.addFiltersHint')}
</p> </p>
)} )}

View file

@ -32,6 +32,29 @@ import {
type PoiFilterName, type PoiFilterName,
} from '../../../lib/poi-distance-filter'; } 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 { interface AddFilterPanelProps {
collapsed: boolean; collapsed: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
@ -91,26 +114,7 @@ export function AddFilterPanel({
const { t } = useTranslation(); const { t } = useTranslation();
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand(); const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
const browserPinnedFeature = const browserPinnedFeature = pinnedFeature && resolveBrowserPinnedFeature(pinnedFeature);
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 handleTogglePin = (name: string) => { const handleTogglePin = (name: string) => {
if (name === SCHOOL_FILTER_NAME) { if (name === SCHOOL_FILTER_NAME) {
@ -163,7 +167,7 @@ export function AddFilterPanel({
> >
<button <button
onClick={onToggleCollapsed} 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="sticky top-0 z-40 md:static 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"> <span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')} {t('filters.addFilter')}
@ -176,7 +180,7 @@ export function AddFilterPanel({
{(!collapsed || !isLicensed) && ( {(!collapsed || !isLicensed) && (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
{!collapsed && ( {!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 <FeatureBrowser
availableFeatures={availableFeatures} availableFeatures={availableFeatures}
allFeatures={allFeatures} allFeatures={allFeatures}
@ -194,7 +198,7 @@ export function AddFilterPanel({
</div> </div>
)} )}
{!isLicensed && ( {!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"> <p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-snug">
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '} {isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '}
<span className="text-xs text-warm-400 dark:text-warm-500"> <span className="text-xs text-warm-400 dark:text-warm-500">
@ -203,7 +207,7 @@ export function AddFilterPanel({
</p> </p>
<button <button
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick} 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')} {isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
</button> </button>

View file

@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({
return ( return (
<div <div
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME} 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 isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned : isPinned
@ -151,7 +151,7 @@ export function ElectionVoteShareFilterCard({
</div> </div>
<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')} {t('filters.party')}
</label> </label>
<div className="relative"> <div className="relative">

View file

@ -35,7 +35,7 @@ export function EnumFeatureFilterCard({
return ( return (
<div <div
data-filter-name={feature.name} 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"> <div className="flex items-start justify-between gap-1">
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap /> <FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap />
@ -47,7 +47,7 @@ export function EnumFeatureFilterCard({
onRemove={onRemoveFilter} onRemove={onRemoveFilter}
/> />
</div> </div>
<PillGroup> <PillGroup bleed>
{allValues.map((val) => ( {allValues.map((val) => (
<PillToggle <PillToggle
key={val} key={val}

View file

@ -119,7 +119,7 @@ export function EthnicityFilterCard({
return ( return (
<div <div
data-filter-name={ETHNICITIES_FILTER_NAME} 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 isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned : isPinned
@ -147,7 +147,7 @@ export function EthnicityFilterCard({
</div> </div>
<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')} {t('filters.ethnicity')}
</label> </label>
<div className="relative"> <div className="relative">

View file

@ -77,7 +77,7 @@ export function NumericFeatureFilterCard({
return ( return (
<div <div
data-filter-name={feature.name} 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"> <div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel <FeatureLabel

View file

@ -128,7 +128,7 @@ export function PoiDistanceFilterCard({
return ( return (
<div <div
data-filter-name={filterName} 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 isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned : isPinned
@ -156,7 +156,7 @@ export function PoiDistanceFilterCard({
</div> </div>
<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')} {t('filters.poiType')}
</label> </label>
<PoiTypeDropdown <PoiTypeDropdown

View file

@ -107,7 +107,7 @@ export function SchoolFilterCard({
return ( return (
<div <div
data-filter-name={SCHOOL_FILTER_NAME} 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"> <div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel <FeatureLabel
@ -127,7 +127,7 @@ export function SchoolFilterCard({
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1">
<div> <div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500"> <div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.schoolType')} {t('filters.schoolType')}

View file

@ -55,7 +55,9 @@ function EditableLabel({
if (e.key === 'Escape') setEditing(false); if (e.key === 'Escape') setEditing(false);
}} }}
onBlur={commit} onBlur={commit}
className="absolute w-16 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400" // w-16 fits ~7 digits at 10px, but touch devices force this to 16px (see
// index.css) to stop iOS zooming on focus, so widen to match there.
className="absolute w-16 pointer-coarse:w-24 text-[10px] text-center rounded border border-warm-300 dark:border-warm-600 bg-white dark:bg-warm-800 text-warm-700 dark:text-warm-200 px-0.5 focus:outline-none focus:ring-1 focus:ring-teal-400"
style={style} style={style}
/> />
); );
@ -118,7 +120,7 @@ export function SliderLabels({
if (feature && onValueChange) { if (feature && onValueChange) {
return ( 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 <EditableLabel
value={labels[0]} value={labels[0]}
formatted={minLabel} formatted={minLabel}

View file

@ -159,7 +159,7 @@ export function VariantFilterCard({
return ( return (
<div <div
data-filter-name={config.filterName} 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 isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned : isPinned
@ -190,7 +190,7 @@ export function VariantFilterCard({
so the dropdown is hidden and only the window toggle + slider remain. */} so the dropdown is hidden and only the window toggle + slider remain. */}
{variantOptions.length > 1 && ( {variantOptions.length > 1 && (
<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(config.dropdownLabelKey)} {t(config.dropdownLabelKey)}
</label> </label>
<div className="relative"> <div className="relative">
@ -216,7 +216,7 @@ export function VariantFilterCard({
{windowConfig && currentWindow && windowOptions.length > 1 && ( {windowConfig && currentWindow && windowOptions.length > 1 && (
<div> <div>
{windowConfig.labelKey && ( {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)} {t(windowConfig.labelKey)}
</label> </label>
)} )}

View file

@ -22,6 +22,7 @@ import { HouseIcon } from '../../ui/icons/HouseIcon';
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar'; import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo, PaneResizeHandlers } from './types'; import type { MapFlyTo, PaneResizeHandlers } from './types';
import { MapControlButton, type MapControlState } from './MapControlButton';
import { MapFallback, PaneFallback } from './Fallbacks'; import { MapFallback, PaneFallback } from './Fallbacks';
import { MapErrorBoundary } from '../MapErrorBoundary'; import { MapErrorBoundary } from '../MapErrorBoundary';
import { LoadingOverlay } from './LoadingOverlay'; import { LoadingOverlay } from './LoadingOverlay';
@ -74,9 +75,11 @@ interface DesktopMapPageProps {
poiPaneOpen: boolean; poiPaneOpen: boolean;
onTogglePoiPane: () => void; onTogglePoiPane: () => void;
poiPane: ReactNode; poiPane: ReactNode;
poiControl: MapControlState;
overlayPaneOpen: boolean; overlayPaneOpen: boolean;
onToggleOverlayPane: () => void; onToggleOverlayPane: () => void;
overlayPane: ReactNode; overlayPane: ReactNode;
overlayControl: MapControlState;
showSelectionPane: boolean; showSelectionPane: boolean;
rightPaneWidth: number; rightPaneWidth: number;
rightPaneHandlers: PaneResizeHandlers; rightPaneHandlers: PaneResizeHandlers;
@ -132,9 +135,11 @@ export function DesktopMapPage({
poiPaneOpen, poiPaneOpen,
onTogglePoiPane, onTogglePoiPane,
poiPane, poiPane,
poiControl,
overlayPaneOpen, overlayPaneOpen,
onToggleOverlayPane, onToggleOverlayPane,
overlayPane, overlayPane,
overlayControl,
showSelectionPane, showSelectionPane,
rightPaneWidth, rightPaneWidth,
rightPaneHandlers, rightPaneHandlers,
@ -179,7 +184,7 @@ export function DesktopMapPage({
> >
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div> <div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
<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} {...leftPaneHandlers}
> >
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@ -255,22 +260,24 @@ export function DesktopMapPage({
</span> </span>
</button> </button>
)} )}
<button <MapControlButton
data-tutorial="overlays-button" {...overlayControl}
dataTutorial="overlays-button"
label={t('overlays.heading')}
paneOpen={overlayPaneOpen}
showLabel
onClick={onToggleOverlayPane} onClick={onToggleOverlayPane}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`} icon={(highlighted) => <EyeIcon className="h-5 w-5" filled={highlighted} />}
> />
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} /> <MapControlButton
<span className="text-sm font-medium">{t('overlays.heading')}</span> {...poiControl}
</button> dataTutorial="poi-button"
<button label={t('poiPane.pointsOfInterest')}
data-tutorial="poi-button" paneOpen={poiPaneOpen}
showLabel
onClick={onTogglePoiPane} onClick={onTogglePoiPane}
className={`flex items-center gap-2 rounded-lg bg-white px-3 py-2 shadow-lg dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`} icon={() => <MapPinIcon className="h-5 w-5" />}
> />
<MapPinIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
</button>
</div> </div>
{listingsPaneOpen && ( {listingsPaneOpen && (
<div className="absolute bottom-16 right-4 z-10 flex max-h-[60vh] min-h-0 w-72 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900"> <div className="absolute bottom-16 right-4 z-10 flex max-h-[60vh] min-h-0 w-72 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900">

View file

@ -0,0 +1,77 @@
import { cleanup, fireEvent, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MapControlButton, type MapControlStatus } from './MapControlButton';
const HINT = 'Zoom in further to see the selected overlays.';
afterEach(cleanup);
/** Queries are scoped to this render's own container, so a test can render
* several buttons without them colliding in the shared document. */
function renderButton(status: MapControlStatus, paneOpen = false) {
const highlightedCalls: boolean[] = [];
const onClick = vi.fn();
const { container } = render(
<MapControlButton
status={status}
hiddenHint={HINT}
label="Overlays"
paneOpen={paneOpen}
showLabel
onClick={onClick}
icon={(highlighted) => {
highlightedCalls.push(highlighted);
return <svg />;
}}
/>
);
const button = container.querySelector('button');
if (!button) throw new Error('button not rendered');
return {
button,
dot: container.querySelector('.bg-amber-500'),
highlighted: highlightedCalls[0],
onClick,
};
}
describe('MapControlButton', () => {
it('only shows the amber dot when the selection is hidden', () => {
expect(renderButton('idle').dot).toBeNull();
expect(renderButton('active').dot).toBeNull();
expect(renderButton('hidden').dot).not.toBeNull();
});
it('explains the amber dot in the name so it is not conveyed by colour alone', () => {
// Hovering gives the tooltip and screen readers get the same text, both
// keeping the visible label as a prefix.
const hidden = renderButton('hidden');
expect(hidden.button.getAttribute('title')).toBe(`Overlays. ${HINT}`);
expect(hidden.button.getAttribute('aria-label')).toBe(`Overlays. ${HINT}`);
const active = renderButton('active');
expect(active.button.getAttribute('title')).toBe('Overlays');
expect(active.button.getAttribute('aria-label')).toBe('Overlays');
});
it('highlights whenever a selection exists or the pane is open', () => {
expect(renderButton('idle').highlighted).toBe(false);
expect(renderButton('active').highlighted).toBe(true);
// Hidden still counts as selected, so the button stays highlighted and the
// dot carries the "not on the map right now" part on its own.
expect(renderButton('hidden').highlighted).toBe(true);
expect(renderButton('idle', true).highlighted).toBe(true);
});
it('reports the pane open state to assistive tech', () => {
expect(renderButton('idle').button.getAttribute('aria-expanded')).toBe('false');
expect(renderButton('idle', true).button.getAttribute('aria-expanded')).toBe('true');
});
it('fires onClick', () => {
const { button, onClick } = renderButton('idle');
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,73 @@
import type { ReactNode } from 'react';
/**
* State of a map control's selection:
* - `idle`: nothing selected, so nothing is expected on the map.
* - `active`: selected and currently drawn.
* - `hidden`: selected but not drawn, because the map is zoomed out past the
* layer's threshold (POI_ZOOM_THRESHOLD / POSTCODE_ZOOM_THRESHOLD).
*/
export type MapControlStatus = 'idle' | 'active' | 'hidden';
export interface MapControlState {
status: MapControlStatus;
/** Explains the amber dot: why the selection isn't on the map right now. */
hiddenHint: string;
}
interface MapControlButtonProps extends MapControlState {
label: string;
paneOpen: boolean;
/** Desktop shows the label beside the icon; mobile is icon-only. */
showLabel: boolean;
onClick: () => void;
icon: (highlighted: boolean) => ReactNode;
dataTutorial?: string;
}
/**
* Floating map control (Overlays, POIs) that reports whether its selection is
* actually on the map: teal once something is selected, plus an amber dot while
* that selection is hidden by the zoom limit.
*/
export function MapControlButton({
status,
hiddenHint,
label,
paneOpen,
showLabel,
onClick,
icon,
dataTutorial,
}: MapControlButtonProps) {
const highlighted = paneOpen || status !== 'idle';
// The dot conveys "hidden" with colour alone, so the button's name carries
// that meaning for the tooltip and for screen readers. Keeping `label` as the
// prefix leaves the accessible name matching the visible one.
const name = status === 'hidden' ? `${label}. ${hiddenHint}` : label;
return (
<button
type="button"
data-tutorial={dataTutorial}
onClick={onClick}
aria-expanded={paneOpen}
aria-label={name}
title={name}
className={`flex items-center gap-2 rounded-lg bg-white shadow-lg dark:bg-warm-800 ${showLabel ? 'px-3 py-2' : 'p-2'} ${highlighted ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`}
>
{/* Anchored to the icon rather than the button so the dot sits in the same
spot on the labelled desktop button and the icon-only mobile one. */}
<span className="relative flex">
{icon(highlighted)}
{status === 'hidden' && (
<span
aria-hidden="true"
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-amber-500 ring-2 ring-white dark:bg-amber-400 dark:ring-warm-800"
/>
)}
</span>
{showLabel && <span className="text-sm font-medium">{label}</span>}
</button>
);
}

View file

@ -21,6 +21,7 @@ import { HouseIcon } from '../../ui/icons/HouseIcon';
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon'; import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar'; import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
import type { MapFlyTo } from './types'; import type { MapFlyTo } from './types';
import { MapControlButton, type MapControlState } from './MapControlButton';
import { MapFallback, PaneFallback } from './Fallbacks'; import { MapFallback, PaneFallback } from './Fallbacks';
import { MapErrorBoundary } from '../MapErrorBoundary'; import { MapErrorBoundary } from '../MapErrorBoundary';
import { LoadingOverlay } from './LoadingOverlay'; import { LoadingOverlay } from './LoadingOverlay';
@ -72,9 +73,11 @@ interface MobileMapPageProps {
onTogglePoiPane: () => void; onTogglePoiPane: () => void;
poiButtonLabel: string; poiButtonLabel: string;
poiPane: ReactNode; poiPane: ReactNode;
poiControl: MapControlState;
overlayPaneOpen: boolean; overlayPaneOpen: boolean;
onToggleOverlayPane: () => void; onToggleOverlayPane: () => void;
overlayPane: ReactNode; overlayPane: ReactNode;
overlayControl: MapControlState;
filtersPane: ReactNode; filtersPane: ReactNode;
mobileLegend: ReactNode; mobileLegend: ReactNode;
renderAreaPane: () => ReactNode; renderAreaPane: () => ReactNode;
@ -127,9 +130,11 @@ export function MobileMapPage({
onTogglePoiPane, onTogglePoiPane,
poiButtonLabel, poiButtonLabel,
poiPane, poiPane,
poiControl,
overlayPaneOpen, overlayPaneOpen,
onToggleOverlayPane, onToggleOverlayPane,
overlayPane, overlayPane,
overlayControl,
filtersPane, filtersPane,
mobileLegend, mobileLegend,
renderAreaPane, renderAreaPane,
@ -220,20 +225,22 @@ export function MobileMapPage({
)} )}
</button> </button>
)} )}
<button <MapControlButton
{...overlayControl}
label={t('overlays.heading')}
paneOpen={overlayPaneOpen}
showLabel={false}
onClick={onToggleOverlayPane} onClick={onToggleOverlayPane}
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${overlayPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`} icon={(highlighted) => <EyeIcon className="h-5 w-5" filled={highlighted} />}
aria-label={t('overlays.heading')} />
> <MapControlButton
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} /> {...poiControl}
</button> label={poiButtonLabel}
<button paneOpen={poiPaneOpen}
showLabel={false}
onClick={onTogglePoiPane} onClick={onTogglePoiPane}
className={`rounded-lg bg-white p-2 shadow-lg dark:bg-warm-800 ${poiPaneOpen ? 'text-teal-600 dark:text-teal-400' : 'text-warm-500 hover:text-teal-600 dark:text-warm-400 dark:hover:text-teal-400'}`} icon={() => <MapPinIcon className="h-5 w-5" />}
aria-label={poiButtonLabel} />
>
<MapPinIcon className="h-5 w-5" />
</button>
</div> </div>
{listingsPaneOpen && ( {listingsPaneOpen && (

View file

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

View file

@ -3,6 +3,8 @@ import { Trans, useTranslation } from 'react-i18next';
import type { ParseKeys } from 'i18next'; import type { ParseKeys } from 'i18next';
import { CloseIcon } from './icons/CloseIcon'; import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon'; import { GoogleIcon } from './icons/GoogleIcon';
import { EyeIcon } from './icons/EyeIcon';
import { EyeOffIcon } from './icons/EyeOffIcon';
import { trackEvent } from '../../lib/analytics'; import { trackEvent } from '../../lib/analytics';
import { useModalA11y } from '../../hooks/useModalA11y'; import { useModalA11y } from '../../hooks/useModalA11y';
import type { AuthErrorAction } from '../../lib/auth-errors'; import type { AuthErrorAction } from '../../lib/auth-errors';
@ -42,6 +44,7 @@ export default function AuthModal({
const [view, setView] = useState<View>(initialTab); const [view, setView] = useState<View>(initialTab);
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [resetSent, setResetSent] = useState(false); const [resetSent, setResetSent] = useState(false);
const dialogRef = useModalA11y(); const dialogRef = useModalA11y();
const fieldId = useId(); const fieldId = useId();
@ -232,22 +235,38 @@ export default function AuthModal({
> >
{t('auth.password')} {t('auth.password')}
</label> </label>
<input <div className="relative">
id={passwordInputId} <input
name="password" id={passwordInputId}
type="password" name="password"
value={password} type={showPassword ? 'text' : 'password'}
onChange={(e) => setPassword(e.target.value)} value={password}
required onChange={(e) => setPassword(e.target.value)}
minLength={8} required
autoComplete={view === 'register' ? 'new-password' : 'current-password'} minLength={8}
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" autoComplete={view === 'register' ? 'new-password' : 'current-password'}
placeholder={ 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"
view === 'register' placeholder={
? t('auth.passwordPlaceholderRegister') view === 'register'
: t('auth.passwordPlaceholderLogin') ? 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' && ( {view === 'login' && (
<button <button
type="button" type="button"

View file

@ -37,7 +37,8 @@ export type Page =
| 'privacy' | 'privacy'
| 'account' | 'account'
| 'saved' | 'saved'
| 'invite'; | 'invite'
| 'reset-password';
export interface HeaderExportState { export interface HeaderExportState {
onExport: (options?: { postcodes?: string[] }) => void; onExport: (options?: { postcodes?: string[] }) => void;
@ -65,6 +66,7 @@ export const PAGE_PATHS: Record<Page, string> = {
saved: '/saved', saved: '/saved',
account: '/account', account: '/account',
invite: '/invite', invite: '/invite',
'reset-password': '/reset-password',
}; };
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)'; const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
@ -206,33 +208,6 @@ export default function Header({
return ( return (
<> <>
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0"> <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 */} {/* Left: Logo + nav */}
<div className="flex min-w-0 items-center gap-4"> <div className="flex min-w-0 items-center gap-4">
<a <a
@ -246,9 +221,9 @@ export default function Header({
</span> </span>
</a> </a>
{/* Desktop nav: hidden while the saved-search "is being updated" banner {/* Desktop nav: hidden while the saved-search "is being updated" bar is
is shown so the centered pointer-events-auto banner can't overlap (and shown so the centered edit bar has room and the header can't get
block clicks on) the Invite Friends / Learn / Pricing links at ~1366px. */} cramped (the bar would otherwise crowd the Learn / Pricing links). */}
{!useSidebarNav && !showEditingBar && ( {!useSidebarNav && !showEditingBar && (
<nav className="top-menu flex items-center"> <nav className="top-menu flex items-center">
<a <a
@ -287,6 +262,36 @@ export default function Header({
)} )}
</div> </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 */} {/* Right side */}
<div className="ml-auto flex shrink-0 items-center gap-2"> <div className="ml-auto flex shrink-0 items-center gap-2">
{/* Desktop-only dashboard actions: shown to everyone; a logged-out click {/* Desktop-only dashboard actions: shown to everyone; a logged-out click

View file

@ -4,16 +4,30 @@ interface PillGroupProps {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
wrap?: boolean; wrap?: boolean;
/**
* Widen the scroll area by the 16px of filter-card padding on each side, and re-add it as
* inner padding. Pills rest where they did, but scroll to the sidebar edge before clipping.
*/
bleed?: boolean;
} }
export function PillGroup({ children, className = '', wrap = false }: PillGroupProps) { const SCROLL =
'flex-nowrap overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible';
export function PillGroup({
children,
className = '',
wrap = false,
bleed = false,
}: PillGroupProps) {
// max-w-full would clamp a bleeding group back to the padded width, undoing the negative margin.
const scroll = bleed
? `${SCROLL} -mx-4 px-4 md:mx-0 md:max-w-full md:px-0`
: `${SCROLL} max-w-full`;
return ( return (
<div <div
className={`flex min-w-0 max-w-full gap-1.5 ${ className={`flex min-w-0 gap-1.5 ${wrap ? 'max-w-full flex-wrap overflow-x-visible' : scroll} ${className}`}
wrap
? 'flex-wrap overflow-x-visible'
: 'flex-nowrap overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible'
} ${className}`}
> >
{children} {children}
</div> </div>

View file

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

View file

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

View file

@ -32,6 +32,16 @@ function authRefreshInvalidated(error: unknown): boolean {
return status === 401 || status === 403; 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() { export function useAuth() {
const { t } = useTranslation(); const { t } = useTranslation();
const [user, setUser] = useState<AuthUser | null>(() => { const [user, setUser] = useState<AuthUser | null>(() => {
@ -62,7 +72,9 @@ export function useAuth() {
setError(null); setError(null);
setErrorAction(null); setErrorAction(null);
try { try {
const result = await pb.collection('users').authWithPassword(email, password); const result = await pb
.collection('users')
.authWithPassword(normalizeEmail(email), password);
setUser(recordToUser(result.record)); setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' }); trackEvent('Login', { method: 'email' });
} catch (err) { } catch (err) {
@ -82,12 +94,13 @@ export function useAuth() {
setLoading(true); setLoading(true);
setError(null); setError(null);
setErrorAction(null); setErrorAction(null);
const normalizedEmail = normalizeEmail(email);
try { try {
// Step 1: create the account. A failure here means nothing was created, // Step 1: create the account. A failure here means nothing was created,
// so surface a friendly, field-aware message (e.g. "email already exists"). // so surface a friendly, field-aware message (e.g. "email already exists").
try { try {
await pb.collection('users').create({ await pb.collection('users').create({
email, email: normalizedEmail,
password, password,
passwordConfirm: password, passwordConfirm: password,
newsletter: true, newsletter: true,
@ -103,7 +116,7 @@ export function useAuth() {
// orphaned: tell the user it was created and let them log in with the // orphaned: tell the user it was created and let them log in with the
// same credentials, instead of a misleading "registration failed". // same credentials, instead of a misleading "registration failed".
try { try {
const result = await pb.collection('users').authWithPassword(email, password); const result = await pb.collection('users').authWithPassword(normalizedEmail, password);
setUser(recordToUser(result.record)); setUser(recordToUser(result.record));
trackEvent('Register'); trackEvent('Register');
} catch (err) { } catch (err) {
@ -159,7 +172,7 @@ export function useAuth() {
setError(null); setError(null);
setErrorAction(null); setErrorAction(null);
try { try {
await pb.collection('users').requestPasswordReset(email); await pb.collection('users').requestPasswordReset(normalizeEmail(email));
} catch (err) { } catch (err) {
const { message, action } = resolveAuthError(err, 'reset', t); const { message, action } = resolveAuthError(err, 'reset', t);
setError(message); setError(message);
@ -172,6 +185,27 @@ export function useAuth() {
[t] [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 () => { const refreshAuth = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -208,6 +242,7 @@ export function useAuth() {
loginWithOAuth, loginWithOAuth,
logout, logout,
requestPasswordReset, requestPasswordReset,
confirmPasswordReset,
refreshAuth, refreshAuth,
clearError, clearError,
}; };

View file

@ -14,8 +14,10 @@ function currentUserId(): string | null {
* *
* Reads the user from the shared PocketBase authStore so it stays self-contained, with no need to * 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 * 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 * the map's colour accessor; a fresh Set instance is produced on every change so React re-renders.
* as a deck.gl `updateTrigger` (identity-compared). * 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 * 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. * the instant it is clicked, independent of the PocketBase round-trip or the next listings fetch.
@ -54,8 +56,11 @@ export function useClickedListings() {
.filter((url): url is string => typeof url === 'string'); .filter((url): url is string => typeof url === 'string');
setClickedUrls(new Set(urls)); setClickedUrls(new Set(urls));
}) })
.catch(() => { .catch((error) => {
// Visited history is a convenience; a failed load just starts the session empty. // Visited history is a convenience; a failed load just starts the session empty. Still
// worth surfacing: a silent failure here is indistinguishable from "user visited nothing",
// which is how a proxy 404 on this collection went unnoticed for weeks.
console.warn('Failed to load visited listings:', error);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
@ -72,9 +77,12 @@ export function useClickedListings() {
if (!uid) return; // anonymous: recolour for the session, nothing to persist if (!uid) return; // anonymous: recolour for the session, nothing to persist
pb.collection(CLICKED_LISTINGS_COLLECTION) pb.collection(CLICKED_LISTINGS_COLLECTION)
.create({ user: uid, url }) .create({ user: uid, url })
.catch(() => { .catch((error: unknown) => {
// Ignore duplicate-index conflicts and transient failures; local state already // A 400 is the idx_clicked_listings_user_url unique index rejecting a re-click that raced
// reflects the click, and a missed write only means it won't persist to the next visit. // another tab: local state already reflects it, so it is expected and not worth reporting.
// Anything else means the click will not survive a reload, which the user does notice.
if ((error as { status?: number })?.status === 400) return;
console.warn('Failed to persist visited listing:', error);
}); });
}, []); }, []);

View file

@ -93,23 +93,23 @@ interface UseFiltersOptions {
onFilterLimitReached?: () => void; 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 { function normalizeFilters(filters: FeatureFilters): FeatureFilters {
return normalizePoiDistanceFilters( return FILTER_NORMALIZERS.reduce((acc, normalize) => normalize(acc), filters);
// Council folds AFTER tenure so a bare "% Social rent" is claimed by tenure.
normalizeCouncilFilters(
normalizeTenureFilters(
normalizeQualificationFilters(
normalizeEthnicityFilters(
normalizeElectionVoteShareFilters(
normalizeCrimeSeverityFilters(
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
)
)
)
)
)
)
);
} }
function getBackendFeatureName(name: string): string { function getBackendFeatureName(name: string): string {
@ -215,7 +215,10 @@ export function useFilters({
const normalizedOriginal = normalizeFilters(initialFilters); const normalizedOriginal = normalizeFilters(initialFilters);
originalNormalizedRef.current = normalizedOriginal; originalNormalizedRef.current = normalizedOriginal;
const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null; const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null;
initialFiltersRef.current = clampToBudget(dropUnknownFilters(normalizedOriginal, features), budget); initialFiltersRef.current = clampToBudget(
dropUnknownFilters(normalizedOriginal, features),
budget
);
} }
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!); const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);

View file

@ -385,7 +385,9 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
highlightColor: [29, 228, 195, 220], highlightColor: [29, 228, 195, 220],
onHover: stableHover, onHover: stableHover,
onClick: stableClick, 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] [visibleListings, clickedUrls, stableHover, stableClick]
); );
@ -483,7 +485,9 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
highlightColor: [29, 228, 195, 220], highlightColor: [29, 228, 195, 220],
onHover: stableExpandedHover, onHover: stableExpandedHover,
onClick: stableExpandedClick, 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] [expandedListings, clickedUrls, stableExpandedHover, stableExpandedClick]
); );

View file

@ -42,7 +42,10 @@ async function detectSessionRejection(
onSessionRejected?: () => void onSessionRejected?: () => void
): Promise<void> { ): Promise<void> {
if (res.status !== 400 || !onSessionRejected || !pb.authStore.isValid) return; if (res.status !== 400 || !onSessionRejected || !pb.authStore.isValid) return;
const body = await res.clone().json().catch(() => null); const body = await res
.clone()
.json()
.catch(() => null);
if (body && (body as { error?: string }).error === 'filter_limit') { if (body && (body as { error?: string }).error === 'filter_limit') {
onSessionRejected(); onSessionRejected();
} }

View file

@ -4,7 +4,7 @@ import { apiUrl, logNonAbortError, authHeaders } from '../lib/api';
const DEBOUNCE_MS = 150; const DEBOUNCE_MS = 150;
export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string>) { export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string>, enabled = true) {
const [pois, setPois] = useState<POI[]>([]); const [pois, setPois] = useState<POI[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@ -14,7 +14,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
requestIdRef.current += 1; requestIdRef.current += 1;
const requestId = requestIdRef.current; const requestId = requestIdRef.current;
if (!bounds || selectedCategories.size === 0) { if (!bounds || selectedCategories.size === 0 || !enabled) {
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
setPois([]); setPois([]);
return; return;
@ -58,7 +58,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
} }
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
}; };
}, [bounds, selectedCategories]); }, [bounds, selectedCategories, enabled]);
return pois; return pois;
} }

View file

@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { POI } from '../types'; import type { POI } from '../types';
import { POI_ZOOM_THRESHOLD } from '../lib/consts';
import { usePoiLayers } from './usePoiLayers'; import { usePoiLayers } from './usePoiLayers';
vi.mock('react-i18next', () => ({ vi.mock('react-i18next', () => ({
@ -150,9 +151,11 @@ describe('usePoiLayers', () => {
}); });
it('hides minor POI categories until the configured zoom threshold', () => { it('hides minor POI categories until the configured zoom threshold', () => {
// Starts at POI_ZOOM_THRESHOLD: past the gate that hides every POI, so this
// isolates the minor-category filter rather than the global one.
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ zoom }) => usePoiLayers({ pois: [busStop], zoom, isDark: false }), ({ zoom }) => usePoiLayers({ pois: [busStop], zoom, isDark: false }),
{ initialProps: { zoom: 13 } } { initialProps: { zoom: POI_ZOOM_THRESHOLD } }
); );
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]); expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
@ -164,6 +167,26 @@ describe('usePoiLayers', () => {
expect(result.current.visiblePois).toEqual([busStop]); expect(result.current.visiblePois).toEqual([busStop]);
}); });
it('hides every POI and cluster below POI_ZOOM_THRESHOLD', () => {
const neighbour: POI = { ...supermarket, id: 'neighbour', name: 'Neighbour', lat: 51.5001 };
const { result, rerender } = renderHook(
({ zoom }) => usePoiLayers({ pois: [supermarket, neighbour], zoom, isDark: false }),
{ initialProps: { zoom: POI_ZOOM_THRESHOLD - 0.1 } }
);
// Zoomed out the POIs go away entirely, rather than collapsing into the
// cluster bubbles they used to leave behind.
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
expect(layerById(result.current.poiLayers, 'poi-clusters').props.data).toEqual([]);
expect(result.current.visiblePois).toEqual([]);
rerender({ zoom: POI_ZOOM_THRESHOLD });
const shown = layerById(result.current.poiLayers, 'poi-background').props.data as POI[];
const clusters = layerById(result.current.poiLayers, 'poi-clusters').props.data as unknown[];
expect(shown.length + clusters.length).toBeGreaterThan(0);
});
it('keeps POI hover popup state in sync with layer hover events', () => { it('keeps POI hover popup state in sync with layer hover events', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false }) usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
@ -248,8 +271,10 @@ describe('usePoiLayers', () => {
lng: -0.12 - index * 0.0001, lng: -0.12 - index * 0.0001,
}) })
); );
// Zoom 14 sits in the band where POIs are visible but still cluster
// (POI_ZOOM_THRESHOLD <= zoom <= POI_CLUSTER_MAX_ZOOM).
const { result } = renderHook(() => const { result } = renderHook(() =>
usePoiLayers({ pois: clusteredPois, zoom: 5, isDark: true }) usePoiLayers({ pois: clusteredPois, zoom: 14, isDark: true })
); );
const clusterLayer = layerById(result.current.poiLayers, 'poi-clusters'); const clusterLayer = layerById(result.current.poiLayers, 'poi-clusters');
const clusters = clusterLayer.props.data as Array<{ count: number; lng: number; lat: number }>; const clusters = clusterLayer.props.data as Array<{ count: number; lng: number; lat: number }>;

View file

@ -11,6 +11,7 @@ import {
MINOR_POI_ZOOM_THRESHOLD, MINOR_POI_ZOOM_THRESHOLD,
POI_CLUSTER_RADIUS, POI_CLUSTER_RADIUS,
POI_CLUSTER_MAX_ZOOM, POI_CLUSTER_MAX_ZOOM,
POI_ZOOM_THRESHOLD,
} from '../lib/consts'; } from '../lib/consts';
import { getPoiIconUrl } from '../lib/map-utils'; import { getPoiIconUrl } from '../lib/map-utils';
@ -149,9 +150,14 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
const clusterZoom = Math.floor(zoom); const clusterZoom = Math.floor(zoom);
const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD; const showMinorPois = zoom >= MINOR_POI_ZOOM_THRESHOLD;
// Zoomed out the POIs go away entirely rather than leaving cluster bubbles
// behind. This reads the same live view zoom OverlayTileLayers gates on, so
// while POI_ZOOM_THRESHOLD matches the overlay limit both vanish on the same
// frame.
const poisZoomedIn = zoom >= POI_ZOOM_THRESHOLD;
const { visiblePois, clusters } = useMemo(() => { const { visiblePois, clusters } = useMemo(() => {
if (!clusterIndex || pois.length === 0) { if (!clusterIndex || pois.length === 0 || !poisZoomedIn) {
return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] }; return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] };
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -173,7 +179,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
} }
} }
return { visiblePois: individual, clusters: clusterPoints }; return { visiblePois: individual, clusters: clusterPoints };
}, [clusterIndex, clusterZoom, showMinorPois, pois]); }, [clusterIndex, clusterZoom, showMinorPois, poisZoomedIn, pois]);
const poiShadowLayer = useMemo( const poiShadowLayer = useMemo(
() => () =>

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import pb from '../lib/pocketbase'; import pb from '../lib/pocketbase';
import { apiUrl, authHeaders } from '../lib/api'; import { apiUrl, authHeaders } from '../lib/api';
import { trackEvent } from '../lib/analytics'; import { trackEvent } from '../lib/analytics';
import { stripSelectedPostcodeParam } from '../lib/url-state';
export interface SavedSearch { export interface SavedSearch {
id: string; id: string;
@ -141,7 +142,11 @@ export function useSavedSearches(userId: string | null) {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { 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 // Create record immediately without screenshot
const formData = new FormData(); const formData = new FormData();
@ -220,11 +225,14 @@ export function useSavedSearches(userId: string | null) {
); );
const updateSearchParams = useCallback( const updateSearchParams = useCallback(
async (id: string, params: string) => { async (id: string, rawParams: string) => {
if (!userId) return; if (!userId) return;
setSaving(true); setSaving(true);
setError(null); setError(null);
try { 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 }); const record = await pb.collection('saved_searches').update(id, { params });
trackEvent('Search Update'); trackEvent('Search Update');
setSearches((prev) => setSearches((prev) =>

View file

@ -97,7 +97,8 @@ const descriptions: Record<string, Record<string, string>> = {
'% Private rent': 'Part des ménages locataires dans le privé ou logés à titre gratuit', '% Private rent': 'Part des ménages locataires dans le privé ou logés à titre gratuit',
'% Council housing': '% Council housing':
'Part des logements du code postal déjà enregistrés comme logement municipal ou social', 'Part des logements du code postal déjà enregistrés comme logement municipal ou social',
'% Ex-council': 'Part des logements du code postal autrefois municipaux mais qui ne le sont plus', '% Ex-council':
'Part des logements du code postal autrefois municipaux mais qui ne le sont plus',
'% White': 'Part de la population sidentifiant comme blanche', '% White': 'Part de la population sidentifiant comme blanche',
'% South Asian': 'Part de la population sidentifiant comme sud-asiatique', '% South Asian': 'Part de la population sidentifiant comme sud-asiatique',
'% Black': 'Part de la population sidentifiant comme noire', '% Black': 'Part de la population sidentifiant comme noire',

View file

@ -42,7 +42,7 @@ const de: Translations = {
minute: 'Min.', minute: 'Min.',
or: 'oder', or: 'oder',
area: 'Gebiet', area: 'Gebiet',
properties: 'Immobilien', properties: 'Verkaufte Immobilien',
postcode: 'Postcode', postcode: 'Postcode',
noAreaSelected: 'Kein Gebiet ausgewählt', noAreaSelected: 'Kein Gebiet ausgewählt',
noAreaSelectedDesc: noAreaSelectedDesc:
@ -631,6 +631,8 @@ const de: Translations = {
email: 'E-Mail', email: 'E-Mail',
emailPlaceholder: 'name@beispiel.de', emailPlaceholder: 'name@beispiel.de',
password: 'Passwort', password: 'Passwort',
showPassword: 'Passwort anzeigen',
hidePassword: 'Passwort verbergen',
passwordPlaceholderRegister: 'Mind. 8 Zeichen', passwordPlaceholderRegister: 'Mind. 8 Zeichen',
passwordPlaceholderLogin: 'Dein Passwort', passwordPlaceholderLogin: 'Dein Passwort',
forgotPassword: 'Passwort vergessen?', forgotPassword: 'Passwort vergessen?',
@ -644,6 +646,16 @@ const de: Translations = {
registrationFailed: 'Registrierung fehlgeschlagen', registrationFailed: 'Registrierung fehlgeschlagen',
oauthFailed: 'OAuth-Anmeldung fehlgeschlagen', oauthFailed: 'OAuth-Anmeldung fehlgeschlagen',
passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts 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 ────────────────────────────────── // ── Upgrade Modal ──────────────────────────────────
@ -951,6 +963,10 @@ const de: Translations = {
viewProperties: '{{count}} Immobilien ansehen', viewProperties: '{{count}} Immobilien ansehen',
viewPropertiesShort: 'Immobilien ansehen', viewPropertiesShort: 'Immobilien ansehen',
priceHistory: 'Preisentwicklung', priceHistory: 'Preisentwicklung',
priceHistoryThisArea: 'Dieses Gebiet',
priceMetric: 'Preisbasis',
priceMetricTotal: 'Gesamt',
priceMetricPerSqm: 'Pro m²',
journeysFrom: 'Fahrzeiten für {{label}}', journeysFrom: 'Fahrzeiten für {{label}}',
to: 'Nach {{destination}}', to: 'Nach {{destination}}',
noJourneyData: 'Keine Verbindungsdaten verfügbar', noJourneyData: 'Keine Verbindungsdaten verfügbar',
@ -996,6 +1012,8 @@ const de: Translations = {
'Daten von OpenStreetMap, NaPTAN und GEOLYTIX Grocery Retail Points. Umfasst Haltestellen, Geschäfte, Supermarktketten, Restaurants, Gesundheitsangebote, Freizeit und mehr.', 'Daten von OpenStreetMap, NaPTAN und GEOLYTIX Grocery Retail Points. Umfasst Haltestellen, Geschäfte, Supermarktketten, Restaurants, Gesundheitsangebote, Freizeit und mehr.',
searchCategories: 'Kategorien durchsuchen...', searchCategories: 'Kategorien durchsuchen...',
dataSourceInfo: 'Datenquelleninfo', dataSourceInfo: 'Datenquelleninfo',
zoomWarning: 'Zoome weiter hinein, um die ausgewählte Kategorie zu sehen.',
zoomWarning_other: 'Zoome weiter hinein, um die ausgewählten Kategorien zu sehen.',
}, },
// ── External Search Links ────────────────────────── // ── External Search Links ──────────────────────────
@ -1025,7 +1043,6 @@ const de: Translations = {
// ── Home Page ────────────────────────────────────── // ── Home Page ──────────────────────────────────────
home: { home: {
heroEyebrow: 'Für Käufer, die nicht für den Ruf eines Postcodes zu viel zahlen wollen',
heroTitle1: 'Finde den', heroTitle1: 'Finde den',
heroTitle2: 'Geheimtipp-Postcode', heroTitle2: 'Geheimtipp-Postcode',
heroTitle3: 'Gleiche Schulen, gleicher Pendelweg, und still günstiger.', heroTitle3: 'Gleiche Schulen, gleicher Pendelweg, und still günstiger.',
@ -1094,8 +1111,6 @@ const de: Translations = {
statFilters: 'kombinierbare Filter', statFilters: 'kombinierbare Filter',
statEvery: 'Jeder', statEvery: 'Jeder',
statPostcodeInEngland: 'Postcode in England', statPostcodeInEngland: 'Postcode in England',
coverageNote:
'Deckt jeden Postcode in England ab, je 200+ Datenfelder. Schottland und Wales sind in Planung.',
priceStrip: priceStrip:
'Einmal zahlen für lebenslangen Zugang: aktuell {{price}}, steigend, sobald sich jede Stufe füllt.', 'Einmal zahlen für lebenslangen Zugang: aktuell {{price}}, steigend, sobald sich jede Stufe füllt.',
priceStripSpots: 'Noch {{count}} Platz zu diesem Preis.', priceStripSpots: 'Noch {{count}} Platz zu diesem Preis.',
@ -1576,6 +1591,10 @@ const de: Translations = {
listing: 'Inserat', listing: 'Inserat',
showingOf: '{{visible}} von {{count}} werden angezeigt', showingOf: '{{visible}} von {{count}} werden angezeigt',
groupedNear: 'Gruppiert an dieser Kartenposition', groupedNear: 'Gruppiert an dieser Kartenposition',
priceHistory: 'Preisverlauf',
priceListed: 'Gelistet',
priceReduced: 'Reduziert',
priceIncreased: 'Erhöht',
}, },
overlays: { overlays: {
@ -1896,6 +1915,7 @@ const de: Translations = {
'Tube station': 'U-Bahn-Station', 'Tube station': 'U-Bahn-Station',
'DLR station': 'DLR-Station', 'DLR station': 'DLR-Station',
'Tram & Metro stop': 'Tram- & Metro-Haltestelle', 'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
'Any station': 'Beliebige Station',
Café: 'Café', Café: 'Café',
Restaurant: 'Restaurant', Restaurant: 'Restaurant',
Pub: 'Pub', Pub: 'Pub',

View file

@ -38,7 +38,7 @@ const en = {
minute: 'min', minute: 'min',
or: 'or', or: 'or',
area: 'Area', area: 'Area',
properties: 'Properties', properties: 'Sold properties',
postcode: 'Postcode', postcode: 'Postcode',
noAreaSelected: 'No area selected', noAreaSelected: 'No area selected',
noAreaSelectedDesc: noAreaSelectedDesc:
@ -618,6 +618,8 @@ const en = {
email: 'Email', email: 'Email',
emailPlaceholder: 'you@example.com', emailPlaceholder: 'you@example.com',
password: 'Password', password: 'Password',
showPassword: 'Show password',
hidePassword: 'Hide password',
passwordPlaceholderRegister: 'Min 8 characters', passwordPlaceholderRegister: 'Min 8 characters',
passwordPlaceholderLogin: 'Your password', passwordPlaceholderLogin: 'Your password',
forgotPassword: 'Forgot password?', forgotPassword: 'Forgot password?',
@ -631,6 +633,15 @@ const en = {
registrationFailed: 'Registration failed', registrationFailed: 'Registration failed',
oauthFailed: 'OAuth login failed', oauthFailed: 'OAuth login failed',
passwordResetFailed: 'Password reset request 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 ────────────────────────────────── // ── Upgrade Modal ──────────────────────────────────
@ -939,6 +950,10 @@ const en = {
viewProperties: 'View {{count}} properties', viewProperties: 'View {{count}} properties',
viewPropertiesShort: 'View properties', viewPropertiesShort: 'View properties',
priceHistory: 'Price History', priceHistory: 'Price History',
priceHistoryThisArea: 'This area',
priceMetric: 'Price basis',
priceMetricTotal: 'Total',
priceMetricPerSqm: 'Per m²',
journeysFrom: 'Journey times for {{label}}', journeysFrom: 'Journey times for {{label}}',
to: 'To {{destination}}', to: 'To {{destination}}',
noJourneyData: 'No journey data available', noJourneyData: 'No journey data available',
@ -984,6 +999,8 @@ const en = {
'Sourced from OpenStreetMap, NaPTAN, and GEOLYTIX Grocery Retail Points. Covers transport stops, shops, chain supermarkets, restaurants, healthcare, leisure, and more.', 'Sourced from OpenStreetMap, NaPTAN, and GEOLYTIX Grocery Retail Points. Covers transport stops, shops, chain supermarkets, restaurants, healthcare, leisure, and more.',
searchCategories: 'Search categories…', searchCategories: 'Search categories…',
dataSourceInfo: 'Data source info', dataSourceInfo: 'Data source info',
zoomWarning: 'Zoom in further to see the selected category.',
zoomWarning_other: 'Zoom in further to see the selected categories.',
}, },
// ── External Search Links ────────────────────────── // ── External Search Links ──────────────────────────
@ -1013,7 +1030,6 @@ const en = {
// ── Home Page ────────────────────────────────────── // ── Home Page ──────────────────────────────────────
home: { home: {
heroEyebrow: 'For buyers who refuse to overpay for a postcodes reputation',
heroTitle1: 'Find the', heroTitle1: 'Find the',
heroTitle2: 'hidden-gem postcode', heroTitle2: 'hidden-gem postcode',
heroTitle3: 'Same schools, same commute, quietly cheaper.', heroTitle3: 'Same schools, same commute, quietly cheaper.',
@ -1080,8 +1096,6 @@ const en = {
statFilters: 'combinable filters', statFilters: 'combinable filters',
statEvery: 'Every', statEvery: 'Every',
statPostcodeInEngland: 'postcode in England', statPostcodeInEngland: 'postcode in England',
coverageNote:
'Covers every postcode in England, with 200+ data fields each. Scotland & Wales are on the roadmap.',
priceStrip: 'Pay once for lifetime access, currently {{price}}, rising as each tier fills.', priceStrip: 'Pay once for lifetime access, currently {{price}}, rising as each tier fills.',
priceStripSpots: '{{count}} spot left at this price.', priceStripSpots: '{{count}} spot left at this price.',
priceStripSpotsPlural: '{{count}} spots left at this price.', priceStripSpotsPlural: '{{count}} spots left at this price.',
@ -1556,6 +1570,10 @@ const en = {
listing: 'Listing', listing: 'Listing',
showingOf: 'Showing {{visible}} of {{count}}', showingOf: 'Showing {{visible}} of {{count}}',
groupedNear: 'Grouped near this map position', groupedNear: 'Grouped near this map position',
priceHistory: 'Price history',
priceListed: 'Listed',
priceReduced: 'Reduced',
priceIncreased: 'Increased',
}, },
// ── Map Overlays Pane ────────────────────────────── // ── Map Overlays Pane ──────────────────────────────
@ -1878,6 +1896,7 @@ const en = {
'Tube station': 'Tube station', 'Tube station': 'Tube station',
'DLR station': 'DLR station', 'DLR station': 'DLR station',
'Tram & Metro stop': 'Tram & Metro stop', 'Tram & Metro stop': 'Tram & Metro stop',
'Any station': 'Any station',
Café: 'Café', Café: 'Café',
Restaurant: 'Restaurant', Restaurant: 'Restaurant',
Pub: 'Pub', Pub: 'Pub',

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