Compare commits
27 commits
fix/audit-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| acacd5982a | |||
| 4428d6c50a | |||
| 75c18a8eb4 | |||
| 4cc588cac4 | |||
| 362b96b8ee | |||
| df02ffd9c0 | |||
| b47c9ba1ec | |||
| bce34b73de | |||
| 35276d34fa | |||
| a03e9633df | |||
| bf3f09aeb6 | |||
| 68097386df | |||
| 988c01c4f7 | |||
| 716e42a57d | |||
| e544b946c9 | |||
| 56b5d9f7df | |||
| d78a301028 | |||
| d1faad314b | |||
| cf348c3ea4 | |||
| f14981afee | |||
| 61114833b7 | |||
| f4d1b58bd8 | |||
| d7f844d566 | |||
| ca771a7edf | |||
| 9e4e65fa2a | |||
| 6df2812a4e | |||
| f948efc06c |
158 changed files with 6275 additions and 2757 deletions
|
|
@ -23,6 +23,10 @@ jobs:
|
|||
- name: Install Python dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Install finder (scraper) dependencies
|
||||
working-directory: finder
|
||||
run: uv sync
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -27,5 +27,5 @@ video/auth.*
|
|||
r5-java/tmp
|
||||
property-data
|
||||
property-data-snapshot
|
||||
property-data-snapshot2
|
||||
property-data-snapshot*
|
||||
video/.audit*
|
||||
|
|
|
|||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1.1
|
||||
|
|
@ -18,7 +18,6 @@ from __future__ import annotations
|
|||
|
||||
import html
|
||||
import json
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(".")
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ def twin_kit(f: dict) -> str:
|
|||
]
|
||||
captions = [
|
||||
f"{pn} vs {wn}",
|
||||
f"Same station. Same schools.",
|
||||
"Same station. Same schools.",
|
||||
f"{money} cheaper",
|
||||
f"Same {typ}, ~{s['build_year']}",
|
||||
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, ~45–60s 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}`",
|
||||
"*(filters are pre-applied so the value is on screen immediately)*",
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -139,9 +139,6 @@ def curate(tw: pl.DataFrame) -> 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"]
|
||||
dear = facts["dearest_sector"]
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -315,7 +315,8 @@
|
|||
"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.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.legend()\n",
|
||||
"\n",
|
||||
|
|
@ -323,9 +324,11 @@
|
|||
"ax2.bar(yr, piv[\"premium_pct\"].to_numpy(), color=colors)\n",
|
||||
"ax2.axhline(0, color=\"black\", lw=0.8)\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",
|
||||
"plt.tight_layout(); plt.show()\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()\n",
|
||||
"\n",
|
||||
"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",
|
||||
" 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",
|
||||
"plt.tight_layout(); plt.show()\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()\n",
|
||||
"area_prem"
|
||||
]
|
||||
},
|
||||
|
|
@ -523,10 +527,12 @@
|
|||
"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.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.legend()\n",
|
||||
"plt.tight_layout(); plt.show()"
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -603,16 +609,19 @@
|
|||
"\n",
|
||||
"area_cagr = cagr_by(\"area\")\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",
|
||||
"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.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_ylabel(\"CAGR % per year\")\n",
|
||||
"ax.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
|
||||
"ax.legend()\n",
|
||||
"plt.tight_layout(); plt.show()\n",
|
||||
"plt.tight_layout()\n",
|
||||
"plt.show()\n",
|
||||
"area_cagr"
|
||||
]
|
||||
},
|
||||
|
|
@ -699,22 +708,25 @@
|
|||
" area_cagr.select(\"area\", \"cagr_gap\", \"newbuild_cagr\", \"existing_cagr\"), on=\"area\", how=\"inner\"\n",
|
||||
")\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",
|
||||
"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.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_ylabel(\"New-build CAGR minus existing CAGR (pp/yr)\")\n",
|
||||
"ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\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",
|
||||
"# Headline numbers for the writeup.\n",
|
||||
"hl = summary.to_dicts()\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",
|
||||
"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\" 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",
|
||||
|
|
|
|||
8
check.sh
8
check.sh
|
|
@ -14,6 +14,14 @@ step "Python lint: ruff" uv run ruff check .
|
|||
step "Python dependency lint: deptry" uv run deptry .
|
||||
step "Python unit tests" uv run pytest pipeline
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR/finder"
|
||||
# finder is a separate uv project (the scraper) with its own venv, so the
|
||||
# root `pytest pipeline` run above never reaches it. pytest is not a declared
|
||||
# finder dependency, so pull it in ephemerally as its README documents.
|
||||
step "Finder (scraper) unit tests" uv run --with pytest pytest -q
|
||||
)
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR/frontend"
|
||||
step "Frontend lint: ESLint" npm run lint
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ services:
|
|||
command: >
|
||||
bash -c "
|
||||
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:
|
||||
- "8001:8001"
|
||||
|
|
@ -30,6 +30,13 @@ services:
|
|||
- cargo-home:/usr/local/cargo
|
||||
- cargo-target:/app/server-rs/target
|
||||
environment:
|
||||
# Disable incremental compilation. cargo-watch does incremental
|
||||
# rebuilds over the mounted source; editing a struct with a generic
|
||||
# serde impl mid-flight can corrupt the incremental cache and produce
|
||||
# `rust-lld: undefined hidden symbol ...::serialize` link failures
|
||||
# that never resolve until a clean rebuild. Off = slightly slower
|
||||
# warm rebuilds, but no such corruption.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
# Fallback only: the binary uses jemalloc as its global allocator
|
||||
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
|
||||
MALLOC_ARENA_MAX: "2"
|
||||
|
|
@ -57,7 +64,7 @@ services:
|
|||
BUGSINK_ENVIRONMENT: ${BUGSINK_ENVIRONMENT:-development}
|
||||
BUGSINK_RELEASE: ${BUGSINK_RELEASE:-}
|
||||
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}
|
||||
depends_on:
|
||||
screenshot:
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@ Environment variables (override the defaults in `constants.py`):
|
|||
| `ZOOPLA_OUTCODE_TIMEOUT_SECONDS` | `300` | Per-outcode wall-clock budget for Zoopla. |
|
||||
| `DETAIL_FETCH_CONCURRENCY` | `8` | Parallel detail fetches (Rightmove/OTM). |
|
||||
| `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). |
|
||||
|
||||
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. |
|
||||
| `rightmove.py` / `onthemarket.py` / `zoopla.py` | Per-portal search + detail scraping and parsing. |
|
||||
| `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. |
|
||||
| `spatial.py` | Grid spatial index for coordinate → nearest postcode. |
|
||||
| `storage.py` | Parquet writer (server-ready column names). |
|
||||
|
|
|
|||
|
|
@ -25,6 +25,32 @@ RETRY_BASE_DELAY = 2.0
|
|||
# down if the portals start returning 429/403.
|
||||
DETAIL_FETCH_CONCURRENCY = int(os.environ.get("DETAIL_FETCH_CONCURRENCY", "8"))
|
||||
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
|
||||
MAX_BEDROOMS = 20 # sanity cap: values above this are almost certainly parsing errors
|
||||
|
||||
|
|
|
|||
130
finder/gluetun.py
Normal file
130
finder/gluetun.py
Normal 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
|
||||
|
|
@ -2,12 +2,16 @@ import logging
|
|||
import random
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from fake_useragent import UserAgent
|
||||
|
||||
import gluetun
|
||||
import shutdown
|
||||
from constants import (
|
||||
BLOCK_403_THRESHOLD,
|
||||
BLOCK_MAX_ROTATIONS,
|
||||
GLUETUN_PROXY,
|
||||
MAX_RETRIES,
|
||||
REQUESTS_PER_SECOND,
|
||||
|
|
@ -17,6 +21,16 @@ from constants import (
|
|||
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:
|
||||
"""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.
|
||||
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(
|
||||
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(
|
||||
client: httpx.Client, url: str, params: dict | None = None, on_403: bool = True
|
||||
) -> 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
|
||||
compatibility with older callers; 403 is now treated as non-retryable.
|
||||
Returns None on permanent failure, EXCEPT when the host is refusing our
|
||||
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():
|
||||
return None
|
||||
try:
|
||||
RATE_LIMITER.acquire()
|
||||
resp = client.get(url, params=params)
|
||||
if resp.status_code == 200:
|
||||
BLOCK_TRACKER.record_success(host)
|
||||
return resp.json()
|
||||
if resp.status_code == 403 and on_403:
|
||||
log.error("HTTP 403 from %s (forbidden)", url)
|
||||
return None
|
||||
action = BLOCK_TRACKER.record_403(host)
|
||||
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):
|
||||
delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1)
|
||||
log.warning(
|
||||
|
|
@ -95,6 +203,7 @@ def fetch_with_retry(
|
|||
MAX_RETRIES,
|
||||
delay,
|
||||
)
|
||||
attempt += 1
|
||||
shutdown.sleep(delay)
|
||||
continue
|
||||
log.error("HTTP %d from %s (non-retryable)", resp.status_code, url)
|
||||
|
|
@ -114,6 +223,7 @@ def fetch_with_retry(
|
|||
MAX_RETRIES,
|
||||
delay,
|
||||
)
|
||||
attempt += 1
|
||||
shutdown.sleep(delay)
|
||||
log.error("All %d retries exhausted for %s", MAX_RETRIES, url)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -218,6 +218,13 @@ def main() -> int:
|
|||
return 130 # 128 + SIGINT, the conventional Ctrl+C exit code.
|
||||
log.info("Scrape finished in %.1fs", elapsed)
|
||||
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"):
|
||||
raise SystemExit("Test scrape failed; see errors in the result above.")
|
||||
return 0
|
||||
|
|
|
|||
120
finder/price_history.py
Normal file
120
finder/price_history.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Forward-only asking-price history, accrued across recurring scrape runs.
|
||||
|
||||
Rightmove (and the other portals) never expose a listing's full asking-price
|
||||
timeline: a detail page carries only the current price plus one most-recent
|
||||
"Reduced on <date>" event, and the previous price is never published. The only
|
||||
way to obtain a real "listed at X, reduced to Y" series is therefore to record
|
||||
each listing's price ourselves every run and diff it over time.
|
||||
|
||||
This module keeps a persistent, listing-id-keyed store of price observations:
|
||||
|
||||
{"<listing id>": [{"date": "YYYY-MM-DD", "price": 425000, "reason": "listed"},
|
||||
{"date": "YYYY-MM-DD", "price": 410000, "reason": "reduced"}]}
|
||||
|
||||
Entries are oldest -> newest. A new point is appended only when the price
|
||||
actually changes (or on first sight), so an unchanged listing costs nothing. The
|
||||
store is seeded from / dumped to disk with the same atomic JSON helpers as the
|
||||
detail-postcode caches (see postcode_cache.py), so a recurring scrape extends the
|
||||
history rather than rebuilding it.
|
||||
|
||||
Two hard limitations, both inherent to the data source:
|
||||
* There is NO backfill. History accrues only from the first instrumented run;
|
||||
prior asking prices cannot be reconstructed (portals don't publish them and
|
||||
we kept no snapshots).
|
||||
* On a listing's first sight we know exactly one price, so we record one point.
|
||||
If the portal's own most-recent event says that price was itself a reduction/
|
||||
increase, we date and label that single point accordingly; we still cannot
|
||||
invent the pre-change price.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from postcode_cache import load_cache, save_cache
|
||||
|
||||
log = logging.getLogger("rightmove")
|
||||
|
||||
# Portal `listingUpdateReason` codes -> our canonical reasons. Anything not a
|
||||
# recognised price move (e.g. "new", "under_offer", "auction", or absent) leaves
|
||||
# the first-sight point labelled "listed" and never fabricates a change.
|
||||
_REASON_MAP = {
|
||||
"price_reduced": "reduced",
|
||||
"price_increased": "increased",
|
||||
}
|
||||
|
||||
_ISO_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})")
|
||||
|
||||
|
||||
def normalize_reason(raw: object) -> str | None:
|
||||
"""Map a portal update-reason code to 'reduced'/'increased', else None."""
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
return _REASON_MAP.get(raw.strip().lower())
|
||||
|
||||
|
||||
def _iso_to_date(value: object) -> str | None:
|
||||
"""Return the YYYY-MM-DD prefix of an ISO timestamp, or None."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
match = _ISO_DATE_RE.match(value.strip())
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def load_history(path: str | Path) -> dict:
|
||||
"""Load the persisted asking-price history. Returns {} when absent/unreadable."""
|
||||
return load_cache(path)
|
||||
|
||||
|
||||
def save_history(path: str | Path, history: dict) -> None:
|
||||
"""Atomically persist the asking-price history to disk."""
|
||||
save_cache(path, history)
|
||||
|
||||
|
||||
def update_history(history: dict, listings: list[dict], run_date: str) -> None:
|
||||
"""Fold one run's listings into ``history`` in place.
|
||||
|
||||
``run_date`` is the ISO date (YYYY-MM-DD) of the scrape. For each listing with
|
||||
a stable id and a positive price:
|
||||
|
||||
* first sight -> append one point. Its date/reason come from the portal's
|
||||
most-recent change event when that event is a price move (so a listing we
|
||||
first meet already-reduced reads "Reduced on <event date>"); otherwise the
|
||||
point is the "listed" price dated to ``first_visible_date`` (falling back
|
||||
to ``run_date``).
|
||||
* later runs -> append a point ONLY when the price differs from the last
|
||||
recorded one, labelled "reduced"/"increased" by direction and dated to
|
||||
``run_date`` (we only know the change happened by this run).
|
||||
|
||||
An unchanged price is a no-op, so the series stays a list of genuine moves.
|
||||
"""
|
||||
for listing in listings:
|
||||
listing_id_raw = listing.get("id")
|
||||
if listing_id_raw is None:
|
||||
continue
|
||||
listing_id = str(listing_id_raw).strip()
|
||||
if not listing_id:
|
||||
continue
|
||||
try:
|
||||
price = int(listing.get("price") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if price <= 0:
|
||||
continue
|
||||
|
||||
entries = history.get(listing_id)
|
||||
if not isinstance(entries, list) or not entries:
|
||||
reason = normalize_reason(listing.get("listing_update_reason"))
|
||||
if reason is not None:
|
||||
date = _iso_to_date(listing.get("listing_update_date")) or run_date
|
||||
else:
|
||||
reason = "listed"
|
||||
date = _iso_to_date(listing.get("first_visible_date")) or run_date
|
||||
history[listing_id] = [{"date": date, "price": price, "reason": reason}]
|
||||
continue
|
||||
|
||||
last_price = entries[-1].get("price")
|
||||
if last_price == price:
|
||||
continue
|
||||
reason = "reduced" if last_price is not None and price < last_price else "increased"
|
||||
entries.append({"date": run_date, "price": price, "reason": reason})
|
||||
|
|
@ -5,6 +5,7 @@ import signal
|
|||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
|
@ -18,6 +19,7 @@ from constants import (
|
|||
DATA_DIR,
|
||||
DELAY_BETWEEN_OUTCODES,
|
||||
LONDON_OUTCODE_PREFIXES,
|
||||
MAX_ROW_DROP_RATIO,
|
||||
ZOOPLA_DETAIL_BUDGET_FRACTION,
|
||||
ZOOPLA_FETCH_DETAILS,
|
||||
ZOOPLA_FETCHER,
|
||||
|
|
@ -28,12 +30,13 @@ import onthemarket
|
|||
import rightmove
|
||||
import shutdown
|
||||
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 postcode_cache import load_cache, save_cache
|
||||
from rightmove import resolve_outcode_id
|
||||
from rightmove import search_outcode as rightmove_search_outcode
|
||||
from spatial import PostcodeSpatialIndex
|
||||
from price_history import load_history, save_history, update_history
|
||||
from storage import write_parquet
|
||||
from zoopla import TurnstileError
|
||||
from zoopla import launch_browser as launch_zoopla_browser
|
||||
|
|
@ -314,6 +317,72 @@ def _record_error(
|
|||
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(
|
||||
outcodes: list[str],
|
||||
pc_index: PostcodeSpatialIndex,
|
||||
|
|
@ -333,6 +402,12 @@ def _scrape_rightmove(
|
|||
|
||||
try:
|
||||
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:
|
||||
_record_error(errors, "rightmove", outcode, exc)
|
||||
shutdown.sleep(DELAY_BETWEEN_OUTCODES)
|
||||
|
|
@ -366,6 +441,8 @@ def _scrape_rightmove(
|
|||
max_properties_per_source,
|
||||
)
|
||||
log.info("Rightmove %s: +%d", outcode, added)
|
||||
except BlockedError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_record_error(errors, "rightmove", outcode, exc)
|
||||
|
||||
|
|
@ -394,6 +471,8 @@ def _scrape_rightmove(
|
|||
max_properties_per_source,
|
||||
)
|
||||
log.info("Rightmove %s new-homes: +%d", outcode, added_new)
|
||||
except BlockedError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_record_error(errors, "rightmove", outcode, exc)
|
||||
|
||||
|
|
@ -729,6 +808,7 @@ def _run_sources(
|
|||
run_zoopla: Callable[[], None] | None,
|
||||
background_runners: list[tuple[str, Callable[[], None]]],
|
||||
errors: list[str],
|
||||
abandoned: set[str],
|
||||
) -> None:
|
||||
"""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
|
||||
``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
|
||||
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:
|
||||
futures = {pool.submit(fn): name for name, fn in background_runners}
|
||||
if run_zoopla is not None:
|
||||
try:
|
||||
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
|
||||
_record_error(errors, "zoopla", "*", exc)
|
||||
for future, name in futures.items():
|
||||
try:
|
||||
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
|
||||
_record_error(errors, name, "*", exc)
|
||||
|
||||
|
|
@ -773,6 +864,10 @@ def run_scrape(
|
|||
output_base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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}
|
||||
started_at = time.time()
|
||||
|
||||
|
|
@ -832,18 +927,37 @@ def run_scrape(
|
|||
)
|
||||
|
||||
try:
|
||||
_run_sources(run_zoopla, background_runners, errors)
|
||||
_run_sources(run_zoopla, background_runners, errors, abandoned)
|
||||
finally:
|
||||
_save_detail_caches(selected_sources, cache_dir)
|
||||
|
||||
merged, source_counts, deduped = _merge_properties(results)
|
||||
output_path = output_base / "online_listings_buy.parquet"
|
||||
if merged:
|
||||
write_parquet(merged, output_path)
|
||||
failures = _sanity_failures(
|
||||
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:
|
||||
if output_path.exists():
|
||||
output_path.unlink()
|
||||
log.warning("No London-ish properties to write to %s", output_path)
|
||||
# Accrue the per-listing asking-price history before writing: load the
|
||||
# persistent store, append this run's price moves, dump it, then embed
|
||||
# each listing's series in the parquet. Forward-only by nature: the
|
||||
# first run seeds one point per listing and reductions appear over time.
|
||||
history_path = output_base / "price_history" / "listings.json"
|
||||
history = load_history(history_path)
|
||||
run_date = (
|
||||
datetime.fromtimestamp(started_at, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
)
|
||||
update_history(history, merged, run_date)
|
||||
save_history(history_path, history)
|
||||
write_parquet(merged, output_path, price_history=history)
|
||||
|
||||
counts = {
|
||||
"total": len(merged),
|
||||
|
|
@ -868,6 +982,10 @@ def run_scrape(
|
|||
},
|
||||
"counts": counts,
|
||||
"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,
|
||||
"failures": failures,
|
||||
"written": not failures,
|
||||
"elapsed_seconds": round(time.time() - started_at, 3),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,16 @@ from transform import map_property_type, normalize_postcode
|
|||
log = logging.getLogger("rightmove")
|
||||
|
||||
|
||||
def write_parquet(properties: list[dict], path: Path) -> None:
|
||||
"""Write sale properties list to parquet with server-ready column names."""
|
||||
def write_parquet(
|
||||
properties: list[dict], path: Path, price_history: dict | None = None
|
||||
) -> None:
|
||||
"""Write sale properties list to parquet with server-ready column names.
|
||||
|
||||
``price_history`` is the persistent listing-id -> [{date, price, reason}]
|
||||
store (see finder/price_history.py); each listing's accrued asking-price
|
||||
series is embedded as a ``price_history`` list column. Absent id -> empty
|
||||
list, so pre-instrumentation runs and tests simply write empty series.
|
||||
"""
|
||||
if not properties:
|
||||
log.warning("No properties to write to %s", path)
|
||||
return
|
||||
|
|
@ -95,6 +103,14 @@ def write_parquet(properties: list[dict], path: Path) -> None:
|
|||
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
|
||||
listing_statuses = ["For sale"] * len(properties)
|
||||
|
||||
# Accrued asking-price history per listing (oldest -> newest). Look up with
|
||||
# the SAME normalisation the store keys on (str(id).strip(), matching
|
||||
# price_history.update_history and scraper dedup); an unseen id -> empty.
|
||||
history_lookup = price_history or {}
|
||||
price_history_col = [
|
||||
history_lookup.get(str(p.get("id")).strip(), []) for p in properties
|
||||
]
|
||||
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"Bedrooms": [p["Bedrooms"] for p in properties],
|
||||
|
|
@ -144,6 +160,7 @@ def write_parquet(properties: list[dict], path: Path) -> None:
|
|||
"Listing date": listing_dates,
|
||||
"Listing status": listing_statuses,
|
||||
"Asking price": asking_prices,
|
||||
"price_history": price_history_col,
|
||||
},
|
||||
schema={
|
||||
"Bedrooms": pl.Int32,
|
||||
|
|
@ -169,6 +186,11 @@ def write_parquet(properties: list[dict], path: Path) -> None:
|
|||
"Listing date": pl.Datetime("us"),
|
||||
"Listing status": pl.Utf8,
|
||||
"Asking price": pl.Int64,
|
||||
"price_history": pl.List(
|
||||
pl.Struct(
|
||||
{"date": pl.Utf8, "price": pl.Int64, "reason": pl.Utf8}
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
321
finder/test_block_detection.py
Normal file
321
finder/test_block_detection.py
Normal 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(),
|
||||
)
|
||||
== []
|
||||
)
|
||||
77
finder/test_price_history.py
Normal file
77
finder/test_price_history.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Tests for the forward-only asking-price history store (price_history.py)."""
|
||||
|
||||
from price_history import normalize_reason, update_history
|
||||
|
||||
|
||||
def test_first_sight_new_listing_records_listed_at_first_visible_date() -> None:
|
||||
history: dict = {}
|
||||
update_history(
|
||||
history,
|
||||
[{"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}],
|
||||
"2026-07-12",
|
||||
)
|
||||
assert history["A"] == [{"date": "2026-07-01", "price": 500000, "reason": "listed"}]
|
||||
|
||||
|
||||
def test_first_sight_already_reduced_uses_event_date_and_reason() -> None:
|
||||
history: dict = {}
|
||||
update_history(
|
||||
history,
|
||||
[
|
||||
{
|
||||
"id": "B",
|
||||
"price": 425000,
|
||||
"first_visible_date": "2026-06-01T09:00:00Z",
|
||||
"listing_update_reason": "price_reduced",
|
||||
"listing_update_date": "2026-07-10T14:00:00Z",
|
||||
}
|
||||
],
|
||||
"2026-07-12",
|
||||
)
|
||||
assert history["B"] == [{"date": "2026-07-10", "price": 425000, "reason": "reduced"}]
|
||||
|
||||
|
||||
def test_later_run_appends_only_on_price_change_with_direction() -> None:
|
||||
history: dict = {}
|
||||
listing = {"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}
|
||||
update_history(history, [listing], "2026-07-12")
|
||||
# Unchanged price -> no new point.
|
||||
update_history(history, [{"id": "A", "price": 500000}], "2026-07-19")
|
||||
assert len(history["A"]) == 1
|
||||
# Reduced -> appended, dated to the run.
|
||||
update_history(history, [{"id": "A", "price": 480000}], "2026-07-26")
|
||||
# Increased -> appended.
|
||||
update_history(history, [{"id": "A", "price": 490000}], "2026-08-02")
|
||||
assert history["A"] == [
|
||||
{"date": "2026-07-01", "price": 500000, "reason": "listed"},
|
||||
{"date": "2026-07-26", "price": 480000, "reason": "reduced"},
|
||||
{"date": "2026-08-02", "price": 490000, "reason": "increased"},
|
||||
]
|
||||
|
||||
|
||||
def test_zero_or_missing_price_and_id_are_skipped() -> None:
|
||||
history: dict = {}
|
||||
update_history(
|
||||
history,
|
||||
[
|
||||
{"id": "POA", "price": 0},
|
||||
{"id": "", "price": 100000},
|
||||
{"price": 100000}, # no id
|
||||
],
|
||||
"2026-07-12",
|
||||
)
|
||||
assert history == {}
|
||||
|
||||
|
||||
def test_run_date_fallback_when_dates_absent() -> None:
|
||||
history: dict = {}
|
||||
update_history(history, [{"id": "A", "price": 300000}], "2026-07-12")
|
||||
assert history["A"] == [{"date": "2026-07-12", "price": 300000, "reason": "listed"}]
|
||||
|
||||
|
||||
def test_normalize_reason_maps_only_price_moves() -> None:
|
||||
assert normalize_reason("price_reduced") == "reduced"
|
||||
assert normalize_reason("price_increased") == "increased"
|
||||
assert normalize_reason("new") is None
|
||||
assert normalize_reason("under_offer") is None
|
||||
assert normalize_reason(None) is None
|
||||
|
|
@ -31,7 +31,7 @@ def test_run_sources_runs_every_source_and_isolates_failures():
|
|||
order.append("zoo")
|
||||
|
||||
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"}
|
||||
# The failing source is recorded but did not stop the others.
|
||||
|
|
@ -45,14 +45,14 @@ def test_run_sources_records_zoopla_failure():
|
|||
def zoo():
|
||||
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)
|
||||
|
||||
|
||||
def test_run_sources_handles_no_background_runners():
|
||||
ran = []
|
||||
errors: list[str] = []
|
||||
scraper._run_sources(lambda: ran.append("z"), [], errors)
|
||||
scraper._run_sources(lambda: ran.append("z"), [], errors, set())
|
||||
assert ran == ["z"]
|
||||
assert errors == []
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ def test_run_sources_handles_no_background_runners():
|
|||
def test_run_sources_handles_zoopla_absent():
|
||||
ran = []
|
||||
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 errors == []
|
||||
|
||||
|
|
@ -125,7 +125,9 @@ def test_run_scrape_runs_all_sources_merges_and_persists_caches(tmp_path, monkey
|
|||
monkeypatch.setattr(
|
||||
scraper,
|
||||
"write_parquet",
|
||||
lambda props, path: written.update(count=len(props), path=path),
|
||||
lambda props, path, price_history=None: written.update(
|
||||
count=len(props), path=path, price_history=price_history
|
||||
),
|
||||
)
|
||||
|
||||
result = scraper.run_scrape(
|
||||
|
|
|
|||
|
|
@ -385,6 +385,16 @@ def transform_property(
|
|||
if not listing_id:
|
||||
return None
|
||||
|
||||
# The search API already carries the single most-recent price-change event
|
||||
# (reason + ISO date) with no extra request. Rightmove never exposes the
|
||||
# previous price, so this only seeds the accruing asking-price history (see
|
||||
# finder/price_history.py); it is not written to the output parquet itself.
|
||||
listing_update = prop.get("listingUpdate")
|
||||
if not isinstance(listing_update, dict):
|
||||
listing_update = {}
|
||||
listing_update_reason = listing_update.get("listingUpdateReason")
|
||||
listing_update_date = listing_update.get("listingUpdateDate")
|
||||
|
||||
return {
|
||||
"id": listing_id,
|
||||
"Bedrooms": bedrooms,
|
||||
|
|
@ -411,4 +421,7 @@ def transform_property(
|
|||
"Listing URL": build_listing_url(property_url, bool(prop.get("development"))),
|
||||
"Listing features": key_features,
|
||||
"first_visible_date": prop.get("firstVisibleDate", ""),
|
||||
# Fed into the asking-price history store, not the parquet directly.
|
||||
"listing_update_reason": listing_update_reason,
|
||||
"listing_update_date": listing_update_date,
|
||||
}
|
||||
|
|
|
|||
109
finder/zoopla.py
109
finder/zoopla.py
|
|
@ -27,14 +27,11 @@ import time
|
|||
from pathlib import Path
|
||||
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
|
||||
import gluetun
|
||||
import shutdown
|
||||
from constants import (
|
||||
DATA_DIR,
|
||||
DELAY_BETWEEN_PAGES,
|
||||
GLUETUN_API_KEY,
|
||||
GLUETUN_CONTROL_URL,
|
||||
GLUETUN_MAX_ROTATIONS,
|
||||
GLUETUN_PROXY,
|
||||
MAX_BEDROOMS,
|
||||
|
|
@ -472,110 +469,16 @@ def _challenge_timeout_seconds() -> int:
|
|||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 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
|
||||
# the VPN, poll until the public IP changes, drop the stale cf_clearance
|
||||
# cookies (bound to the previous IP), then reload and re-check the challenge.
|
||||
|
||||
|
||||
def _gluetun_base_url() -> str:
|
||||
return GLUETUN_CONTROL_URL.rstrip("/")
|
||||
|
||||
|
||||
def _gluetun_api_key() -> str | None:
|
||||
return GLUETUN_API_KEY
|
||||
# swap the egress IP via Gluetun's HTTP control server, then drop the stale
|
||||
# cf_clearance cookies (bound to the previous IP) 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_max_rotations() -> int:
|
||||
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:
|
||||
"""Drop cf_clearance / __cf_bm which are bound to the previous egress IP."""
|
||||
try:
|
||||
|
|
@ -596,7 +499,7 @@ def _rotate_and_retry_challenge(page, max_rotations: int) -> bool:
|
|||
"Cloudflare Turnstile challenge, rotating Gluetun IP (attempt %d/%d)",
|
||||
attempt, max_rotations,
|
||||
)
|
||||
if not _rotate_gluetun_ip():
|
||||
if not gluetun.rotate_ip():
|
||||
continue
|
||||
|
||||
_clear_cloudflare_cookies(page)
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-01-say-it.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-01-say-it.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-01-say-it.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-01-say-it.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,14 +1,14 @@
|
|||
WEBVTT
|
||||
|
||||
00:00:00.201 --> 00:00:03.961
|
||||
00:00:00.202 --> 00:00:03.802
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-02-twenty-minute-map.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-02-twenty-minute-map.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-02-twenty-minute-map.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-02-twenty-minute-map.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,14 +1,14 @@
|
|||
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.
|
||||
|
||||
00:00:06.361 --> 00:00:09.801
|
||||
00:00:04.412 --> 00:00:07.852
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-03-postcode-files.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-03-postcode-files.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-03-postcode-files.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-03-postcode-files.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,14 +1,14 @@
|
|||
WEBVTT
|
||||
|
||||
00:00:01.021 --> 00:00:05.341
|
||||
00:00:00.241 --> 00:00:05.121
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-04-quiet-streets.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-04-quiet-streets.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-04-quiet-streets.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-04-quiet-streets.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,14 +1,14 @@
|
|||
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.
|
||||
|
||||
00:00:04.841 --> 00:00:07.881
|
||||
00:00:04.772 --> 00:00:07.812
|
||||
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.
|
||||
|
||||
00:00:13.671 --> 00:00:16.071
|
||||
00:00:12.992 --> 00:00:15.392
|
||||
The quiet street nobody bid up.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-05-school-run.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-05-school-run.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-05-school-run.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-05-school-run.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -3,12 +3,12 @@ WEBVTT
|
|||
00:00:00.201 --> 00:00:06.521
|
||||
Good primary, low crime, under three hundred and fifty. The actual family brief.
|
||||
|
||||
00:00:06.921 --> 00:00:11.081
|
||||
00:00:06.851 --> 00:00:10.611
|
||||
Everything still on the map passes all three filters.
|
||||
|
||||
00:00:12.381 --> 00:00:17.741
|
||||
00:00:11.691 --> 00:00:17.211
|
||||
Same primary, same low crime, without the name everyone else is bidding up.
|
||||
|
||||
00:00:18.391 --> 00:00:21.831
|
||||
00:00:17.711 --> 00:00:20.831
|
||||
The schools are real. The premium is optional.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-06-waitrose-test.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-06-waitrose-test.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-06-waitrose-test.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-06-waitrose-test.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,14 +1,14 @@
|
|||
WEBVTT
|
||||
|
||||
00:00:00.204 --> 00:00:05.724
|
||||
00:00:00.202 --> 00:00:05.722
|
||||
A Waitrose, a tube stop, a park nearby. Search by what you want.
|
||||
|
||||
00:00:06.124 --> 00:00:10.204
|
||||
00:00:06.052 --> 00:00:10.132
|
||||
London, narrowed to the postcodes that fit the way you live.
|
||||
|
||||
00:00:11.504 --> 00:00:14.704
|
||||
00:00:11.212 --> 00:00:14.252
|
||||
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.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/ad-07-renters-map.jpg
(Stored with Git LFS)
BIN
frontend/public/video/ad-07-renters-map.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/ad-07-renters-map.mp4
(Stored with Git LFS)
BIN
frontend/public/video/ad-07-renters-map.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,14 +1,14 @@
|
|||
WEBVTT
|
||||
|
||||
00:00:00.201 --> 00:00:05.561
|
||||
Rent under sixteen hundred, half an hour to central, on a quiet street.
|
||||
00:00:00.202 --> 00:00:05.002
|
||||
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.
|
||||
|
||||
00:00:11.501 --> 00:00:15.821
|
||||
00:00:10.972 --> 00:00:14.892
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
00:00:16.231 --> 00:00:19.351
|
||||
00:00:16.912 --> 00:00:19.552
|
||||
Buy value, not reputation.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/recording-mobile.jpg
(Stored with Git LFS)
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)
BIN
frontend/public/video/recording-mobile.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,26 +1,26 @@
|
|||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
00:00:43.822 --> 00:00:46.942
|
||||
00:00:42.958 --> 00:00:46.158
|
||||
Stop paying for the name. Find the value.
|
||||
|
||||
|
|
|
|||
BIN
frontend/public/video/recording.jpg
(Stored with Git LFS)
BIN
frontend/public/video/recording.jpg
(Stored with Git LFS)
Binary file not shown.
BIN
frontend/public/video/recording.mp4
(Stored with Git LFS)
BIN
frontend/public/video/recording.mp4
(Stored with Git LFS)
Binary file not shown.
|
|
@ -1,26 +1,26 @@
|
|||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
00:00:44.190 --> 00:00:47.310
|
||||
00:00:44.197 --> 00:00:47.397
|
||||
Stop paying for the name. Find the value.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
WEBVTT
|
||||
|
||||
00:00:00.203 --> 00:00:06.283
|
||||
Beckenham and Croydon sit side by side: same trains, same school catchment.
|
||||
00:00:00.206 --> 00:00:07.246
|
||||
Beckenham and Croydon sit two kilometres apart, on the same trains, in the same school catchments.
|
||||
|
||||
00:00:06.683 --> 00:00:10.523
|
||||
Rank them by what each pound of floor space actually buys.
|
||||
00:00:07.546 --> 00:00:12.586
|
||||
Now colour every postcode by what a square metre of home actually costs there.
|
||||
|
||||
00:00:11.823 --> 00:00:16.943
|
||||
One postcode over, the same home quietly costs about a third less.
|
||||
00:00:13.666 --> 00:00:19.826
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
WEBVTT
|
||||
|
||||
00:00:00.202 --> 00:00:05.882
|
||||
Stanmore and Kenton sit right next door, with the same schools and transport links.
|
||||
00:00:00.201 --> 00:00:07.001
|
||||
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
|
||||
Rank every postcode by what each pound of floor space actually buys.
|
||||
00:00:07.301 --> 00:00:12.341
|
||||
Now colour every postcode by what a square metre of home actually costs there.
|
||||
|
||||
00:00:12.142 --> 00:00:17.422
|
||||
One postcode over, the same home quietly costs about a sixth less.
|
||||
00:00:13.421 --> 00:00:19.421
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
WEBVTT
|
||||
|
||||
00:00:00.203 --> 00:00:06.843
|
||||
Childwall and Broadgreen sit right next door, with the same schools and transport links.
|
||||
00:00:00.201 --> 00:00:07.081
|
||||
Childwall and Broadgreen sit under two kilometres apart, with the same schools and transport links.
|
||||
|
||||
00:00:07.243 --> 00:00:11.803
|
||||
Rank every postcode by what each pound of floor space actually buys.
|
||||
00:00:07.381 --> 00:00:12.421
|
||||
Now colour every postcode by what a square metre of home actually costs there.
|
||||
|
||||
00:00:13.103 --> 00:00:18.223
|
||||
One postcode over, the same home quietly costs about a third less.
|
||||
00:00:13.501 --> 00:00:19.901
|
||||
Green means cheaper floor space. Broadgreen runs nearly a third under Childwall, street for street.
|
||||
|
||||
00:00:19.373 --> 00:00:24.493
|
||||
00:00:20.331 --> 00:00:24.571
|
||||
That's well over a hundred thousand pounds back on an average home.
|
||||
|
||||
00:00:25.171 --> 00:00:30.691
|
||||
Childwall's cheaper twin is on this map. Find yours, free.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
|||
import { trackEvent } from './lib/analytics';
|
||||
import { parseUrlState } from './lib/url-state';
|
||||
import pb from './lib/pocketbase';
|
||||
import { DEFAULT_DEMO_FILTERS, INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { DEFAULT_DEMO_FILTERS, DEFAULT_DEMO_TRAVEL, INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
|
|
@ -38,6 +38,7 @@ const LearnPage = lazy(() => import('./components/learn/LearnPage'));
|
|||
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
|
||||
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
|
||||
const AccountPage = lazy(() => import('./components/account/AccountPage'));
|
||||
const ResetPasswordPage = lazy(() => import('./components/account/ResetPasswordPage'));
|
||||
const SavedPage = lazy(() =>
|
||||
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
||||
);
|
||||
|
|
@ -160,6 +161,8 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
return '/saved';
|
||||
case 'account':
|
||||
return '/account';
|
||||
case 'reset-password':
|
||||
return '/reset-password';
|
||||
case 'invite':
|
||||
if (!inviteCode) {
|
||||
throw new Error('Cannot build invite path without an invite code');
|
||||
|
|
@ -183,6 +186,7 @@ function pathToPage(rawPathname: string): RouteMatch | null {
|
|||
const seoContentPage = getSeoContentPage(pathname);
|
||||
if (seoContentPage) return { page: seoContentPage };
|
||||
if (pathname === '/account') return { page: 'account' };
|
||||
if (pathname === '/reset-password') return { page: 'reset-password' };
|
||||
if (pathname === '/support') return { page: 'learn' };
|
||||
if (pathname === '/terms') return { page: 'terms' };
|
||||
if (pathname === '/privacy') return { page: 'privacy' };
|
||||
|
|
@ -234,15 +238,26 @@ export default function App() {
|
|||
return params.get('og') === '1';
|
||||
}, []);
|
||||
|
||||
// Funnel fix: pre-seed high-intent filters on a cold (empty) map open so first-time visitors
|
||||
// immediately see value and are one filter from the demo cap. Deep links (OG screenshots, the
|
||||
// SEO landing-page CTAs) already carry filters, so they're left as-is. See DEFAULT_DEMO_FILTERS.
|
||||
const initialMapFilters = useMemo(() => {
|
||||
if (isScreenshotMode || isOgMode) return mapUrlState.filters;
|
||||
return Object.keys(mapUrlState.filters).length === 0
|
||||
? { ...DEFAULT_DEMO_FILTERS }
|
||||
: mapUrlState.filters;
|
||||
}, [mapUrlState.filters, isScreenshotMode, isOgMode]);
|
||||
// Funnel fix: pre-seed high-intent defaults on a cold map open so first-time visitors immediately
|
||||
// see value and are one filter from the demo cap. "Cold" means the URL carries neither filters nor
|
||||
// a travel time; a deep link (OG screenshot, SEO landing-page CTA) sets at least one of those and
|
||||
// is left as-is, as is the non-interactive screenshot/OG render. See DEFAULT_DEMO_FILTERS.
|
||||
const isColdMapOpen = useMemo(() => {
|
||||
if (isScreenshotMode || isOgMode) return false;
|
||||
const hasFilters = Object.keys(mapUrlState.filters).length > 0;
|
||||
const hasTravel = (mapUrlState.travelTime?.entries?.length ?? 0) > 0;
|
||||
return !hasFilters && !hasTravel;
|
||||
}, [mapUrlState.filters, mapUrlState.travelTime, isScreenshotMode, isOgMode]);
|
||||
|
||||
const initialMapFilters = useMemo(
|
||||
() => (isColdMapOpen ? { ...DEFAULT_DEMO_FILTERS } : mapUrlState.filters),
|
||||
[isColdMapOpen, mapUrlState.filters]
|
||||
);
|
||||
|
||||
const initialMapTravelTime = useMemo(
|
||||
() => (isColdMapOpen ? DEFAULT_DEMO_TRAVEL : mapUrlState.travelTime),
|
||||
[isColdMapOpen, mapUrlState.travelTime]
|
||||
);
|
||||
|
||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
||||
|
|
@ -283,6 +298,7 @@ export default function App() {
|
|||
loginWithOAuth,
|
||||
logout,
|
||||
requestPasswordReset,
|
||||
confirmPasswordReset,
|
||||
refreshAuth,
|
||||
clearError,
|
||||
} = useAuth();
|
||||
|
|
@ -708,6 +724,17 @@ export default function App() {
|
|||
<LearnPage />
|
||||
) : activePage === 'terms' || activePage === 'privacy' ? (
|
||||
<LegalPage kind={activePage} />
|
||||
) : activePage === 'reset-password' ? (
|
||||
<ResetPasswordPage
|
||||
onConfirm={confirmPasswordReset}
|
||||
onLoginClick={() => {
|
||||
navigateTo('home');
|
||||
openAuthModal('login');
|
||||
}}
|
||||
loading={authLoading}
|
||||
error={authError}
|
||||
onClearError={clearError}
|
||||
/>
|
||||
) : isSeoLandingPage(activePage) ? (
|
||||
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
||||
) : isSeoContentPage(activePage) ? (
|
||||
|
|
@ -769,7 +796,7 @@ export default function App() {
|
|||
}}
|
||||
onDashboardReadyChange={setDashboardReady}
|
||||
isMobile={isMobile}
|
||||
initialTravelTime={mapUrlState.travelTime}
|
||||
initialTravelTime={initialMapTravelTime}
|
||||
initialPostcode={mapUrlState.postcode}
|
||||
shareCode={mapUrlState.share}
|
||||
onSessionRejected={() => {
|
||||
|
|
|
|||
53
frontend/src/components/account/ResetPasswordPage.test.tsx
Normal file
53
frontend/src/components/account/ResetPasswordPage.test.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-i18next')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ResetPasswordPage from './ResetPasswordPage';
|
||||
|
||||
function renderPage(search: string, onConfirm = vi.fn(async () => {})) {
|
||||
window.history.replaceState({}, '', `/reset-password${search}`);
|
||||
const onLoginClick = vi.fn();
|
||||
render(
|
||||
<ResetPasswordPage
|
||||
onConfirm={onConfirm}
|
||||
onLoginClick={onLoginClick}
|
||||
loading={false}
|
||||
error={null}
|
||||
onClearError={() => {}}
|
||||
/>
|
||||
);
|
||||
return { onConfirm, onLoginClick };
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ResetPasswordPage', () => {
|
||||
it('shows an incomplete-link message and no form when the token is missing', () => {
|
||||
const { onLoginClick } = renderPage('');
|
||||
expect(screen.getByText('auth.resetMissingToken')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('auth.newPassword')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'auth.goToLogin' }));
|
||||
expect(onLoginClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('submits the URL token plus the new password, then shows success', async () => {
|
||||
const { onConfirm } = renderPage('?token=reset-token-123');
|
||||
const input = screen.getByLabelText('auth.newPassword') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'sup3rs3cret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'auth.updatePassword' }));
|
||||
expect(onConfirm).toHaveBeenCalledWith('reset-token-123', 'sup3rs3cret');
|
||||
expect(await screen.findByText('auth.resetSuccessBody')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggles password visibility', () => {
|
||||
renderPage('?token=abc');
|
||||
const input = screen.getByLabelText('auth.newPassword') as HTMLInputElement;
|
||||
expect(input.type).toBe('password');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'auth.showPassword' }));
|
||||
expect(input.type).toBe('text');
|
||||
});
|
||||
});
|
||||
136
frontend/src/components/account/ResetPasswordPage.tsx
Normal file
136
frontend/src/components/account/ResetPasswordPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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-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">
|
||||
<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]">
|
||||
{t('home.heroTitle1')}{' '}
|
||||
<span className="text-teal-400">{t('home.heroTitle2')}</span>.
|
||||
|
|
@ -511,8 +510,7 @@ export default function HomePage({
|
|||
</div>
|
||||
<p className="text-sm font-medium text-teal-200 mb-4">{t('home.freeToExplore')}</p>
|
||||
<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 pt-3 border-t border-white/10">
|
||||
<div className="home-hero-stats flex flex-wrap mt-6 pt-3 border-t border-white/10">
|
||||
<div className="home-hero-stat">
|
||||
<div className="home-hero-stat-value tabular-nums">
|
||||
<TickerValue text="13M" active={statsActive} />
|
||||
|
|
|
|||
|
|
@ -143,11 +143,11 @@ export default memo(function AiFilterInput({
|
|||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
|
||||
<div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group"
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group"
|
||||
>
|
||||
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
|
||||
|
|
@ -159,8 +159,8 @@ export default memo(function AiFilterInput({
|
|||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">
|
||||
{t('aiFilter.aiSearch')}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import type {
|
|||
FeatureMeta,
|
||||
FilterExclusion,
|
||||
HexagonStatsResponse,
|
||||
PriceMetric,
|
||||
PricePoint,
|
||||
} from '../../types';
|
||||
import { postcodeOutcode, postcodeSector } from '../../lib/postcode';
|
||||
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||
import type { HexagonLocation } from '../../lib/external-search';
|
||||
import {
|
||||
|
|
@ -289,6 +292,7 @@ export default function AreaPane({
|
|||
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
|
||||
}, [paneFeatureGroups, hexagonLocation]);
|
||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||
const [priceMetric, setPriceMetric] = useState<PriceMetric>('price');
|
||||
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
|
||||
restoreKey: scrollRestoreKey ?? hexagonId,
|
||||
scrollTopRef,
|
||||
|
|
@ -548,14 +552,105 @@ export default function AreaPane({
|
|||
{stats.price_history &&
|
||||
(() => {
|
||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||
return uniqueYears.size > 1 ? (
|
||||
if (uniqueYears.size <= 1) return null;
|
||||
|
||||
const charts: {
|
||||
key: string;
|
||||
label: string;
|
||||
dot: string;
|
||||
points: PricePoint[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'area',
|
||||
label: t('areaPane.priceHistoryThisArea'),
|
||||
dot: 'bg-teal-500 dark:bg-teal-400',
|
||||
points: stats.price_history,
|
||||
},
|
||||
];
|
||||
// Sector/outcode context is postcode-only: a hexagon cell
|
||||
// straddles arbitrary postcodes, so its sector/outcode is not a
|
||||
// meaningful aggregation unit. The Total/Per-m² toggle still
|
||||
// applies to the area chart in both cases.
|
||||
if (isPostcode && hexagonId) {
|
||||
const sector = postcodeSector(hexagonId);
|
||||
const outcode = postcodeOutcode(hexagonId);
|
||||
if (sector && stats.sector_price_history?.length) {
|
||||
charts.push({
|
||||
key: 'sector',
|
||||
label: sector,
|
||||
dot: 'bg-indigo-500 dark:bg-indigo-400',
|
||||
points: stats.sector_price_history,
|
||||
});
|
||||
}
|
||||
if (outcode && stats.outcode_price_history?.length) {
|
||||
charts.push({
|
||||
key: 'outcode',
|
||||
label: outcode,
|
||||
dot: 'bg-amber-500 dark:bg-amber-400',
|
||||
points: stats.outcode_price_history,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasPerSqm = charts.some((c) =>
|
||||
c.points.some((p) => p.price_per_sqm != null)
|
||||
);
|
||||
const metric: PriceMetric = hasPerSqm ? priceMetric : 'price';
|
||||
const optionClass = (active: boolean) =>
|
||||
`px-2 py-0.5 text-[11px] font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
|
||||
active
|
||||
? 'bg-teal-600 text-white dark:bg-teal-500'
|
||||
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{t('areaPane.priceHistory')}
|
||||
</span>
|
||||
<PriceHistoryChart points={stats.price_history} />
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||
{t('areaPane.priceHistory')}
|
||||
</span>
|
||||
{hasPerSqm && (
|
||||
<div
|
||||
className="grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700"
|
||||
role="radiogroup"
|
||||
aria-label={t('areaPane.priceMetric')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={metric === 'price'}
|
||||
onClick={() => setPriceMetric('price')}
|
||||
className={optionClass(metric === 'price')}
|
||||
>
|
||||
{t('areaPane.priceMetricTotal')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={metric === 'price_per_sqm'}
|
||||
onClick={() => setPriceMetric('price_per_sqm')}
|
||||
className={optionClass(metric === 'price_per_sqm')}
|
||||
>
|
||||
{t('areaPane.priceMetricPerSqm')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{charts.map((chart) => (
|
||||
<div key={chart.key} className={chart.key === 'area' ? '' : 'mt-1.5'}>
|
||||
{charts.length > 1 && (
|
||||
<span className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-warm-500 dark:text-warm-400">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${chart.dot}`}
|
||||
/>
|
||||
{chart.label}
|
||||
</span>
|
||||
)}
|
||||
<PriceHistoryChart points={chart.points} metric={metric} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
})()}
|
||||
{displayFeatureGroups.map((group) => {
|
||||
const showNearbyStations =
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export default function FeatureBrowser({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="shrink-0 px-2 py-1.5 border-b border-warm-200 dark:border-navy-700">
|
||||
<div className="shrink-0 px-2 py-1 border-b border-warm-200 dark:border-navy-700">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
|
|
@ -125,7 +125,7 @@ export default function FeatureBrowser({
|
|||
toggleGroup(group.name);
|
||||
onGroupToggle?.(group.name, willExpand);
|
||||
}}
|
||||
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||
{group.features.length +
|
||||
|
|
@ -141,7 +141,7 @@ export default function FeatureBrowser({
|
|||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 min-w-0"
|
||||
|
|
@ -184,7 +184,7 @@ export default function FeatureBrowser({
|
|||
return (
|
||||
<div
|
||||
key={f.name}
|
||||
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||
>
|
||||
<div className="min-w-0 mr-2">
|
||||
<FeatureLabel feature={f} size="sm" description={f.description} />
|
||||
|
|
|
|||
97
frontend/src/components/map/ListingPopups.test.tsx
Normal file
97
frontend/src/components/map/ListingPopups.test.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
|
||||
import { ListingPopupSingleContent } from './ListingPopups';
|
||||
import type { ActualListing } from '../../types';
|
||||
|
||||
// No global RTL setup file registers auto-cleanup, so unmount between cases to
|
||||
// keep each render isolated in the shared jsdom document.
|
||||
afterEach(cleanup);
|
||||
|
||||
// Minimal react-i18next stub: t() echoes a readable label per key; i18n.language
|
||||
// is fixed so date formatting is deterministic.
|
||||
vi.mock('react-i18next', () => {
|
||||
const labels: Record<string, string> = {
|
||||
'listing.priceHistory': 'Price history',
|
||||
'listing.priceListed': 'Listed',
|
||||
'listing.priceReduced': 'Reduced',
|
||||
'listing.priceIncreased': 'Increased',
|
||||
'listing.openListing': 'Open listing',
|
||||
'listing.viewed': 'Viewed',
|
||||
};
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
labels[key] ?? (opts ? `${key} ${JSON.stringify(opts)}` : key),
|
||||
i18n: { language: 'en-GB' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function baseListing(overrides: Partial<ActualListing> = {}): ActualListing {
|
||||
return {
|
||||
lat: 51.5,
|
||||
lon: -0.1,
|
||||
postcode: 'SW9 0HD',
|
||||
listing_url: 'https://www.rightmove.co.uk/properties/1',
|
||||
asking_price: 490000,
|
||||
features: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ListingPopupSingleContent price history', () => {
|
||||
it('renders points newest-first with signed deltas and reason labels', () => {
|
||||
const listing = baseListing({
|
||||
price_history: [
|
||||
{ date: '2026-07-01', price: 500000, reason: 'listed' },
|
||||
{ date: '2026-07-19', price: 480000, reason: 'reduced' },
|
||||
{ date: '2026-07-26', price: 490000, reason: 'increased' },
|
||||
],
|
||||
});
|
||||
const { getByText, container } = render(
|
||||
<ListingPopupSingleContent listing={listing} clickedUrls={new Set()} onOpen={() => {}} />
|
||||
);
|
||||
|
||||
getByText('Price history');
|
||||
// Newest first: increased row shows +£10,000 vs the £480k point before it.
|
||||
const items = Array.from(container.querySelectorAll('ol li'));
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].textContent).toContain('£490,000');
|
||||
expect(items[0].textContent).toContain('+£10,000');
|
||||
expect(items[0].textContent).toContain('Increased');
|
||||
// Middle: the reduction from £500k -> £480k.
|
||||
expect(items[1].textContent).toContain('£480,000');
|
||||
expect(items[1].textContent).toContain('−£20,000');
|
||||
expect(items[1].textContent).toContain('Reduced');
|
||||
// Oldest: the initial listing, no delta.
|
||||
expect(items[2].textContent).toContain('£500,000');
|
||||
expect(items[2].textContent).toContain('Listed');
|
||||
expect(items[2].textContent).not.toContain('£0');
|
||||
// UTC-stable date: "2026-07-01" must read as 1 Jul regardless of runner TZ.
|
||||
expect(items[2].textContent).toContain('1 Jul 2026');
|
||||
});
|
||||
|
||||
it('renders a single listed point with no delta', () => {
|
||||
const listing = baseListing({
|
||||
price_history: [{ date: '2026-06-15', price: 325000, reason: 'listed' }],
|
||||
});
|
||||
const { getByText, container } = render(
|
||||
<ListingPopupSingleContent listing={listing} clickedUrls={new Set()} onOpen={() => {}} />
|
||||
);
|
||||
getByText('Price history');
|
||||
expect(container.querySelectorAll('ol li')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('omits the section entirely when there is no history', () => {
|
||||
const { queryByText } = render(
|
||||
<ListingPopupSingleContent
|
||||
listing={baseListing({ price_history: [] })}
|
||||
clickedUrls={new Set()}
|
||||
onOpen={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(queryByText('Price history')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,12 +2,89 @@ import { memo } from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type { ActualListing } from '../../types';
|
||||
import type { ActualListing, PriceHistoryPoint } from '../../types';
|
||||
|
||||
function formatListingPrice(price: number): string {
|
||||
return `£${price.toLocaleString()}`;
|
||||
}
|
||||
|
||||
function formatHistoryDate(iso: string, locale: string): string {
|
||||
const parsed = new Date(iso);
|
||||
if (Number.isNaN(parsed.getTime())) return iso;
|
||||
// `iso` is a UTC calendar date ("YYYY-MM-DD"), parsed as UTC midnight. Format
|
||||
// in UTC too, or a viewer west of UTC would see every date shifted a day back.
|
||||
return parsed.toLocaleDateString(locale, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
function reasonLabel(reason: string, t: TFunction): string {
|
||||
switch (reason) {
|
||||
case 'reduced':
|
||||
return t('listing.priceReduced');
|
||||
case 'increased':
|
||||
return t('listing.priceIncreased');
|
||||
default:
|
||||
return t('listing.priceListed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact asking-price timeline for the hover card. Renders most-recent first,
|
||||
* with the £ change from the prior point on each reduction/increase. Accrues from
|
||||
* the scraper's forward-only store, so a fresh listing shows a single point. */
|
||||
function ListingPriceHistory({ history }: { history: PriceHistoryPoint[] }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
if (history.length === 0) return null;
|
||||
// history is oldest -> newest; show up to the 5 most recent, newest first.
|
||||
const shown = history.slice(-5);
|
||||
const baseIndex = history.length - shown.length;
|
||||
const rows = shown
|
||||
.map((point, idx) => {
|
||||
const globalIdx = baseIndex + idx;
|
||||
const prev = globalIdx > 0 ? history[globalIdx - 1] : null;
|
||||
const delta = prev ? point.price - prev.price : 0;
|
||||
return { point, delta };
|
||||
})
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t border-warm-100 pt-1.5 dark:border-warm-700/60">
|
||||
<div className="text-[11px] font-medium text-warm-500 dark:text-warm-400">
|
||||
{t('listing.priceHistory')}
|
||||
</div>
|
||||
<ol className="mt-1 space-y-0.5">
|
||||
{rows.map(({ point, delta }, idx) => {
|
||||
const isReduction = point.reason === 'reduced';
|
||||
const isIncrease = point.reason === 'increased';
|
||||
const accent = isReduction
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: isIncrease
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-warm-600 dark:text-warm-300';
|
||||
return (
|
||||
<li key={idx} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className={`font-semibold ${accent}`}>{formatListingPrice(point.price)}</span>
|
||||
{delta !== 0 && (
|
||||
<span className={accent}>
|
||||
{delta < 0 ? '−' : '+'}£{Math.abs(delta).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 text-warm-400 dark:text-warm-500">
|
||||
{reasonLabel(point.reason, t)} · {formatHistoryDate(point.date, i18n.language)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
|
||||
const parts: string[] = [];
|
||||
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
|
||||
|
|
@ -78,6 +155,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
|
|||
))}
|
||||
</ul>
|
||||
)}
|
||||
{listing.price_history && listing.price_history.length > 0 && (
|
||||
<ListingPriceHistory history={listing.price_history} />
|
||||
)}
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
|
||||
{visited && (
|
||||
<span className="text-violet-600 dark:text-violet-400">✓ {t('listing.viewed')}</span>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
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 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type {
|
||||
|
|
@ -350,6 +350,16 @@ export default memo(function Map({
|
|||
}, [screenshotMode]);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
|
|
@ -550,9 +560,6 @@ export default memo(function Map({
|
|||
zoom={viewState.zoom}
|
||||
/>
|
||||
<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" />}
|
||||
</MapGL>
|
||||
{basemap === 'satellite' && (
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
REGISTERED_MAX_FILTERS,
|
||||
INITIAL_VIEW_STATE,
|
||||
POSTCODE_ZOOM_THRESHOLD,
|
||||
POI_ZOOM_THRESHOLD,
|
||||
} from '../../lib/consts';
|
||||
import { boundsToCenterZoom } from '../../lib/fit-bounds';
|
||||
import type { OverlayId } from '../../lib/overlays';
|
||||
|
|
@ -76,6 +77,7 @@ import {
|
|||
useScreenshotReadySignal,
|
||||
} from './map-page/effects';
|
||||
import { useMobileDrawer } from './map-page/useMobileDrawer';
|
||||
import type { MapControlState } from './map-page/MapControlButton';
|
||||
import type { MapFlyTo, MapPageProps } from './map-page/types';
|
||||
|
||||
export type { ExportState } from './map-page/types';
|
||||
|
|
@ -586,12 +588,26 @@ export default function MapPage({
|
|||
[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
|
||||
// immediately, not on the next fetch tick. Gate on the selection itself so a
|
||||
// stale fetch result can never keep cards on screen.
|
||||
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(
|
||||
() => buildFilterString(filters, features),
|
||||
[filters, features]
|
||||
|
|
@ -710,6 +726,11 @@ export default function MapPage({
|
|||
const shareAndSaveView = isMobile
|
||||
? (mapData.currentVisibleView ?? mapData.currentView)
|
||||
: mapData.currentView;
|
||||
// Params for share/save/checkout-return/last-session, deliberately WITHOUT the
|
||||
// selected postcode: the lat/lon/zoom already convey the location, so focusing
|
||||
// a postcode adds nothing to a shared link and shouldn't be baked into a saved
|
||||
// search. The live URL (useUrlSync above) still carries `pc` so a reload
|
||||
// re-opens the selection.
|
||||
const dashboardParams = useMemo(
|
||||
() =>
|
||||
stateToParams(
|
||||
|
|
@ -723,7 +744,7 @@ export default function MapPage({
|
|||
activeOverlays,
|
||||
basemap,
|
||||
crimeTypes,
|
||||
selectedPostcodeParam,
|
||||
undefined,
|
||||
colorOpacity,
|
||||
listingsMode
|
||||
).toString(),
|
||||
|
|
@ -738,7 +759,6 @@ export default function MapPage({
|
|||
listingsMode,
|
||||
rightPaneTab,
|
||||
selectedPOICategories,
|
||||
selectedPostcodeParam,
|
||||
shareCode,
|
||||
shareAndSaveView,
|
||||
]
|
||||
|
|
@ -894,6 +914,9 @@ export default function MapPage({
|
|||
total={propertiesTotal}
|
||||
loading={loadingProperties}
|
||||
hexagonId={selectedHexagon?.id || null}
|
||||
statsUseFilters={areaStatsUseFilters}
|
||||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
||||
filtersActive={Object.keys(filters).length + activeEntries.length > 0}
|
||||
onLoadMore={handleLoadMoreProperties}
|
||||
scrollTopRef={propertiesPaneScrollTopRef}
|
||||
scrollRestoreKey={
|
||||
|
|
@ -903,7 +926,17 @@ export default function MapPage({
|
|||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon]
|
||||
[
|
||||
activeEntries,
|
||||
areaStatsUseFilters,
|
||||
filters,
|
||||
handleLoadMoreProperties,
|
||||
loadingProperties,
|
||||
properties,
|
||||
propertiesTotal,
|
||||
selectedHexagon,
|
||||
setAreaStatsUseFilters,
|
||||
]
|
||||
);
|
||||
|
||||
const poiPane = useMemo(
|
||||
|
|
@ -914,11 +947,12 @@ export default function MapPage({
|
|||
selectedCategories={selectedPOICategories}
|
||||
onCategoriesChange={setSelectedPOICategories}
|
||||
poiCount={pois.length}
|
||||
zoomedIn={poisZoomedIn}
|
||||
onClose={handleClosePoiPane}
|
||||
/>
|
||||
</Suspense>
|
||||
),
|
||||
[handleClosePoiPane, poiCategoryGroups, pois.length, selectedPOICategories]
|
||||
[handleClosePoiPane, poisZoomedIn, poiCategoryGroups, pois.length, selectedPOICategories]
|
||||
);
|
||||
|
||||
const overlayPane = useMemo(
|
||||
|
|
@ -1222,9 +1256,11 @@ export default function MapPage({
|
|||
onTogglePoiPane={handleTogglePoiPane}
|
||||
poiButtonLabel={t('poiPane.pointsOfInterest')}
|
||||
poiPane={poiPane}
|
||||
poiControl={poiControl}
|
||||
overlayPaneOpen={overlayPaneOpen}
|
||||
onToggleOverlayPane={handleToggleOverlayPane}
|
||||
overlayPane={overlayPane}
|
||||
overlayControl={overlayControl}
|
||||
filtersPane={filtersPane}
|
||||
mobileLegend={mobileLegend}
|
||||
renderAreaPane={renderAreaPane}
|
||||
|
|
@ -1279,9 +1315,11 @@ export default function MapPage({
|
|||
poiPaneOpen={poiPaneOpen}
|
||||
onTogglePoiPane={handleTogglePoiPane}
|
||||
poiPane={poiPane}
|
||||
poiControl={poiControl}
|
||||
overlayPaneOpen={overlayPaneOpen}
|
||||
onToggleOverlayPane={handleToggleOverlayPane}
|
||||
overlayPane={overlayPane}
|
||||
overlayControl={overlayControl}
|
||||
showSelectionPane={!!selectedHexagon}
|
||||
rightPaneWidth={rightPaneWidth}
|
||||
rightPaneHandlers={rightPaneHandlers}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface POIPaneProps {
|
|||
selectedCategories: Set<string>;
|
||||
onCategoriesChange: (categories: Set<string>) => void;
|
||||
poiCount: number;
|
||||
zoomedIn: boolean;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
|
@ -26,6 +27,7 @@ export default function POIPane({
|
|||
selectedCategories,
|
||||
onCategoriesChange,
|
||||
poiCount: _poiCount,
|
||||
zoomedIn,
|
||||
onNavigateToSource,
|
||||
onClose,
|
||||
}: POIPaneProps) {
|
||||
|
|
@ -148,6 +150,15 @@ export default function POIPane({
|
|||
</p>
|
||||
</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 className="flex-1 min-h-0 overflow-y-auto overscroll-contain border-t border-warm-200 dark:border-warm-700">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import type { PricePoint } from '../../types';
|
||||
import type { PriceMetric, PricePoint } from '../../types';
|
||||
import { formatValue } from '../../lib/format';
|
||||
|
||||
interface PriceHistoryChartProps {
|
||||
points: PricePoint[];
|
||||
/** Which value to plot. Defaults to the absolute sale price. */
|
||||
metric?: PriceMetric;
|
||||
}
|
||||
|
||||
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
|
||||
|
|
@ -22,7 +24,7 @@ interface PriceScale {
|
|||
ticks: number[];
|
||||
}
|
||||
|
||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||
export default function PriceHistoryChart({ points, metric = 'price' }: PriceHistoryChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
|
|
@ -37,7 +39,18 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Project each point onto the chosen metric as a plain {year, price} so the
|
||||
// rest of the chart is metric-agnostic. In per-m² mode, points without a
|
||||
// recorded floor area drop out.
|
||||
const plotPoints = useMemo<PricePoint[]>(() => {
|
||||
if (metric === 'price') return points;
|
||||
return points
|
||||
.filter((p) => Number.isFinite(p.price_per_sqm))
|
||||
.map((p) => ({ year: p.year, price: p.price_per_sqm as number }));
|
||||
}, [points, metric]);
|
||||
|
||||
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
|
||||
const points = plotPoints;
|
||||
let yMin = Infinity,
|
||||
yMax = -Infinity;
|
||||
for (const p of points) {
|
||||
|
|
@ -83,7 +96,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
priceScale: getPriceScale(points),
|
||||
medians: meds,
|
||||
};
|
||||
}, [points]);
|
||||
}, [plotPoints]);
|
||||
|
||||
const plotW = width - PADDING.left - PADDING.right;
|
||||
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
|
||||
|
|
@ -104,7 +117,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
|
||||
return (
|
||||
<div ref={containerRef} style={{ height: HEIGHT }}>
|
||||
{width > 0 && (
|
||||
{width > 0 && plotPoints.length > 0 && (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
{/* Grid lines */}
|
||||
{priceScale.ticks.map((tick) => (
|
||||
|
|
@ -120,7 +133,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
))}
|
||||
|
||||
{/* Dots */}
|
||||
{points.map((p, i) => (
|
||||
{plotPoints.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={scaleX(p.year)}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ interface PropertiesPaneProps {
|
|||
total: number;
|
||||
loading: boolean;
|
||||
hexagonId: string | null;
|
||||
statsUseFilters: boolean;
|
||||
onStatsUseFiltersChange: (useFilters: boolean) => void;
|
||||
filtersActive: boolean;
|
||||
onLoadMore: () => void;
|
||||
onNavigateToSource?: (slug: string) => void;
|
||||
scrollTopRef?: MutableRefObject<number>;
|
||||
|
|
@ -35,6 +38,9 @@ export function PropertiesPane({
|
|||
total,
|
||||
loading,
|
||||
hexagonId,
|
||||
statsUseFilters,
|
||||
onStatsUseFiltersChange,
|
||||
filtersActive,
|
||||
onLoadMore,
|
||||
onNavigateToSource,
|
||||
scrollTopRef,
|
||||
|
|
@ -106,13 +112,41 @@ export function PropertiesPane({
|
|||
</InfoPopup>
|
||||
)}
|
||||
|
||||
<div className="p-2">
|
||||
<div className="p-2 space-y-2">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('propertyCard.searchPlaceholder')}
|
||||
className="p-2"
|
||||
/>
|
||||
{filtersActive && (
|
||||
<div className="grid grid-cols-2 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={statsUseFilters}
|
||||
onClick={() => onStatsUseFiltersChange(true)}
|
||||
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
||||
statsUseFilters
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.matchingFiltersOption')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!statsUseFilters}
|
||||
onClick={() => onStatsUseFiltersChange(false)}
|
||||
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
||||
!statsUseFilters
|
||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
||||
}`}
|
||||
>
|
||||
{t('areaPane.allPropertiesOption')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -307,7 +307,9 @@ export function ActiveFilterList({
|
|||
dragValue={dragValue}
|
||||
pinnedFeature={pinnedFeature}
|
||||
filterImpact={councilBackendName ? filterImpacts?.[councilBackendName] : undefined}
|
||||
percentileScale={councilBackendName ? percentileScales.get(councilBackendName) : undefined}
|
||||
percentileScale={
|
||||
councilBackendName ? percentileScales.get(councilBackendName) : undefined
|
||||
}
|
||||
onFilterChange={onFilterChange}
|
||||
onDragStart={onDragStart}
|
||||
onDragChange={onDragChange}
|
||||
|
|
@ -448,12 +450,12 @@ export function ActiveFilterList({
|
|||
onToggleGroup(group.name);
|
||||
onGroupToggle?.(group.name, !expanded);
|
||||
}}
|
||||
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
className="sticky top-0 z-30 px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
|
||||
>
|
||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
||||
</CollapsibleGroupHeader>
|
||||
{expanded && (
|
||||
<div className="px-2 py-1.5 space-y-3.5">
|
||||
<div className="px-2 py-1 space-y-2">
|
||||
{group.name === TRANSPORT_GROUP && travelCards}
|
||||
{group.features.map((feature) => renderFeatureCard(feature))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ export function ActiveFiltersPanel({
|
|||
>
|
||||
<button
|
||||
onClick={onToggleCollapsed}
|
||||
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
|
||||
className="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">
|
||||
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
|
||||
|
|
@ -202,7 +202,7 @@ export function ActiveFiltersPanel({
|
|||
onLoginRequired={onLoginRequired}
|
||||
/>
|
||||
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||
<p className="px-3 py-1 text-xs text-warm-400 dark:text-warm-500">
|
||||
{t('filters.addFiltersHint')}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,29 @@ import {
|
|||
type PoiFilterName,
|
||||
} from '../../../lib/poi-distance-filter';
|
||||
|
||||
// Each resolver maps a raw pinned feature name to the folded filter card that
|
||||
// represents it in the browser, or returns null when it doesn't belong to that fold.
|
||||
const FOLDED_FILTER_RESOLVERS: Array<(name: string) => string | null> = [
|
||||
(name) => (isSchoolFilterName(name) ? SCHOOL_FILTER_NAME : null),
|
||||
(name) => (isSpecificCrimeFilterName(name) ? SPECIFIC_CRIMES_FILTER_NAME : null),
|
||||
(name) => (isElectionVoteShareFilterName(name) ? ELECTION_VOTE_SHARE_FILTER_NAME : null),
|
||||
(name) => (isEthnicityFilterName(name) ? ETHNICITIES_FILTER_NAME : null),
|
||||
(name) => (isQualificationFilterName(name) ? QUALIFICATIONS_FILTER_NAME : null),
|
||||
(name) => (isTenureFilterName(name) ? TENURE_FILTER_NAME : null),
|
||||
(name) => (isCouncilFilterName(name) ? COUNCIL_FILTER_NAME : null),
|
||||
(name) =>
|
||||
isPoiDistanceFilterName(name) ? (getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME) : null,
|
||||
(name) => (isCrimeSeverityFilterName(name) ? (getCrimeSeverityFilterName(name) ?? name) : null),
|
||||
];
|
||||
|
||||
function resolveBrowserPinnedFeature(name: string): string {
|
||||
for (const resolve of FOLDED_FILTER_RESOLVERS) {
|
||||
const folded = resolve(name);
|
||||
if (folded) return folded;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
interface AddFilterPanelProps {
|
||||
collapsed: boolean;
|
||||
isLoggedIn: boolean;
|
||||
|
|
@ -91,26 +114,7 @@ export function AddFilterPanel({
|
|||
const { t } = useTranslation();
|
||||
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
|
||||
|
||||
const browserPinnedFeature =
|
||||
pinnedFeature && isSchoolFilterName(pinnedFeature)
|
||||
? SCHOOL_FILTER_NAME
|
||||
: pinnedFeature && isSpecificCrimeFilterName(pinnedFeature)
|
||||
? SPECIFIC_CRIMES_FILTER_NAME
|
||||
: pinnedFeature && isElectionVoteShareFilterName(pinnedFeature)
|
||||
? ELECTION_VOTE_SHARE_FILTER_NAME
|
||||
: pinnedFeature && isEthnicityFilterName(pinnedFeature)
|
||||
? ETHNICITIES_FILTER_NAME
|
||||
: pinnedFeature && isQualificationFilterName(pinnedFeature)
|
||||
? QUALIFICATIONS_FILTER_NAME
|
||||
: pinnedFeature && isTenureFilterName(pinnedFeature)
|
||||
? TENURE_FILTER_NAME
|
||||
: pinnedFeature && isCouncilFilterName(pinnedFeature)
|
||||
? COUNCIL_FILTER_NAME
|
||||
: pinnedFeature && isPoiDistanceFilterName(pinnedFeature)
|
||||
? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME)
|
||||
: pinnedFeature && isCrimeSeverityFilterName(pinnedFeature)
|
||||
? (getCrimeSeverityFilterName(pinnedFeature) ?? pinnedFeature)
|
||||
: pinnedFeature;
|
||||
const browserPinnedFeature = pinnedFeature && resolveBrowserPinnedFeature(pinnedFeature);
|
||||
|
||||
const handleTogglePin = (name: string) => {
|
||||
if (name === SCHOOL_FILTER_NAME) {
|
||||
|
|
@ -163,7 +167,7 @@ export function AddFilterPanel({
|
|||
>
|
||||
<button
|
||||
onClick={onToggleCollapsed}
|
||||
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
|
||||
className="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">
|
||||
{t('filters.addFilter')}
|
||||
|
|
@ -176,7 +180,7 @@ export function AddFilterPanel({
|
|||
{(!collapsed || !isLicensed) && (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{!collapsed && (
|
||||
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto md:min-h-[12rem]">
|
||||
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto">
|
||||
<FeatureBrowser
|
||||
availableFeatures={availableFeatures}
|
||||
allFeatures={allFeatures}
|
||||
|
|
@ -194,7 +198,7 @@ export function AddFilterPanel({
|
|||
</div>
|
||||
)}
|
||||
{!isLicensed && (
|
||||
<div className="mt-auto shrink-0 flex flex-col items-center gap-2 px-3 pt-3 pb-2.5 border-t border-warm-200 dark:border-warm-700">
|
||||
<div className="mt-auto shrink-0 flex flex-col items-center gap-1.5 px-3 pt-2 pb-2 border-t border-warm-200 dark:border-warm-700">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-snug">
|
||||
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '}
|
||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||
|
|
@ -203,7 +207,7 @@ export function AddFilterPanel({
|
|||
</p>
|
||||
<button
|
||||
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick}
|
||||
className="w-full px-5 py-2 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||
className="w-full px-5 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||
>
|
||||
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
className={`space-y-1 px-2 py-1 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -151,7 +151,7 @@ export function ElectionVoteShareFilterCard({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.party')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export function EnumFeatureFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`space-y-0.5 px-2 py-1 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap />
|
||||
|
|
@ -47,7 +47,7 @@ export function EnumFeatureFilterCard({
|
|||
onRemove={onRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
<PillGroup>
|
||||
<PillGroup bleed>
|
||||
{allValues.map((val) => (
|
||||
<PillToggle
|
||||
key={val}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export function EthnicityFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={ETHNICITIES_FILTER_NAME}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
className={`space-y-1 px-2 py-1 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -147,7 +147,7 @@ export function EthnicityFilterCard({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.ethnicity')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export function NumericFeatureFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={feature.name}
|
||||
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`space-y-0.5 px-2 py-1 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export function PoiDistanceFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={filterName}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
className={`space-y-1 px-2 py-1 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -156,7 +156,7 @@ export function PoiDistanceFilterCard({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.poiType')}
|
||||
</label>
|
||||
<PoiTypeDropdown
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export function SchoolFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={SCHOOL_FILTER_NAME}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
className={`space-y-1 px-2 py-1 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||
>
|
||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||
<FeatureLabel
|
||||
|
|
@ -127,7 +127,7 @@ export function SchoolFilterCard({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t('filters.schoolType')}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ function EditableLabel({
|
|||
if (e.key === 'Escape') setEditing(false);
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -118,7 +120,7 @@ export function SliderLabels({
|
|||
|
||||
if (feature && onValueChange) {
|
||||
return (
|
||||
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<div className="relative h-4 mt-1.5 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||
<EditableLabel
|
||||
value={labels[0]}
|
||||
formatted={minLabel}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export function VariantFilterCard({
|
|||
return (
|
||||
<div
|
||||
data-filter-name={config.filterName}
|
||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||
className={`space-y-1 px-2 py-1 rounded ${
|
||||
isActive
|
||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||
: isPinned
|
||||
|
|
@ -190,7 +190,7 @@ export function VariantFilterCard({
|
|||
so the dropdown is hidden and only the window toggle + slider remain. */}
|
||||
{variantOptions.length > 1 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t(config.dropdownLabelKey)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
|
|
@ -216,7 +216,7 @@ export function VariantFilterCard({
|
|||
{windowConfig && currentWindow && windowOptions.length > 1 && (
|
||||
<div>
|
||||
{windowConfig.labelKey && (
|
||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||
{t(windowConfig.labelKey)}
|
||||
</label>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { HouseIcon } from '../../ui/icons/HouseIcon';
|
|||
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo, PaneResizeHandlers } from './types';
|
||||
import { MapControlButton, type MapControlState } from './MapControlButton';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
import { MapErrorBoundary } from '../MapErrorBoundary';
|
||||
import { LoadingOverlay } from './LoadingOverlay';
|
||||
|
|
@ -74,9 +75,11 @@ interface DesktopMapPageProps {
|
|||
poiPaneOpen: boolean;
|
||||
onTogglePoiPane: () => void;
|
||||
poiPane: ReactNode;
|
||||
poiControl: MapControlState;
|
||||
overlayPaneOpen: boolean;
|
||||
onToggleOverlayPane: () => void;
|
||||
overlayPane: ReactNode;
|
||||
overlayControl: MapControlState;
|
||||
showSelectionPane: boolean;
|
||||
rightPaneWidth: number;
|
||||
rightPaneHandlers: PaneResizeHandlers;
|
||||
|
|
@ -132,9 +135,11 @@ export function DesktopMapPage({
|
|||
poiPaneOpen,
|
||||
onTogglePoiPane,
|
||||
poiPane,
|
||||
poiControl,
|
||||
overlayPaneOpen,
|
||||
onToggleOverlayPane,
|
||||
overlayPane,
|
||||
overlayControl,
|
||||
showSelectionPane,
|
||||
rightPaneWidth,
|
||||
rightPaneHandlers,
|
||||
|
|
@ -179,7 +184,7 @@ export function DesktopMapPage({
|
|||
>
|
||||
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
|
||||
<div
|
||||
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
className="w-2 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
|
||||
{...leftPaneHandlers}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
|
@ -255,22 +260,24 @@ export function DesktopMapPage({
|
|||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
data-tutorial="overlays-button"
|
||||
<MapControlButton
|
||||
{...overlayControl}
|
||||
dataTutorial="overlays-button"
|
||||
label={t('overlays.heading')}
|
||||
paneOpen={overlayPaneOpen}
|
||||
showLabel
|
||||
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'}`}
|
||||
>
|
||||
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
||||
<span className="text-sm font-medium">{t('overlays.heading')}</span>
|
||||
</button>
|
||||
<button
|
||||
data-tutorial="poi-button"
|
||||
icon={(highlighted) => <EyeIcon className="h-5 w-5" filled={highlighted} />}
|
||||
/>
|
||||
<MapControlButton
|
||||
{...poiControl}
|
||||
dataTutorial="poi-button"
|
||||
label={t('poiPane.pointsOfInterest')}
|
||||
paneOpen={poiPaneOpen}
|
||||
showLabel
|
||||
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'}`}
|
||||
>
|
||||
<MapPinIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">{t('poiPane.pointsOfInterest')}</span>
|
||||
</button>
|
||||
icon={() => <MapPinIcon className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
73
frontend/src/components/map/map-page/MapControlButton.tsx
Normal file
73
frontend/src/components/map/map-page/MapControlButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import { HouseIcon } from '../../ui/icons/HouseIcon';
|
|||
import { SpinnerIcon } from '../../ui/icons/SpinnerIcon';
|
||||
import { IndeterminateProgressBar } from '../../ui/IndeterminateProgressBar';
|
||||
import type { MapFlyTo } from './types';
|
||||
import { MapControlButton, type MapControlState } from './MapControlButton';
|
||||
import { MapFallback, PaneFallback } from './Fallbacks';
|
||||
import { MapErrorBoundary } from '../MapErrorBoundary';
|
||||
import { LoadingOverlay } from './LoadingOverlay';
|
||||
|
|
@ -72,9 +73,11 @@ interface MobileMapPageProps {
|
|||
onTogglePoiPane: () => void;
|
||||
poiButtonLabel: string;
|
||||
poiPane: ReactNode;
|
||||
poiControl: MapControlState;
|
||||
overlayPaneOpen: boolean;
|
||||
onToggleOverlayPane: () => void;
|
||||
overlayPane: ReactNode;
|
||||
overlayControl: MapControlState;
|
||||
filtersPane: ReactNode;
|
||||
mobileLegend: ReactNode;
|
||||
renderAreaPane: () => ReactNode;
|
||||
|
|
@ -127,9 +130,11 @@ export function MobileMapPage({
|
|||
onTogglePoiPane,
|
||||
poiButtonLabel,
|
||||
poiPane,
|
||||
poiControl,
|
||||
overlayPaneOpen,
|
||||
onToggleOverlayPane,
|
||||
overlayPane,
|
||||
overlayControl,
|
||||
filtersPane,
|
||||
mobileLegend,
|
||||
renderAreaPane,
|
||||
|
|
@ -220,20 +225,22 @@ export function MobileMapPage({
|
|||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
<MapControlButton
|
||||
{...overlayControl}
|
||||
label={t('overlays.heading')}
|
||||
paneOpen={overlayPaneOpen}
|
||||
showLabel={false}
|
||||
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'}`}
|
||||
aria-label={t('overlays.heading')}
|
||||
>
|
||||
<EyeIcon className="h-5 w-5" filled={overlayPaneOpen} />
|
||||
</button>
|
||||
<button
|
||||
icon={(highlighted) => <EyeIcon className="h-5 w-5" filled={highlighted} />}
|
||||
/>
|
||||
<MapControlButton
|
||||
{...poiControl}
|
||||
label={poiButtonLabel}
|
||||
paneOpen={poiPaneOpen}
|
||||
showLabel={false}
|
||||
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'}`}
|
||||
aria-label={poiButtonLabel}
|
||||
>
|
||||
<MapPinIcon className="h-5 w-5" />
|
||||
</button>
|
||||
icon={() => <MapPinIcon className="h-5 w-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{listingsPaneOpen && (
|
||||
|
|
|
|||
63
frontend/src/components/ui/AuthModal.test.tsx
Normal file
63
frontend/src/components/ui/AuthModal.test.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../lib/analytics', () => ({ trackEvent: () => {} }));
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-i18next')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}</>,
|
||||
}));
|
||||
|
||||
import AuthModal from './AuthModal';
|
||||
|
||||
const noop = async () => {};
|
||||
|
||||
function renderModal(initialTab: 'login' | 'register' = 'login') {
|
||||
return render(
|
||||
<AuthModal
|
||||
onClose={() => {}}
|
||||
onLogin={noop}
|
||||
onRegister={noop}
|
||||
onOAuthLogin={noop}
|
||||
onForgotPassword={noop}
|
||||
loading={false}
|
||||
error={null}
|
||||
onClearError={() => {}}
|
||||
initialTab={initialTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AuthModal password visibility toggle', () => {
|
||||
it('starts hidden and reveals the password when clicked', () => {
|
||||
renderModal('login');
|
||||
|
||||
const input = screen.getByLabelText('auth.password') as HTMLInputElement;
|
||||
expect(input.type).toBe('password');
|
||||
|
||||
// Hidden state exposes a "show" toggle.
|
||||
const toggle = screen.getByRole('button', { name: 'auth.showPassword' });
|
||||
expect(toggle.getAttribute('type')).toBe('button');
|
||||
expect(toggle.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(input.type).toBe('text');
|
||||
const hideToggle = screen.getByRole('button', { name: 'auth.hidePassword' });
|
||||
expect(hideToggle.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
fireEvent.click(hideToggle);
|
||||
expect(input.type).toBe('password');
|
||||
});
|
||||
|
||||
it('offers the toggle on the register tab too', () => {
|
||||
renderModal('register');
|
||||
const input = screen.getByLabelText('auth.password') as HTMLInputElement;
|
||||
expect(input.type).toBe('password');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'auth.showPassword' }));
|
||||
expect(input.type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,8 @@ import { Trans, useTranslation } from 'react-i18next';
|
|||
import type { ParseKeys } from 'i18next';
|
||||
import { CloseIcon } from './icons/CloseIcon';
|
||||
import { GoogleIcon } from './icons/GoogleIcon';
|
||||
import { EyeIcon } from './icons/EyeIcon';
|
||||
import { EyeOffIcon } from './icons/EyeOffIcon';
|
||||
import { trackEvent } from '../../lib/analytics';
|
||||
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||
import type { AuthErrorAction } from '../../lib/auth-errors';
|
||||
|
|
@ -42,6 +44,7 @@ export default function AuthModal({
|
|||
const [view, setView] = useState<View>(initialTab);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
const dialogRef = useModalA11y();
|
||||
const fieldId = useId();
|
||||
|
|
@ -232,22 +235,38 @@ export default function AuthModal({
|
|||
>
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<input
|
||||
id={passwordInputId}
|
||||
name="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={view === 'register' ? 'new-password' : 'current-password'}
|
||||
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={
|
||||
view === 'register'
|
||||
? t('auth.passwordPlaceholderRegister')
|
||||
: t('auth.passwordPlaceholderLogin')
|
||||
}
|
||||
/>
|
||||
<div className="relative">
|
||||
<input
|
||||
id={passwordInputId}
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete={view === 'register' ? 'new-password' : 'current-password'}
|
||||
className="w-full pl-3 pr-10 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={
|
||||
view === 'register'
|
||||
? t('auth.passwordPlaceholderRegister')
|
||||
: t('auth.passwordPlaceholderLogin')
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||
aria-pressed={showPassword}
|
||||
title={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 outline-none focus-visible:text-teal-600 dark:focus-visible:text-teal-400"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeIcon filled={false} className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{view === 'login' && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export type Page =
|
|||
| 'privacy'
|
||||
| 'account'
|
||||
| 'saved'
|
||||
| 'invite';
|
||||
| 'invite'
|
||||
| 'reset-password';
|
||||
|
||||
export interface HeaderExportState {
|
||||
onExport: (options?: { postcodes?: string[] }) => void;
|
||||
|
|
@ -65,6 +66,7 @@ export const PAGE_PATHS: Record<Page, string> = {
|
|||
saved: '/saved',
|
||||
account: '/account',
|
||||
invite: '/invite',
|
||||
'reset-password': '/reset-password',
|
||||
};
|
||||
|
||||
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
|
||||
|
|
@ -206,33 +208,6 @@ export default function Header({
|
|||
return (
|
||||
<>
|
||||
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
|
||||
{showEditingBar && (
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-0 flex items-center justify-center px-4">
|
||||
<div className="pointer-events-auto flex items-center gap-3 max-w-[60%]">
|
||||
<span className="text-sm text-warm-300 truncate" title={editingSearch.name}>
|
||||
<Trans
|
||||
i18nKey="savedPage.isBeingUpdated"
|
||||
values={{ name: editingSearch.name }}
|
||||
components={{ strong: <strong className="font-semibold text-white" /> }}
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCancelEdit}
|
||||
className="cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUpdateEdit}
|
||||
disabled={savingSearch || dashboardActionsBlocked}
|
||||
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Left: Logo + nav */}
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<a
|
||||
|
|
@ -246,9 +221,9 @@ export default function Header({
|
|||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop nav: hidden while the saved-search "is being updated" banner
|
||||
is shown so the centered pointer-events-auto banner can't overlap (and
|
||||
block clicks on) the Invite Friends / Learn / Pricing links at ~1366px. */}
|
||||
{/* Desktop nav: hidden while the saved-search "is being updated" bar is
|
||||
shown so the centered edit bar has room and the header can't get
|
||||
cramped (the bar would otherwise crowd the Learn / Pricing links). */}
|
||||
{!useSidebarNav && !showEditingBar && (
|
||||
<nav className="top-menu flex items-center">
|
||||
<a
|
||||
|
|
@ -287,6 +262,36 @@ export default function Header({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Saved-search "is being updated" bar. In normal flow (not an absolute
|
||||
overlay) between the logo and the right-side actions so flexbox
|
||||
reserves its space: the Cancel / Update buttons can never overlap the
|
||||
Share / Export buttons. Text truncates; the buttons stay put. */}
|
||||
{showEditingBar && (
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-3 px-3">
|
||||
<span className="min-w-0 truncate text-sm text-warm-300" title={editingSearch.name}>
|
||||
<Trans
|
||||
i18nKey="savedPage.isBeingUpdated"
|
||||
values={{ name: editingSearch.name }}
|
||||
components={{ strong: <strong className="font-semibold text-white" /> }}
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCancelEdit}
|
||||
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUpdateEdit}
|
||||
disabled={savingSearch || dashboardActionsBlocked}
|
||||
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{/* Desktop-only dashboard actions: shown to everyone; a logged-out click
|
||||
|
|
|
|||
|
|
@ -4,16 +4,30 @@ interface PillGroupProps {
|
|||
children: ReactNode;
|
||||
className?: string;
|
||||
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 (
|
||||
<div
|
||||
className={`flex min-w-0 max-w-full gap-1.5 ${
|
||||
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}`}
|
||||
className={`flex min-w-0 gap-1.5 ${wrap ? 'max-w-full flex-wrap overflow-x-visible' : scroll} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
22
frontend/src/components/ui/icons/EyeOffIcon.tsx
Normal file
22
frontend/src/components/ui/icons/EyeOffIcon.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EyeOffIcon({ className = 'w-7 h-7' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
||||
<path d="M6.61 6.61A13.526 13.526 0 0 0 1 12s4 8 11 8a9.74 9.74 0 0 0 5.39-1.61" />
|
||||
<line x1="2" y1="2" x2="22" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ export { CloseIcon } from './CloseIcon';
|
|||
export { DownloadIcon } from './DownloadIcon';
|
||||
export { ExpandIcon } from './ExpandIcon';
|
||||
export { EyeIcon } from './EyeIcon';
|
||||
export { EyeOffIcon } from './EyeOffIcon';
|
||||
export { FilterIcon } from './FilterIcon';
|
||||
export { GoogleIcon } from './GoogleIcon';
|
||||
export { GraduationCapIcon } from './GraduationCapIcon';
|
||||
|
|
|
|||
|
|
@ -32,6 +32,16 @@ function authRefreshInvalidated(error: unknown): boolean {
|
|||
return status === 401 || status === 403;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an email so login and registration are case- and
|
||||
* whitespace-insensitive: ` John@Example.com` and `john@example.com`
|
||||
* resolve to the same account. Applied at the PocketBase boundary rather
|
||||
* than on the input field, so the user still sees exactly what they typed.
|
||||
*/
|
||||
function normalizeEmail(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const { t } = useTranslation();
|
||||
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||
|
|
@ -62,7 +72,9 @@ export function useAuth() {
|
|||
setError(null);
|
||||
setErrorAction(null);
|
||||
try {
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
const result = await pb
|
||||
.collection('users')
|
||||
.authWithPassword(normalizeEmail(email), password);
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Login', { method: 'email' });
|
||||
} catch (err) {
|
||||
|
|
@ -82,12 +94,13 @@ export function useAuth() {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
setErrorAction(null);
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
try {
|
||||
// Step 1: create the account. A failure here means nothing was created,
|
||||
// so surface a friendly, field-aware message (e.g. "email already exists").
|
||||
try {
|
||||
await pb.collection('users').create({
|
||||
email,
|
||||
email: normalizedEmail,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
newsletter: true,
|
||||
|
|
@ -103,7 +116,7 @@ export function useAuth() {
|
|||
// orphaned: tell the user it was created and let them log in with the
|
||||
// same credentials, instead of a misleading "registration failed".
|
||||
try {
|
||||
const result = await pb.collection('users').authWithPassword(email, password);
|
||||
const result = await pb.collection('users').authWithPassword(normalizedEmail, password);
|
||||
setUser(recordToUser(result.record));
|
||||
trackEvent('Register');
|
||||
} catch (err) {
|
||||
|
|
@ -159,7 +172,7 @@ export function useAuth() {
|
|||
setError(null);
|
||||
setErrorAction(null);
|
||||
try {
|
||||
await pb.collection('users').requestPasswordReset(email);
|
||||
await pb.collection('users').requestPasswordReset(normalizeEmail(email));
|
||||
} catch (err) {
|
||||
const { message, action } = resolveAuthError(err, 'reset', t);
|
||||
setError(message);
|
||||
|
|
@ -172,6 +185,27 @@ export function useAuth() {
|
|||
[t]
|
||||
);
|
||||
|
||||
const confirmPasswordReset = useCallback(
|
||||
async (token: string, password: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setErrorAction(null);
|
||||
try {
|
||||
// PocketBase requires the password twice; the reset form uses a single
|
||||
// (show/hide) field, so both arguments are the same value.
|
||||
await pb.collection('users').confirmPasswordReset(token, password, password);
|
||||
} catch (err) {
|
||||
const { message, action } = resolveAuthError(err, 'confirmReset', t);
|
||||
setError(message);
|
||||
setErrorAction(action);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const refreshAuth = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -208,6 +242,7 @@ export function useAuth() {
|
|||
loginWithOAuth,
|
||||
logout,
|
||||
requestPasswordReset,
|
||||
confirmPasswordReset,
|
||||
refreshAuth,
|
||||
clearError,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ function currentUserId(): string | null {
|
|||
*
|
||||
* Reads the user from the shared PocketBase authStore so it stays self-contained, with no need to
|
||||
* thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in
|
||||
* the map's colour accessor; a fresh Set instance is produced on every change so it can double
|
||||
* as a deck.gl `updateTrigger` (identity-compared).
|
||||
* the map's colour accessor; a fresh Set instance is produced on every change so React re-renders.
|
||||
* Note: a Set can NOT be used directly as a deck.gl `updateTrigger` — deck diffs triggers with
|
||||
* `compareProps`, which finds no enumerable keys on a Set and reports "no change". Callers must
|
||||
* pass `Array.from(clickedUrls)` (or a version counter) as the trigger instead.
|
||||
*
|
||||
* Writes are optimistic: `markClicked` updates local state synchronously so the pin recolours
|
||||
* the instant it is clicked, independent of the PocketBase round-trip or the next listings fetch.
|
||||
|
|
@ -54,8 +56,11 @@ export function useClickedListings() {
|
|||
.filter((url): url is string => typeof url === 'string');
|
||||
setClickedUrls(new Set(urls));
|
||||
})
|
||||
.catch(() => {
|
||||
// Visited history is a convenience; a failed load just starts the session empty.
|
||||
.catch((error) => {
|
||||
// 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 () => {
|
||||
cancelled = true;
|
||||
|
|
@ -72,9 +77,12 @@ export function useClickedListings() {
|
|||
if (!uid) return; // anonymous: recolour for the session, nothing to persist
|
||||
pb.collection(CLICKED_LISTINGS_COLLECTION)
|
||||
.create({ user: uid, url })
|
||||
.catch(() => {
|
||||
// Ignore duplicate-index conflicts and transient failures; local state already
|
||||
// reflects the click, and a missed write only means it won't persist to the next visit.
|
||||
.catch((error: unknown) => {
|
||||
// A 400 is the idx_clicked_listings_user_url unique index rejecting a re-click that raced
|
||||
// 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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -93,23 +93,23 @@ interface UseFiltersOptions {
|
|||
onFilterLimitReached?: () => void;
|
||||
}
|
||||
|
||||
// Applied in order: each normalizer folds its own raw feature names into a single
|
||||
// folded filter. Council folds AFTER tenure so a bare "% Social rent" is claimed by
|
||||
// tenure first.
|
||||
const FILTER_NORMALIZERS: Array<(filters: FeatureFilters) => FeatureFilters> = [
|
||||
normalizeSchoolFilters,
|
||||
normalizeSpecificCrimeFilters,
|
||||
normalizeCrimeSeverityFilters,
|
||||
normalizeElectionVoteShareFilters,
|
||||
normalizeEthnicityFilters,
|
||||
normalizeQualificationFilters,
|
||||
normalizeTenureFilters,
|
||||
normalizeCouncilFilters,
|
||||
normalizePoiDistanceFilters,
|
||||
];
|
||||
|
||||
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
||||
return normalizePoiDistanceFilters(
|
||||
// Council folds AFTER tenure so a bare "% Social rent" is claimed by tenure.
|
||||
normalizeCouncilFilters(
|
||||
normalizeTenureFilters(
|
||||
normalizeQualificationFilters(
|
||||
normalizeEthnicityFilters(
|
||||
normalizeElectionVoteShareFilters(
|
||||
normalizeCrimeSeverityFilters(
|
||||
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
return FILTER_NORMALIZERS.reduce((acc, normalize) => normalize(acc), filters);
|
||||
}
|
||||
|
||||
function getBackendFeatureName(name: string): string {
|
||||
|
|
@ -215,7 +215,10 @@ export function useFilters({
|
|||
const normalizedOriginal = normalizeFilters(initialFilters);
|
||||
originalNormalizedRef.current = normalizedOriginal;
|
||||
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!);
|
||||
|
|
|
|||
|
|
@ -385,7 +385,9 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
highlightColor: [29, 228, 195, 220],
|
||||
onHover: stableHover,
|
||||
onClick: stableClick,
|
||||
updateTriggers: { getFillColor: clickedUrls },
|
||||
// deck.gl can't diff a Set (no enumerable keys), so a click never
|
||||
// re-runs getFillColor until data changes. Feed it a diffable array.
|
||||
updateTriggers: { getFillColor: Array.from(clickedUrls) },
|
||||
}),
|
||||
[visibleListings, clickedUrls, stableHover, stableClick]
|
||||
);
|
||||
|
|
@ -483,7 +485,9 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
|||
highlightColor: [29, 228, 195, 220],
|
||||
onHover: stableExpandedHover,
|
||||
onClick: stableExpandedClick,
|
||||
updateTriggers: { getFillColor: clickedUrls },
|
||||
// deck.gl can't diff a Set (no enumerable keys), so a click never
|
||||
// re-runs getFillColor until data changes. Feed it a diffable array.
|
||||
updateTriggers: { getFillColor: Array.from(clickedUrls) },
|
||||
}),
|
||||
[expandedListings, clickedUrls, stableExpandedHover, stableExpandedClick]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -42,7 +42,10 @@ async function detectSessionRejection(
|
|||
onSessionRejected?: () => void
|
||||
): Promise<void> {
|
||||
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') {
|
||||
onSessionRejected();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { apiUrl, logNonAbortError, authHeaders } from '../lib/api';
|
|||
|
||||
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 debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -14,7 +14,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
requestIdRef.current += 1;
|
||||
const requestId = requestIdRef.current;
|
||||
|
||||
if (!bounds || selectedCategories.size === 0) {
|
||||
if (!bounds || selectedCategories.size === 0 || !enabled) {
|
||||
abortControllerRef.current?.abort();
|
||||
setPois([]);
|
||||
return;
|
||||
|
|
@ -58,7 +58,7 @@ export function usePOIData(bounds: Bounds | null, selectedCategories: Set<string
|
|||
}
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [bounds, selectedCategories]);
|
||||
}, [bounds, selectedCategories, enabled]);
|
||||
|
||||
return pois;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react';
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { POI } from '../types';
|
||||
import { POI_ZOOM_THRESHOLD } from '../lib/consts';
|
||||
import { usePoiLayers } from './usePoiLayers';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
|
|
@ -150,9 +151,11 @@ describe('usePoiLayers', () => {
|
|||
});
|
||||
|
||||
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(
|
||||
({ 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([]);
|
||||
|
|
@ -164,6 +167,26 @@ describe('usePoiLayers', () => {
|
|||
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', () => {
|
||||
const { result } = renderHook(() =>
|
||||
usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
|
||||
|
|
@ -248,8 +271,10 @@ describe('usePoiLayers', () => {
|
|||
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(() =>
|
||||
usePoiLayers({ pois: clusteredPois, zoom: 5, isDark: true })
|
||||
usePoiLayers({ pois: clusteredPois, zoom: 14, isDark: true })
|
||||
);
|
||||
const clusterLayer = layerById(result.current.poiLayers, 'poi-clusters');
|
||||
const clusters = clusterLayer.props.data as Array<{ count: number; lng: number; lat: number }>;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
MINOR_POI_ZOOM_THRESHOLD,
|
||||
POI_CLUSTER_RADIUS,
|
||||
POI_CLUSTER_MAX_ZOOM,
|
||||
POI_ZOOM_THRESHOLD,
|
||||
} from '../lib/consts';
|
||||
import { getPoiIconUrl } from '../lib/map-utils';
|
||||
|
||||
|
|
@ -149,9 +150,14 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) {
|
|||
|
||||
const clusterZoom = Math.floor(zoom);
|
||||
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(() => {
|
||||
if (!clusterIndex || pois.length === 0) {
|
||||
if (!clusterIndex || pois.length === 0 || !poisZoomedIn) {
|
||||
return { visiblePois: [] as POI[], clusters: [] as ClusterPoint[] };
|
||||
}
|
||||
// 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 };
|
||||
}, [clusterIndex, clusterZoom, showMinorPois, pois]);
|
||||
}, [clusterIndex, clusterZoom, showMinorPois, poisZoomedIn, pois]);
|
||||
|
||||
const poiShadowLayer = useMemo(
|
||||
() =>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import pb from '../lib/pocketbase';
|
||||
import { apiUrl, authHeaders } from '../lib/api';
|
||||
import { trackEvent } from '../lib/analytics';
|
||||
import { stripSelectedPostcodeParam } from '../lib/url-state';
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
|
|
@ -141,7 +142,11 @@ export function useSavedSearches(userId: string | null) {
|
|||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = paramsOverride ?? window.location.search.replace(/^\?/, '');
|
||||
// A saved search stores filter criteria, so drop the transient
|
||||
// selected-postcode param (a search/map-click leaves it in the URL).
|
||||
const params = stripSelectedPostcodeParam(
|
||||
paramsOverride ?? window.location.search.replace(/^\?/, '')
|
||||
);
|
||||
|
||||
// Create record immediately without screenshot
|
||||
const formData = new FormData();
|
||||
|
|
@ -220,11 +225,14 @@ export function useSavedSearches(userId: string | null) {
|
|||
);
|
||||
|
||||
const updateSearchParams = useCallback(
|
||||
async (id: string, params: string) => {
|
||||
async (id: string, rawParams: string) => {
|
||||
if (!userId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Match saveSearch: a saved search never carries the transient
|
||||
// selected-postcode param.
|
||||
const params = stripSelectedPostcodeParam(rawParams);
|
||||
const record = await pb.collection('saved_searches').update(id, { params });
|
||||
trackEvent('Search Update');
|
||||
setSearches((prev) =>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
'% Council housing':
|
||||
'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 s’identifiant comme blanche',
|
||||
'% South Asian': 'Part de la population s’identifiant comme sud-asiatique',
|
||||
'% Black': 'Part de la population s’identifiant comme noire',
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const de: Translations = {
|
|||
minute: 'Min.',
|
||||
or: 'oder',
|
||||
area: 'Gebiet',
|
||||
properties: 'Immobilien',
|
||||
properties: 'Verkaufte Immobilien',
|
||||
postcode: 'Postcode',
|
||||
noAreaSelected: 'Kein Gebiet ausgewählt',
|
||||
noAreaSelectedDesc:
|
||||
|
|
@ -631,6 +631,8 @@ const de: Translations = {
|
|||
email: 'E-Mail',
|
||||
emailPlaceholder: 'name@beispiel.de',
|
||||
password: 'Passwort',
|
||||
showPassword: 'Passwort anzeigen',
|
||||
hidePassword: 'Passwort verbergen',
|
||||
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
|
||||
passwordPlaceholderLogin: 'Dein Passwort',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
|
|
@ -644,6 +646,16 @@ const de: Translations = {
|
|||
registrationFailed: 'Registrierung fehlgeschlagen',
|
||||
oauthFailed: 'OAuth-Anmeldung fehlgeschlagen',
|
||||
passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts fehlgeschlagen',
|
||||
newPassword: 'Neues Passwort',
|
||||
newPasswordPlaceholder: 'Mindestens 8 Zeichen',
|
||||
updatePassword: 'Passwort aktualisieren',
|
||||
resetSuccessBody:
|
||||
'Dein Passwort wurde geändert. Du kannst dich jetzt mit deinem neuen Passwort anmelden.',
|
||||
resetMissingToken:
|
||||
'Dieser Link zum Zurücksetzen ist unvollständig. Fordere über die Anmeldeseite einen neuen an.',
|
||||
resetInvalidLink:
|
||||
'Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Fordere über die Anmeldeseite einen neuen an.',
|
||||
goToLogin: 'Zur Anmeldung',
|
||||
},
|
||||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
|
|
@ -951,6 +963,10 @@ const de: Translations = {
|
|||
viewProperties: '{{count}} Immobilien ansehen',
|
||||
viewPropertiesShort: 'Immobilien ansehen',
|
||||
priceHistory: 'Preisentwicklung',
|
||||
priceHistoryThisArea: 'Dieses Gebiet',
|
||||
priceMetric: 'Preisbasis',
|
||||
priceMetricTotal: 'Gesamt',
|
||||
priceMetricPerSqm: 'Pro m²',
|
||||
journeysFrom: 'Fahrzeiten für {{label}}',
|
||||
to: 'Nach {{destination}}',
|
||||
noJourneyData: 'Keine Verbindungsdaten verfügbar',
|
||||
|
|
@ -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.',
|
||||
searchCategories: 'Kategorien durchsuchen...',
|
||||
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 ──────────────────────────
|
||||
|
|
@ -1025,7 +1043,6 @@ const de: Translations = {
|
|||
|
||||
// ── Home Page ──────────────────────────────────────
|
||||
home: {
|
||||
heroEyebrow: 'Für Käufer, die nicht für den Ruf eines Postcodes zu viel zahlen wollen',
|
||||
heroTitle1: 'Finde den',
|
||||
heroTitle2: 'Geheimtipp-Postcode',
|
||||
heroTitle3: 'Gleiche Schulen, gleicher Pendelweg, und still günstiger.',
|
||||
|
|
@ -1094,8 +1111,6 @@ const de: Translations = {
|
|||
statFilters: 'kombinierbare Filter',
|
||||
statEvery: 'Jeder',
|
||||
statPostcodeInEngland: 'Postcode in England',
|
||||
coverageNote:
|
||||
'Deckt jeden Postcode in England ab, je 200+ Datenfelder. Schottland und Wales sind in Planung.',
|
||||
priceStrip:
|
||||
'Einmal zahlen für lebenslangen Zugang: aktuell {{price}}, steigend, sobald sich jede Stufe füllt.',
|
||||
priceStripSpots: 'Noch {{count}} Platz zu diesem Preis.',
|
||||
|
|
@ -1576,6 +1591,10 @@ const de: Translations = {
|
|||
listing: 'Inserat',
|
||||
showingOf: '{{visible}} von {{count}} werden angezeigt',
|
||||
groupedNear: 'Gruppiert an dieser Kartenposition',
|
||||
priceHistory: 'Preisverlauf',
|
||||
priceListed: 'Gelistet',
|
||||
priceReduced: 'Reduziert',
|
||||
priceIncreased: 'Erhöht',
|
||||
},
|
||||
|
||||
overlays: {
|
||||
|
|
@ -1896,6 +1915,7 @@ const de: Translations = {
|
|||
'Tube station': 'U-Bahn-Station',
|
||||
'DLR station': 'DLR-Station',
|
||||
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
|
||||
'Any station': 'Beliebige Station',
|
||||
Café: 'Café',
|
||||
Restaurant: 'Restaurant',
|
||||
Pub: 'Pub',
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const en = {
|
|||
minute: 'min',
|
||||
or: 'or',
|
||||
area: 'Area',
|
||||
properties: 'Properties',
|
||||
properties: 'Sold properties',
|
||||
postcode: 'Postcode',
|
||||
noAreaSelected: 'No area selected',
|
||||
noAreaSelectedDesc:
|
||||
|
|
@ -618,6 +618,8 @@ const en = {
|
|||
email: 'Email',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
password: 'Password',
|
||||
showPassword: 'Show password',
|
||||
hidePassword: 'Hide password',
|
||||
passwordPlaceholderRegister: 'Min 8 characters',
|
||||
passwordPlaceholderLogin: 'Your password',
|
||||
forgotPassword: 'Forgot password?',
|
||||
|
|
@ -631,6 +633,15 @@ const en = {
|
|||
registrationFailed: 'Registration failed',
|
||||
oauthFailed: 'OAuth login failed',
|
||||
passwordResetFailed: 'Password reset request failed',
|
||||
newPassword: 'New password',
|
||||
newPasswordPlaceholder: 'At least 8 characters',
|
||||
updatePassword: 'Update password',
|
||||
resetSuccessBody: 'Your password has been changed. You can now log in with your new password.',
|
||||
resetMissingToken:
|
||||
'This password reset link is incomplete. Request a new one from the log in screen.',
|
||||
resetInvalidLink:
|
||||
'This reset link is invalid or has expired. Request a new one from the log in screen.',
|
||||
goToLogin: 'Go to log in',
|
||||
},
|
||||
|
||||
// ── Upgrade Modal ──────────────────────────────────
|
||||
|
|
@ -939,6 +950,10 @@ const en = {
|
|||
viewProperties: 'View {{count}} properties',
|
||||
viewPropertiesShort: 'View properties',
|
||||
priceHistory: 'Price History',
|
||||
priceHistoryThisArea: 'This area',
|
||||
priceMetric: 'Price basis',
|
||||
priceMetricTotal: 'Total',
|
||||
priceMetricPerSqm: 'Per m²',
|
||||
journeysFrom: 'Journey times for {{label}}',
|
||||
to: 'To {{destination}}',
|
||||
noJourneyData: 'No journey data available',
|
||||
|
|
@ -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.',
|
||||
searchCategories: 'Search categories…',
|
||||
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 ──────────────────────────
|
||||
|
|
@ -1013,7 +1030,6 @@ const en = {
|
|||
|
||||
// ── Home Page ──────────────────────────────────────
|
||||
home: {
|
||||
heroEyebrow: 'For buyers who refuse to overpay for a postcode’s reputation',
|
||||
heroTitle1: 'Find the',
|
||||
heroTitle2: 'hidden-gem postcode',
|
||||
heroTitle3: 'Same schools, same commute, quietly cheaper.',
|
||||
|
|
@ -1080,8 +1096,6 @@ const en = {
|
|||
statFilters: 'combinable filters',
|
||||
statEvery: 'Every',
|
||||
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.',
|
||||
priceStripSpots: '{{count}} spot left at this price.',
|
||||
priceStripSpotsPlural: '{{count}} spots left at this price.',
|
||||
|
|
@ -1556,6 +1570,10 @@ const en = {
|
|||
listing: 'Listing',
|
||||
showingOf: 'Showing {{visible}} of {{count}}',
|
||||
groupedNear: 'Grouped near this map position',
|
||||
priceHistory: 'Price history',
|
||||
priceListed: 'Listed',
|
||||
priceReduced: 'Reduced',
|
||||
priceIncreased: 'Increased',
|
||||
},
|
||||
|
||||
// ── Map Overlays Pane ──────────────────────────────
|
||||
|
|
@ -1878,6 +1896,7 @@ const en = {
|
|||
'Tube station': 'Tube station',
|
||||
'DLR station': 'DLR station',
|
||||
'Tram & Metro stop': 'Tram & Metro stop',
|
||||
'Any station': 'Any station',
|
||||
Café: 'Café',
|
||||
Restaurant: 'Restaurant',
|
||||
Pub: 'Pub',
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue