Compare commits
No commits in common. "main" and "fix/audit-findings" have entirely different histories.
main
...
fix/audit-
131 changed files with 2438 additions and 4497 deletions
|
|
@ -23,10 +23,6 @@ jobs:
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: uv sync
|
run: uv sync
|
||||||
|
|
||||||
- name: Install finder (scraper) dependencies
|
|
||||||
working-directory: finder
|
|
||||||
run: uv sync
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -27,5 +27,5 @@ video/auth.*
|
||||||
r5-java/tmp
|
r5-java/tmp
|
||||||
property-data
|
property-data
|
||||||
property-data-snapshot
|
property-data-snapshot
|
||||||
property-data-snapshot*
|
property-data-snapshot2
|
||||||
video/.audit*
|
video/.audit*
|
||||||
|
|
|
||||||
1
VERSION
1
VERSION
|
|
@ -1 +0,0 @@
|
||||||
0.1.0
|
|
||||||
|
|
@ -18,6 +18,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(".")
|
ROOT = Path(".")
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ def twin_kit(f: dict) -> str:
|
||||||
]
|
]
|
||||||
captions = [
|
captions = [
|
||||||
f"{pn} vs {wn}",
|
f"{pn} vs {wn}",
|
||||||
"Same station. Same schools.",
|
f"Same station. Same schools.",
|
||||||
f"{money} cheaper",
|
f"{money} cheaper",
|
||||||
f"Same {typ}, ~{s['build_year']}",
|
f"Same {typ}, ~{s['build_year']}",
|
||||||
f"{gap} less per m²",
|
f"{gap} less per m²",
|
||||||
|
|
@ -135,7 +135,7 @@ def twin_kit(f: dict) -> str:
|
||||||
"",
|
"",
|
||||||
f"**Page:** {SITE}{f['page_path']} · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut",
|
f"**Page:** {SITE}{f['page_path']} · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut",
|
||||||
"",
|
"",
|
||||||
"## 🎬 Map URL to record (open this, hit record)",
|
f"## 🎬 Map URL to record (open this, hit record)",
|
||||||
f"`{url}`",
|
f"`{url}`",
|
||||||
"*(filters are pre-applied so the value is on screen immediately)*",
|
"*(filters are pre-applied so the value is on screen immediately)*",
|
||||||
"",
|
"",
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,9 @@ def curate(tw: pl.DataFrame) -> list[dict]:
|
||||||
|
|
||||||
|
|
||||||
def national_findings(idx: pl.DataFrame, facts: dict) -> list[dict]:
|
def national_findings(idx: pl.DataFrame, facts: dict) -> list[dict]:
|
||||||
|
named = idx.with_columns(
|
||||||
|
pl.col("Sector").str.extract(r"^([A-Z]+[0-9][A-Z0-9]?)", 1).alias("outward")
|
||||||
|
)
|
||||||
best = facts["best_value_sector"]
|
best = facts["best_value_sector"]
|
||||||
dear = facts["dearest_sector"]
|
dear = facts["dearest_sector"]
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -315,8 +315,7 @@
|
||||||
"ax1.plot(yr, piv[\"existing\"].to_numpy(), marker=\"o\", label=\"Existing houses\", color=\"#0f766e\")\n",
|
"ax1.plot(yr, piv[\"existing\"].to_numpy(), marker=\"o\", label=\"Existing houses\", color=\"#0f766e\")\n",
|
||||||
"ax1.plot(yr, piv[\"new\"].to_numpy(), marker=\"s\", label=\"New-build houses\", color=\"#d97706\")\n",
|
"ax1.plot(yr, piv[\"new\"].to_numpy(), marker=\"s\", label=\"New-build houses\", color=\"#d97706\")\n",
|
||||||
"ax1.set_title(\"Median £/sqm by sale year\")\n",
|
"ax1.set_title(\"Median £/sqm by sale year\")\n",
|
||||||
"ax1.set_xlabel(\"Year\")\n",
|
"ax1.set_xlabel(\"Year\"); ax1.set_ylabel(\"£/sqm\")\n",
|
||||||
"ax1.set_ylabel(\"£/sqm\")\n",
|
|
||||||
"ax1.yaxis.set_major_formatter(mticker.StrMethodFormatter(\"£{x:,.0f}\"))\n",
|
"ax1.yaxis.set_major_formatter(mticker.StrMethodFormatter(\"£{x:,.0f}\"))\n",
|
||||||
"ax1.legend()\n",
|
"ax1.legend()\n",
|
||||||
"\n",
|
"\n",
|
||||||
|
|
@ -324,11 +323,9 @@
|
||||||
"ax2.bar(yr, piv[\"premium_pct\"].to_numpy(), color=colors)\n",
|
"ax2.bar(yr, piv[\"premium_pct\"].to_numpy(), color=colors)\n",
|
||||||
"ax2.axhline(0, color=\"black\", lw=0.8)\n",
|
"ax2.axhline(0, color=\"black\", lw=0.8)\n",
|
||||||
"ax2.set_title(\"New-build premium (median £/sqm, new vs existing)\")\n",
|
"ax2.set_title(\"New-build premium (median £/sqm, new vs existing)\")\n",
|
||||||
"ax2.set_xlabel(\"Year\")\n",
|
"ax2.set_xlabel(\"Year\"); ax2.set_ylabel(\"Premium %\")\n",
|
||||||
"ax2.set_ylabel(\"Premium %\")\n",
|
|
||||||
"ax2.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
|
"ax2.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
|
||||||
"plt.tight_layout()\n",
|
"plt.tight_layout(); plt.show()\n",
|
||||||
"plt.show()\n",
|
|
||||||
"\n",
|
"\n",
|
||||||
"piv.select(\"year\", \"existing\", \"new\", \"premium_pct\", \"n_new\", \"n_existing\")"
|
"piv.select(\"year\", \"existing\", \"new\", \"premium_pct\", \"n_new\", \"n_existing\")"
|
||||||
]
|
]
|
||||||
|
|
@ -429,8 +426,7 @@
|
||||||
"for y, v, nnew in zip(range(len(labels)), vals, area_prem[\"n_new\"].to_list()):\n",
|
"for y, v, nnew in zip(range(len(labels)), vals, area_prem[\"n_new\"].to_list()):\n",
|
||||||
" ax.text(v + (0.4 if v >= 0 else -0.4), y, f\"n={nnew}\", va=\"center\",\n",
|
" ax.text(v + (0.4 if v >= 0 else -0.4), y, f\"n={nnew}\", va=\"center\",\n",
|
||||||
" ha=\"left\" if v >= 0 else \"right\", fontsize=8, color=\"#555\")\n",
|
" ha=\"left\" if v >= 0 else \"right\", fontsize=8, color=\"#555\")\n",
|
||||||
"plt.tight_layout()\n",
|
"plt.tight_layout(); plt.show()\n",
|
||||||
"plt.show()\n",
|
|
||||||
"area_prem"
|
"area_prem"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -527,12 +523,10 @@
|
||||||
"ax.axvline(np.median(old_cagr), color=\"#0f766e\", ls=\"--\", lw=1.2)\n",
|
"ax.axvline(np.median(old_cagr), color=\"#0f766e\", ls=\"--\", lw=1.2)\n",
|
||||||
"ax.axvline(np.median(new_cagr), color=\"#d97706\", ls=\"--\", lw=1.2)\n",
|
"ax.axvline(np.median(new_cagr), color=\"#d97706\", ls=\"--\", lw=1.2)\n",
|
||||||
"ax.set_title(\"Distribution of annualised resale growth (CAGR)\")\n",
|
"ax.set_title(\"Distribution of annualised resale growth (CAGR)\")\n",
|
||||||
"ax.set_xlabel(\"CAGR % per year\")\n",
|
"ax.set_xlabel(\"CAGR % per year\"); ax.set_ylabel(\"density\")\n",
|
||||||
"ax.set_ylabel(\"density\")\n",
|
|
||||||
"ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
|
"ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
|
||||||
"ax.legend()\n",
|
"ax.legend()\n",
|
||||||
"plt.tight_layout()\n",
|
"plt.tight_layout(); plt.show()"
|
||||||
"plt.show()"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -609,19 +603,16 @@
|
||||||
"\n",
|
"\n",
|
||||||
"area_cagr = cagr_by(\"area\")\n",
|
"area_cagr = cagr_by(\"area\")\n",
|
||||||
"labels = area_cagr[\"area\"].to_list()\n",
|
"labels = area_cagr[\"area\"].to_list()\n",
|
||||||
"x = np.arange(len(labels))\n",
|
"x = np.arange(len(labels)); w = 0.4\n",
|
||||||
"w = 0.4\n",
|
|
||||||
"fig, ax = plt.subplots(figsize=(10, 4.5))\n",
|
"fig, ax = plt.subplots(figsize=(10, 4.5))\n",
|
||||||
"ax.bar(x - w/2, area_cagr[\"existing_cagr\"].to_numpy(), w, label=\"Existing\", color=\"#0f766e\")\n",
|
"ax.bar(x - w/2, area_cagr[\"existing_cagr\"].to_numpy(), w, label=\"Existing\", color=\"#0f766e\")\n",
|
||||||
"ax.bar(x + w/2, area_cagr[\"newbuild_cagr\"].to_numpy(), w, label=\"New-build\", color=\"#d97706\")\n",
|
"ax.bar(x + w/2, area_cagr[\"newbuild_cagr\"].to_numpy(), w, label=\"New-build\", color=\"#d97706\")\n",
|
||||||
"ax.set_xticks(x)\n",
|
"ax.set_xticks(x); ax.set_xticklabels(labels)\n",
|
||||||
"ax.set_xticklabels(labels)\n",
|
|
||||||
"ax.set_title(\"Median resale CAGR by postal area\")\n",
|
"ax.set_title(\"Median resale CAGR by postal area\")\n",
|
||||||
"ax.set_ylabel(\"CAGR % per year\")\n",
|
"ax.set_ylabel(\"CAGR % per year\")\n",
|
||||||
"ax.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
|
"ax.yaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
|
||||||
"ax.legend()\n",
|
"ax.legend()\n",
|
||||||
"plt.tight_layout()\n",
|
"plt.tight_layout(); plt.show()\n",
|
||||||
"plt.show()\n",
|
|
||||||
"area_cagr"
|
"area_cagr"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -708,25 +699,22 @@
|
||||||
" area_cagr.select(\"area\", \"cagr_gap\", \"newbuild_cagr\", \"existing_cagr\"), on=\"area\", how=\"inner\"\n",
|
" area_cagr.select(\"area\", \"cagr_gap\", \"newbuild_cagr\", \"existing_cagr\"), on=\"area\", how=\"inner\"\n",
|
||||||
")\n",
|
")\n",
|
||||||
"fig, ax = plt.subplots(figsize=(8, 6))\n",
|
"fig, ax = plt.subplots(figsize=(8, 6))\n",
|
||||||
"xs = combined[\"premium_pct\"].to_numpy()\n",
|
"xs = combined[\"premium_pct\"].to_numpy(); ys = combined[\"cagr_gap\"].to_numpy()\n",
|
||||||
"ys = combined[\"cagr_gap\"].to_numpy()\n",
|
|
||||||
"ax.scatter(xs, ys, s=70, color=\"#d97706\", zorder=3)\n",
|
"ax.scatter(xs, ys, s=70, color=\"#d97706\", zorder=3)\n",
|
||||||
"for a, xv, yv in zip(combined[\"area\"].to_list(), xs, ys):\n",
|
"for a, xv, yv in zip(combined[\"area\"].to_list(), xs, ys):\n",
|
||||||
" ax.annotate(a, (xv, yv), textcoords=\"offset points\", xytext=(6, 4), fontsize=10)\n",
|
" ax.annotate(a, (xv, yv), textcoords=\"offset points\", xytext=(6, 4), fontsize=10)\n",
|
||||||
"ax.axhline(0, color=\"black\", lw=0.8)\n",
|
"ax.axhline(0, color=\"black\", lw=0.8); ax.axvline(0, color=\"black\", lw=0.8)\n",
|
||||||
"ax.axvline(0, color=\"black\", lw=0.8)\n",
|
|
||||||
"ax.set_xlabel(\"New-build £/sqm premium at purchase\")\n",
|
"ax.set_xlabel(\"New-build £/sqm premium at purchase\")\n",
|
||||||
"ax.set_ylabel(\"New-build CAGR minus existing CAGR (pp/yr)\")\n",
|
"ax.set_ylabel(\"New-build CAGR minus existing CAGR (pp/yr)\")\n",
|
||||||
"ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
|
"ax.xaxis.set_major_formatter(mticker.PercentFormatter(decimals=0))\n",
|
||||||
"ax.set_title(\"Premium paid vs appreciation gap, by London postal area\")\n",
|
"ax.set_title(\"Premium paid vs appreciation gap, by London postal area\")\n",
|
||||||
"plt.tight_layout()\n",
|
"plt.tight_layout(); plt.show()\n",
|
||||||
"plt.show()\n",
|
|
||||||
"\n",
|
"\n",
|
||||||
"# Headline numbers for the writeup.\n",
|
"# Headline numbers for the writeup.\n",
|
||||||
"hl = summary.to_dicts()\n",
|
"hl = summary.to_dicts()\n",
|
||||||
"new_row = next(r for r in hl if r[\"is_newbuild_prop\"])\n",
|
"new_row = next(r for r in hl if r[\"is_newbuild_prop\"])\n",
|
||||||
"old_row = next(r for r in hl if not r[\"is_newbuild_prop\"])\n",
|
"old_row = next(r for r in hl if not r[\"is_newbuild_prop\"])\n",
|
||||||
"print(\"London terraced/detached, repeat sales:\")\n",
|
"print(f\"London terraced/detached, repeat sales:\")\n",
|
||||||
"print(f\" existing median CAGR: {old_row['median_cagr_pct']}%/yr (n={old_row['n']:,})\")\n",
|
"print(f\" existing median CAGR: {old_row['median_cagr_pct']}%/yr (n={old_row['n']:,})\")\n",
|
||||||
"print(f\" new-build median CAGR: {new_row['median_cagr_pct']}%/yr (n={new_row['n']:,})\")\n",
|
"print(f\" new-build median CAGR: {new_row['median_cagr_pct']}%/yr (n={new_row['n']:,})\")\n",
|
||||||
"print(f\" appreciation gap: {round(new_row['median_cagr_pct']-old_row['median_cagr_pct'],2)} pp/yr\")\n",
|
"print(f\" appreciation gap: {round(new_row['median_cagr_pct']-old_row['median_cagr_pct'],2)} pp/yr\")\n",
|
||||||
|
|
|
||||||
8
check.sh
8
check.sh
|
|
@ -14,14 +14,6 @@ step "Python lint: ruff" uv run ruff check .
|
||||||
step "Python dependency lint: deptry" uv run deptry .
|
step "Python dependency lint: deptry" uv run deptry .
|
||||||
step "Python unit tests" uv run pytest pipeline
|
step "Python unit tests" uv run pytest pipeline
|
||||||
|
|
||||||
(
|
|
||||||
cd "$ROOT_DIR/finder"
|
|
||||||
# finder is a separate uv project (the scraper) with its own venv, so the
|
|
||||||
# root `pytest pipeline` run above never reaches it. pytest is not a declared
|
|
||||||
# finder dependency, so pull it in ephemerally as its README documents.
|
|
||||||
step "Finder (scraper) unit tests" uv run --with pytest pytest -q
|
|
||||||
)
|
|
||||||
|
|
||||||
(
|
(
|
||||||
cd "$ROOT_DIR/frontend"
|
cd "$ROOT_DIR/frontend"
|
||||||
step "Frontend lint: ESLint" npm run lint
|
step "Frontend lint: ESLint" npm run lint
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,6 @@ services:
|
||||||
- cargo-home:/usr/local/cargo
|
- cargo-home:/usr/local/cargo
|
||||||
- cargo-target:/app/server-rs/target
|
- cargo-target:/app/server-rs/target
|
||||||
environment:
|
environment:
|
||||||
# Disable incremental compilation. cargo-watch does incremental
|
|
||||||
# rebuilds over the mounted source; editing a struct with a generic
|
|
||||||
# serde impl mid-flight can corrupt the incremental cache and produce
|
|
||||||
# `rust-lld: undefined hidden symbol ...::serialize` link failures
|
|
||||||
# that never resolve until a clean rebuild. Off = slightly slower
|
|
||||||
# warm rebuilds, but no such corruption.
|
|
||||||
CARGO_INCREMENTAL: "0"
|
|
||||||
# Fallback only: the binary uses jemalloc as its global allocator
|
# Fallback only: the binary uses jemalloc as its global allocator
|
||||||
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
|
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
|
||||||
MALLOC_ARENA_MAX: "2"
|
MALLOC_ARENA_MAX: "2"
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
"""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,7 +5,6 @@ import signal
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timezone
|
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
|
|
@ -35,7 +34,6 @@ from postcode_cache import load_cache, save_cache
|
||||||
from rightmove import resolve_outcode_id
|
from rightmove import resolve_outcode_id
|
||||||
from rightmove import search_outcode as rightmove_search_outcode
|
from rightmove import search_outcode as rightmove_search_outcode
|
||||||
from spatial import PostcodeSpatialIndex
|
from spatial import PostcodeSpatialIndex
|
||||||
from price_history import load_history, save_history, update_history
|
|
||||||
from storage import write_parquet
|
from storage import write_parquet
|
||||||
from zoopla import TurnstileError
|
from zoopla import TurnstileError
|
||||||
from zoopla import launch_browser as launch_zoopla_browser
|
from zoopla import launch_browser as launch_zoopla_browser
|
||||||
|
|
@ -841,18 +839,7 @@ def run_scrape(
|
||||||
merged, source_counts, deduped = _merge_properties(results)
|
merged, source_counts, deduped = _merge_properties(results)
|
||||||
output_path = output_base / "online_listings_buy.parquet"
|
output_path = output_base / "online_listings_buy.parquet"
|
||||||
if merged:
|
if merged:
|
||||||
# Accrue the per-listing asking-price history before writing: load the
|
write_parquet(merged, output_path)
|
||||||
# persistent store, append this run's price moves, dump it, then embed
|
|
||||||
# each listing's series in the parquet. Forward-only by nature — the
|
|
||||||
# first run seeds one point per listing and reductions appear over time.
|
|
||||||
history_path = output_base / "price_history" / "listings.json"
|
|
||||||
history = load_history(history_path)
|
|
||||||
run_date = (
|
|
||||||
datetime.fromtimestamp(started_at, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
)
|
|
||||||
update_history(history, merged, run_date)
|
|
||||||
save_history(history_path, history)
|
|
||||||
write_parquet(merged, output_path, price_history=history)
|
|
||||||
else:
|
else:
|
||||||
if output_path.exists():
|
if output_path.exists():
|
||||||
output_path.unlink()
|
output_path.unlink()
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,8 @@ from transform import map_property_type, normalize_postcode
|
||||||
log = logging.getLogger("rightmove")
|
log = logging.getLogger("rightmove")
|
||||||
|
|
||||||
|
|
||||||
def write_parquet(
|
def write_parquet(properties: list[dict], path: Path) -> None:
|
||||||
properties: list[dict], path: Path, price_history: dict | None = None
|
"""Write sale properties list to parquet with server-ready column names."""
|
||||||
) -> None:
|
|
||||||
"""Write sale properties list to parquet with server-ready column names.
|
|
||||||
|
|
||||||
``price_history`` is the persistent listing-id -> [{date, price, reason}]
|
|
||||||
store (see finder/price_history.py); each listing's accrued asking-price
|
|
||||||
series is embedded as a ``price_history`` list column. Absent id -> empty
|
|
||||||
list, so pre-instrumentation runs and tests simply write empty series.
|
|
||||||
"""
|
|
||||||
if not properties:
|
if not properties:
|
||||||
log.warning("No properties to write to %s", path)
|
log.warning("No properties to write to %s", path)
|
||||||
return
|
return
|
||||||
|
|
@ -103,14 +95,6 @@ def write_parquet(
|
||||||
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
|
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
|
||||||
listing_statuses = ["For sale"] * len(properties)
|
listing_statuses = ["For sale"] * len(properties)
|
||||||
|
|
||||||
# Accrued asking-price history per listing (oldest -> newest). Look up with
|
|
||||||
# the SAME normalisation the store keys on (str(id).strip(), matching
|
|
||||||
# price_history.update_history and scraper dedup); an unseen id -> empty.
|
|
||||||
history_lookup = price_history or {}
|
|
||||||
price_history_col = [
|
|
||||||
history_lookup.get(str(p.get("id")).strip(), []) for p in properties
|
|
||||||
]
|
|
||||||
|
|
||||||
df = pl.DataFrame(
|
df = pl.DataFrame(
|
||||||
{
|
{
|
||||||
"Bedrooms": [p["Bedrooms"] for p in properties],
|
"Bedrooms": [p["Bedrooms"] for p in properties],
|
||||||
|
|
@ -160,7 +144,6 @@ def write_parquet(
|
||||||
"Listing date": listing_dates,
|
"Listing date": listing_dates,
|
||||||
"Listing status": listing_statuses,
|
"Listing status": listing_statuses,
|
||||||
"Asking price": asking_prices,
|
"Asking price": asking_prices,
|
||||||
"price_history": price_history_col,
|
|
||||||
},
|
},
|
||||||
schema={
|
schema={
|
||||||
"Bedrooms": pl.Int32,
|
"Bedrooms": pl.Int32,
|
||||||
|
|
@ -186,11 +169,6 @@ def write_parquet(
|
||||||
"Listing date": pl.Datetime("us"),
|
"Listing date": pl.Datetime("us"),
|
||||||
"Listing status": pl.Utf8,
|
"Listing status": pl.Utf8,
|
||||||
"Asking price": pl.Int64,
|
"Asking price": pl.Int64,
|
||||||
"price_history": pl.List(
|
|
||||||
pl.Struct(
|
|
||||||
{"date": pl.Utf8, "price": pl.Int64, "reason": pl.Utf8}
|
|
||||||
)
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
"""Tests for the forward-only asking-price history store (price_history.py)."""
|
|
||||||
|
|
||||||
from price_history import normalize_reason, update_history
|
|
||||||
|
|
||||||
|
|
||||||
def test_first_sight_new_listing_records_listed_at_first_visible_date() -> None:
|
|
||||||
history: dict = {}
|
|
||||||
update_history(
|
|
||||||
history,
|
|
||||||
[{"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}],
|
|
||||||
"2026-07-12",
|
|
||||||
)
|
|
||||||
assert history["A"] == [{"date": "2026-07-01", "price": 500000, "reason": "listed"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_first_sight_already_reduced_uses_event_date_and_reason() -> None:
|
|
||||||
history: dict = {}
|
|
||||||
update_history(
|
|
||||||
history,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": "B",
|
|
||||||
"price": 425000,
|
|
||||||
"first_visible_date": "2026-06-01T09:00:00Z",
|
|
||||||
"listing_update_reason": "price_reduced",
|
|
||||||
"listing_update_date": "2026-07-10T14:00:00Z",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"2026-07-12",
|
|
||||||
)
|
|
||||||
assert history["B"] == [{"date": "2026-07-10", "price": 425000, "reason": "reduced"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_later_run_appends_only_on_price_change_with_direction() -> None:
|
|
||||||
history: dict = {}
|
|
||||||
listing = {"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}
|
|
||||||
update_history(history, [listing], "2026-07-12")
|
|
||||||
# Unchanged price -> no new point.
|
|
||||||
update_history(history, [{"id": "A", "price": 500000}], "2026-07-19")
|
|
||||||
assert len(history["A"]) == 1
|
|
||||||
# Reduced -> appended, dated to the run.
|
|
||||||
update_history(history, [{"id": "A", "price": 480000}], "2026-07-26")
|
|
||||||
# Increased -> appended.
|
|
||||||
update_history(history, [{"id": "A", "price": 490000}], "2026-08-02")
|
|
||||||
assert history["A"] == [
|
|
||||||
{"date": "2026-07-01", "price": 500000, "reason": "listed"},
|
|
||||||
{"date": "2026-07-26", "price": 480000, "reason": "reduced"},
|
|
||||||
{"date": "2026-08-02", "price": 490000, "reason": "increased"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_zero_or_missing_price_and_id_are_skipped() -> None:
|
|
||||||
history: dict = {}
|
|
||||||
update_history(
|
|
||||||
history,
|
|
||||||
[
|
|
||||||
{"id": "POA", "price": 0},
|
|
||||||
{"id": "", "price": 100000},
|
|
||||||
{"price": 100000}, # no id
|
|
||||||
],
|
|
||||||
"2026-07-12",
|
|
||||||
)
|
|
||||||
assert history == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_date_fallback_when_dates_absent() -> None:
|
|
||||||
history: dict = {}
|
|
||||||
update_history(history, [{"id": "A", "price": 300000}], "2026-07-12")
|
|
||||||
assert history["A"] == [{"date": "2026-07-12", "price": 300000, "reason": "listed"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_reason_maps_only_price_moves() -> None:
|
|
||||||
assert normalize_reason("price_reduced") == "reduced"
|
|
||||||
assert normalize_reason("price_increased") == "increased"
|
|
||||||
assert normalize_reason("new") is None
|
|
||||||
assert normalize_reason("under_offer") is None
|
|
||||||
assert normalize_reason(None) is None
|
|
||||||
|
|
@ -125,9 +125,7 @@ def test_run_scrape_runs_all_sources_merges_and_persists_caches(tmp_path, monkey
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
scraper,
|
scraper,
|
||||||
"write_parquet",
|
"write_parquet",
|
||||||
lambda props, path, price_history=None: written.update(
|
lambda props, path: written.update(count=len(props), path=path),
|
||||||
count=len(props), path=path, price_history=price_history
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
result = scraper.run_scrape(
|
result = scraper.run_scrape(
|
||||||
|
|
|
||||||
|
|
@ -385,16 +385,6 @@ def transform_property(
|
||||||
if not listing_id:
|
if not listing_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# The search API already carries the single most-recent price-change event
|
|
||||||
# (reason + ISO date) with no extra request. Rightmove never exposes the
|
|
||||||
# previous price, so this only seeds the accruing asking-price history (see
|
|
||||||
# finder/price_history.py); it is not written to the output parquet itself.
|
|
||||||
listing_update = prop.get("listingUpdate")
|
|
||||||
if not isinstance(listing_update, dict):
|
|
||||||
listing_update = {}
|
|
||||||
listing_update_reason = listing_update.get("listingUpdateReason")
|
|
||||||
listing_update_date = listing_update.get("listingUpdateDate")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": listing_id,
|
"id": listing_id,
|
||||||
"Bedrooms": bedrooms,
|
"Bedrooms": bedrooms,
|
||||||
|
|
@ -421,7 +411,4 @@ def transform_property(
|
||||||
"Listing URL": build_listing_url(property_url, bool(prop.get("development"))),
|
"Listing URL": build_listing_url(property_url, bool(prop.get("development"))),
|
||||||
"Listing features": key_features,
|
"Listing features": key_features,
|
||||||
"first_visible_date": prop.get("firstVisibleDate", ""),
|
"first_visible_date": prop.get("firstVisibleDate", ""),
|
||||||
# Fed into the asking-price history store, not the parquet directly.
|
|
||||||
"listing_update_reason": listing_update_reason,
|
|
||||||
"listing_update_date": listing_update_date,
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.202 --> 00:00:03.802
|
00:00:00.201 --> 00:00:03.961
|
||||||
My whole house brief is one plain sentence.
|
My whole house brief is one plain sentence.
|
||||||
|
|
||||||
00:00:04.102 --> 00:00:08.022
|
00:00:04.361 --> 00:00:08.841
|
||||||
Every postcode in England that fits, sorted by value.
|
Every postcode in England that fits, sorted by value.
|
||||||
|
|
||||||
00:00:09.181 --> 00:00:13.501
|
00:00:10.141 --> 00:00:14.941
|
||||||
Same schools, same commute, the price quietly drops nearby.
|
Same schools, same commute, the price quietly drops nearby.
|
||||||
|
|
||||||
00:00:14.001 --> 00:00:17.921
|
00:00:15.591 --> 00:00:19.511
|
||||||
The underpriced twin is on this map. Find it.
|
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
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.242 --> 00:00:04.082
|
00:00:02.121 --> 00:00:05.961
|
||||||
Every colour on this map is a commute to central London.
|
Every colour on this map is a commute to central London.
|
||||||
|
|
||||||
00:00:04.412 --> 00:00:07.852
|
00:00:06.361 --> 00:00:09.801
|
||||||
Here's what twenty minutes actually leaves you.
|
Here's what twenty minutes actually leaves you.
|
||||||
|
|
||||||
00:00:08.832 --> 00:00:12.912
|
00:00:11.301 --> 00:00:16.741
|
||||||
Same twenty minutes wherever it's lit, but not the same price.
|
Same twenty minutes wherever it's lit, but not the same price.
|
||||||
|
|
||||||
00:00:13.412 --> 00:00:17.092
|
00:00:17.391 --> 00:00:21.151
|
||||||
The commute is priced in. The bargain is not.
|
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
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.241 --> 00:00:05.121
|
00:00:01.021 --> 00:00:05.341
|
||||||
One name costs more. The block next door scores the same.
|
One name costs more. The block next door scores the same.
|
||||||
|
|
||||||
00:00:05.451 --> 00:00:11.291
|
00:00:05.741 --> 00:00:11.581
|
||||||
Sold prices, schools, crime, even Street View, for this exact postcode.
|
Sold prices, schools, crime, even Street View, for this exact postcode.
|
||||||
|
|
||||||
00:00:11.671 --> 00:00:16.231
|
00:00:12.031 --> 00:00:17.071
|
||||||
The same evidence a pricey postcode has, sitting quietly cheaper here.
|
The same evidence a pricey postcode has, sitting quietly cheaper here.
|
||||||
|
|
||||||
00:00:16.731 --> 00:00:21.931
|
00:00:17.671 --> 00:00:22.071
|
||||||
Every postcode, proven on the numbers, not its reputation.
|
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
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.202 --> 00:00:04.442
|
00:00:00.201 --> 00:00:04.441
|
||||||
Listing photos are silent. Main roads are not.
|
Listing photos are silent. Main roads are not.
|
||||||
|
|
||||||
00:00:04.772 --> 00:00:07.812
|
00:00:04.841 --> 00:00:07.881
|
||||||
This is London under fifty-five decibels.
|
This is London under fifty-five decibels.
|
||||||
|
|
||||||
00:00:08.892 --> 00:00:12.492
|
00:00:09.181 --> 00:00:13.021
|
||||||
Nearby, just as quiet, and overlooked.
|
Nearby, just as quiet, and overlooked.
|
||||||
|
|
||||||
00:00:12.992 --> 00:00:15.392
|
00:00:13.671 --> 00:00:16.071
|
||||||
The quiet street nobody bid up.
|
The quiet street nobody bid up.
|
||||||
|
|
||||||
|
|
|
||||||
BIN
frontend/public/video/ad-05-school-run.jpg
(Stored with Git LFS)
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
|
00:00:00.201 --> 00:00:06.521
|
||||||
Good primary, low crime, under three hundred and fifty. The actual family brief.
|
Good primary, low crime, under three hundred and fifty. The actual family brief.
|
||||||
|
|
||||||
00:00:06.851 --> 00:00:10.611
|
00:00:06.921 --> 00:00:11.081
|
||||||
Everything still on the map passes all three filters.
|
Everything still on the map passes all three filters.
|
||||||
|
|
||||||
00:00:11.691 --> 00:00:17.211
|
00:00:12.381 --> 00:00:17.741
|
||||||
Same primary, same low crime, without the name everyone else is bidding up.
|
Same primary, same low crime, without the name everyone else is bidding up.
|
||||||
|
|
||||||
00:00:17.711 --> 00:00:20.831
|
00:00:18.391 --> 00:00:21.831
|
||||||
The schools are real. The premium is optional.
|
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
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.202 --> 00:00:05.722
|
00:00:00.204 --> 00:00:05.724
|
||||||
A Waitrose, a tube stop, a park nearby. Search by what you want.
|
A Waitrose, a tube stop, a park nearby. Search by what you want.
|
||||||
|
|
||||||
00:00:06.052 --> 00:00:10.132
|
00:00:06.124 --> 00:00:10.204
|
||||||
London, narrowed to the postcodes that fit the way you live.
|
London, narrowed to the postcodes that fit the way you live.
|
||||||
|
|
||||||
00:00:11.212 --> 00:00:14.252
|
00:00:11.504 --> 00:00:14.704
|
||||||
Same amenities, lower price nearby.
|
Same amenities, lower price nearby.
|
||||||
|
|
||||||
00:00:14.752 --> 00:00:18.992
|
00:00:15.354 --> 00:00:19.354
|
||||||
Same Waitrose. Same tube. Cheaper postcode.
|
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
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.202 --> 00:00:05.002
|
00:00:00.201 --> 00:00:05.561
|
||||||
Rent under two grand, half an hour to central, on a quiet street.
|
Rent under sixteen hundred, half an hour to central, on a quiet street.
|
||||||
|
|
||||||
00:00:05.332 --> 00:00:09.892
|
00:00:05.961 --> 00:00:10.201
|
||||||
Letting sites rank flats. This ranks the streets around them.
|
Letting sites rank flats. This ranks the streets around them.
|
||||||
|
|
||||||
00:00:10.972 --> 00:00:14.892
|
00:00:11.501 --> 00:00:15.821
|
||||||
Same commute, same quiet, lower rent nearby.
|
Same commute, same quiet, lower rent nearby.
|
||||||
|
|
||||||
00:00:16.797 --> 00:00:19.997
|
00:00:16.471 --> 00:00:19.831
|
||||||
The name costs more. The street does not.
|
The name costs more. The street does not.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
WEBVTT
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.202 --> 00:00:05.722
|
00:00:00.201 --> 00:00:05.641
|
||||||
Two streets, same schools, same commute, priced tens of thousands apart.
|
Two streets, same schools, same commute, priced tens of thousands apart.
|
||||||
|
|
||||||
00:00:06.052 --> 00:00:10.772
|
00:00:06.041 --> 00:00:10.201
|
||||||
You pay for the postcode's name; the value sits a postcode away.
|
You pay for the postcode's name; the value sits a postcode away.
|
||||||
|
|
||||||
00:00:11.852 --> 00:00:16.412
|
00:00:11.501 --> 00:00:15.581
|
||||||
Pay once, find the bargain, stop overpaying for the name.
|
Pay once, find the bargain, stop overpaying for the name.
|
||||||
|
|
||||||
00:00:16.912 --> 00:00:19.552
|
00:00:16.231 --> 00:00:19.351
|
||||||
Buy value, not reputation.
|
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
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.241 --> 00:00:04.121
|
00:00:00.263 --> 00:00:04.663
|
||||||
A postcode's reputation is priced in. Its value isn't.
|
A postcode's reputation is priced in. Its value isn't.
|
||||||
|
|
||||||
00:00:04.471 --> 00:00:10.911
|
00:00:05.013 --> 00:00:11.413
|
||||||
So start with the brief. Budget, commute, schools, even how quiet the street is.
|
So start with the brief. Budget, commute, schools, even how quiet the street is.
|
||||||
|
|
||||||
00:00:11.281 --> 00:00:16.361
|
00:00:11.783 --> 00:00:16.023
|
||||||
One brief, and England narrows to the postcodes worth your money.
|
One brief, and England narrows to the postcodes worth your money.
|
||||||
|
|
||||||
00:00:16.911 --> 00:00:21.151
|
00:00:16.573 --> 00:00:20.813
|
||||||
Tighten the commute, and the keepers narrow further in seconds.
|
Tighten the commute, and the keepers narrow further in seconds.
|
||||||
|
|
||||||
00:00:21.751 --> 00:00:25.791
|
00:00:21.413 --> 00:00:25.733
|
||||||
Down at street level, the strongest streets start to stand out.
|
Down at street level, the strongest streets start to stand out.
|
||||||
|
|
||||||
00:00:27.033 --> 00:00:34.073
|
00:00:26.418 --> 00:00:35.218
|
||||||
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
|
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
|
||||||
|
|
||||||
00:00:36.278 --> 00:00:41.958
|
00:00:37.418 --> 00:00:42.698
|
||||||
Keep the best-value few, export them, and scout where it actually counts.
|
Keep the best-value few, export them, and scout where it actually counts.
|
||||||
|
|
||||||
00:00:42.958 --> 00:00:46.158
|
00:00:43.822 --> 00:00:46.942
|
||||||
Stop paying for the name. Find the value.
|
Stop paying for the name. Find the value.
|
||||||
|
|
||||||
|
|
|
||||||
BIN
frontend/public/video/recording.jpg
(Stored with Git LFS)
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
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.241 --> 00:00:04.121
|
00:00:00.249 --> 00:00:04.649
|
||||||
A postcode's reputation is priced in. Its value isn't.
|
A postcode's reputation is priced in. Its value isn't.
|
||||||
|
|
||||||
00:00:04.471 --> 00:00:10.911
|
00:00:04.999 --> 00:00:11.399
|
||||||
So start with the brief. Budget, commute, schools, even how quiet the street is.
|
So start with the brief. Budget, commute, schools, even how quiet the street is.
|
||||||
|
|
||||||
00:00:11.281 --> 00:00:16.361
|
00:00:11.769 --> 00:00:16.009
|
||||||
One brief, and England narrows to the postcodes worth your money.
|
One brief, and England narrows to the postcodes worth your money.
|
||||||
|
|
||||||
00:00:16.911 --> 00:00:21.151
|
00:00:16.559 --> 00:00:20.799
|
||||||
Tighten the commute, and the keepers narrow further in seconds.
|
Tighten the commute, and the keepers narrow further in seconds.
|
||||||
|
|
||||||
00:00:21.751 --> 00:00:25.791
|
00:00:21.399 --> 00:00:25.719
|
||||||
Down at street level, the strongest streets start to stand out.
|
Down at street level, the strongest streets start to stand out.
|
||||||
|
|
||||||
00:00:28.039 --> 00:00:35.079
|
00:00:27.010 --> 00:00:35.810
|
||||||
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
|
Open one, and it shows its work. Sold prices, schools, crime, noise, broadband.
|
||||||
|
|
||||||
00:00:37.517 --> 00:00:43.197
|
00:00:37.910 --> 00:00:43.190
|
||||||
Keep the best-value few, export them, and scout where it actually counts.
|
Keep the best-value few, export them, and scout where it actually counts.
|
||||||
|
|
||||||
00:00:44.197 --> 00:00:47.397
|
00:00:44.190 --> 00:00:47.310
|
||||||
Stop paying for the name. Find the value.
|
Stop paying for the name. Find the value.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
WEBVTT
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.206 --> 00:00:07.246
|
00:00:00.203 --> 00:00:06.283
|
||||||
Beckenham and Croydon sit two kilometres apart, on the same trains, in the same school catchments.
|
Beckenham and Croydon sit side by side: same trains, same school catchment.
|
||||||
|
|
||||||
00:00:07.546 --> 00:00:12.586
|
00:00:06.683 --> 00:00:10.523
|
||||||
Now colour every postcode by what a square metre of home actually costs there.
|
Rank them by what each pound of floor space actually buys.
|
||||||
|
|
||||||
00:00:13.666 --> 00:00:19.826
|
00:00:11.823 --> 00:00:16.943
|
||||||
Green means cheaper floor space. Croydon runs about a third under Beckenham, street for street.
|
One postcode over, the same home quietly costs about a third less.
|
||||||
|
|
||||||
00:00:20.256 --> 00:00:24.096
|
00:00:18.093 --> 00:00:22.893
|
||||||
That's over two hundred thousand pounds back on an average home.
|
|
||||||
|
|
||||||
00:00:24.696 --> 00:00:30.696
|
|
||||||
Beckenham's cheaper twin is on this map. Find yours, free.
|
Beckenham's cheaper twin is on this map. Find yours, free.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
WEBVTT
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.201 --> 00:00:07.001
|
00:00:00.202 --> 00:00:05.882
|
||||||
Stanmore and Kenton sit about two and a half kilometres apart, with the same schools and transport links.
|
Stanmore and Kenton sit right next door, with the same schools and transport links.
|
||||||
|
|
||||||
00:00:07.301 --> 00:00:12.341
|
00:00:06.282 --> 00:00:10.842
|
||||||
Now colour every postcode by what a square metre of home actually costs there.
|
Rank every postcode by what each pound of floor space actually buys.
|
||||||
|
|
||||||
00:00:13.421 --> 00:00:19.421
|
00:00:12.142 --> 00:00:17.422
|
||||||
Green means cheaper floor space. Kenton runs about a sixth under Stanmore, street for street.
|
One postcode over, the same home quietly costs about a sixth less.
|
||||||
|
|
||||||
00:00:19.851 --> 00:00:23.531
|
00:00:18.572 --> 00:00:22.652
|
||||||
That's more than a hundred thousand pounds back on an average home.
|
|
||||||
|
|
||||||
00:00:24.131 --> 00:00:29.171
|
|
||||||
Stanmore's cheaper twin is on this map. Find yours, free.
|
Stanmore's cheaper twin is on this map. Find yours, free.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
WEBVTT
|
WEBVTT
|
||||||
|
|
||||||
00:00:00.201 --> 00:00:07.081
|
00:00:00.203 --> 00:00:06.843
|
||||||
Childwall and Broadgreen sit under two kilometres apart, with the same schools and transport links.
|
Childwall and Broadgreen sit right next door, with the same schools and transport links.
|
||||||
|
|
||||||
00:00:07.381 --> 00:00:12.421
|
00:00:07.243 --> 00:00:11.803
|
||||||
Now colour every postcode by what a square metre of home actually costs there.
|
Rank every postcode by what each pound of floor space actually buys.
|
||||||
|
|
||||||
00:00:13.501 --> 00:00:19.901
|
00:00:13.103 --> 00:00:18.223
|
||||||
Green means cheaper floor space. Broadgreen runs nearly a third under Childwall, street for street.
|
One postcode over, the same home quietly costs about a third less.
|
||||||
|
|
||||||
00:00:20.331 --> 00:00:24.571
|
00:00:19.373 --> 00:00:24.493
|
||||||
That's well over a hundred thousand pounds back on an average home.
|
|
||||||
|
|
||||||
00:00:25.171 --> 00:00:30.691
|
|
||||||
Childwall's cheaper twin is on this map. Find yours, free.
|
Childwall's cheaper twin is on this map. Find yours, free.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
||||||
import { trackEvent } from './lib/analytics';
|
import { trackEvent } from './lib/analytics';
|
||||||
import { parseUrlState } from './lib/url-state';
|
import { parseUrlState } from './lib/url-state';
|
||||||
import pb from './lib/pocketbase';
|
import pb from './lib/pocketbase';
|
||||||
import { DEFAULT_DEMO_FILTERS, DEFAULT_DEMO_TRAVEL, INITIAL_VIEW_STATE } from './lib/consts';
|
import { DEFAULT_DEMO_FILTERS, INITIAL_VIEW_STATE } from './lib/consts';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { useIsMobile } from './hooks/useIsMobile';
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
import { useAuth } from './hooks/useAuth';
|
import { useAuth } from './hooks/useAuth';
|
||||||
|
|
@ -38,7 +38,6 @@ const LearnPage = lazy(() => import('./components/learn/LearnPage'));
|
||||||
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
|
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
|
||||||
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
|
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
|
||||||
const AccountPage = lazy(() => import('./components/account/AccountPage'));
|
const AccountPage = lazy(() => import('./components/account/AccountPage'));
|
||||||
const ResetPasswordPage = lazy(() => import('./components/account/ResetPasswordPage'));
|
|
||||||
const SavedPage = lazy(() =>
|
const SavedPage = lazy(() =>
|
||||||
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
||||||
);
|
);
|
||||||
|
|
@ -161,8 +160,6 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
||||||
return '/saved';
|
return '/saved';
|
||||||
case 'account':
|
case 'account':
|
||||||
return '/account';
|
return '/account';
|
||||||
case 'reset-password':
|
|
||||||
return '/reset-password';
|
|
||||||
case 'invite':
|
case 'invite':
|
||||||
if (!inviteCode) {
|
if (!inviteCode) {
|
||||||
throw new Error('Cannot build invite path without an invite code');
|
throw new Error('Cannot build invite path without an invite code');
|
||||||
|
|
@ -186,7 +183,6 @@ function pathToPage(rawPathname: string): RouteMatch | null {
|
||||||
const seoContentPage = getSeoContentPage(pathname);
|
const seoContentPage = getSeoContentPage(pathname);
|
||||||
if (seoContentPage) return { page: seoContentPage };
|
if (seoContentPage) return { page: seoContentPage };
|
||||||
if (pathname === '/account') return { page: 'account' };
|
if (pathname === '/account') return { page: 'account' };
|
||||||
if (pathname === '/reset-password') return { page: 'reset-password' };
|
|
||||||
if (pathname === '/support') return { page: 'learn' };
|
if (pathname === '/support') return { page: 'learn' };
|
||||||
if (pathname === '/terms') return { page: 'terms' };
|
if (pathname === '/terms') return { page: 'terms' };
|
||||||
if (pathname === '/privacy') return { page: 'privacy' };
|
if (pathname === '/privacy') return { page: 'privacy' };
|
||||||
|
|
@ -238,26 +234,15 @@ export default function App() {
|
||||||
return params.get('og') === '1';
|
return params.get('og') === '1';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Funnel fix: pre-seed high-intent defaults on a cold map open so first-time visitors immediately
|
// Funnel fix: pre-seed high-intent filters on a cold (empty) map open so first-time visitors
|
||||||
// see value and are one filter from the demo cap. "Cold" means the URL carries neither filters nor
|
// immediately see value and are one filter from the demo cap. Deep links (OG screenshots, the
|
||||||
// a travel time; a deep link (OG screenshot, SEO landing-page CTA) sets at least one of those and
|
// SEO landing-page CTAs) already carry filters, so they're left as-is. See DEFAULT_DEMO_FILTERS.
|
||||||
// is left as-is, as is the non-interactive screenshot/OG render. See DEFAULT_DEMO_FILTERS.
|
const initialMapFilters = useMemo(() => {
|
||||||
const isColdMapOpen = useMemo(() => {
|
if (isScreenshotMode || isOgMode) return mapUrlState.filters;
|
||||||
if (isScreenshotMode || isOgMode) return false;
|
return Object.keys(mapUrlState.filters).length === 0
|
||||||
const hasFilters = Object.keys(mapUrlState.filters).length > 0;
|
? { ...DEFAULT_DEMO_FILTERS }
|
||||||
const hasTravel = (mapUrlState.travelTime?.entries?.length ?? 0) > 0;
|
: mapUrlState.filters;
|
||||||
return !hasFilters && !hasTravel;
|
}, [mapUrlState.filters, isScreenshotMode, isOgMode]);
|
||||||
}, [mapUrlState.filters, mapUrlState.travelTime, isScreenshotMode, isOgMode]);
|
|
||||||
|
|
||||||
const initialMapFilters = useMemo(
|
|
||||||
() => (isColdMapOpen ? { ...DEFAULT_DEMO_FILTERS } : mapUrlState.filters),
|
|
||||||
[isColdMapOpen, mapUrlState.filters]
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialMapTravelTime = useMemo(
|
|
||||||
() => (isColdMapOpen ? DEFAULT_DEMO_TRAVEL : mapUrlState.travelTime),
|
|
||||||
[isColdMapOpen, mapUrlState.travelTime]
|
|
||||||
);
|
|
||||||
|
|
||||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||||
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
||||||
|
|
@ -298,7 +283,6 @@ export default function App() {
|
||||||
loginWithOAuth,
|
loginWithOAuth,
|
||||||
logout,
|
logout,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
confirmPasswordReset,
|
|
||||||
refreshAuth,
|
refreshAuth,
|
||||||
clearError,
|
clearError,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
|
|
@ -724,17 +708,6 @@ export default function App() {
|
||||||
<LearnPage />
|
<LearnPage />
|
||||||
) : activePage === 'terms' || activePage === 'privacy' ? (
|
) : activePage === 'terms' || activePage === 'privacy' ? (
|
||||||
<LegalPage kind={activePage} />
|
<LegalPage kind={activePage} />
|
||||||
) : activePage === 'reset-password' ? (
|
|
||||||
<ResetPasswordPage
|
|
||||||
onConfirm={confirmPasswordReset}
|
|
||||||
onLoginClick={() => {
|
|
||||||
navigateTo('home');
|
|
||||||
openAuthModal('login');
|
|
||||||
}}
|
|
||||||
loading={authLoading}
|
|
||||||
error={authError}
|
|
||||||
onClearError={clearError}
|
|
||||||
/>
|
|
||||||
) : isSeoLandingPage(activePage) ? (
|
) : isSeoLandingPage(activePage) ? (
|
||||||
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
||||||
) : isSeoContentPage(activePage) ? (
|
) : isSeoContentPage(activePage) ? (
|
||||||
|
|
@ -796,7 +769,7 @@ export default function App() {
|
||||||
}}
|
}}
|
||||||
onDashboardReadyChange={setDashboardReady}
|
onDashboardReadyChange={setDashboardReady}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
initialTravelTime={initialMapTravelTime}
|
initialTravelTime={mapUrlState.travelTime}
|
||||||
initialPostcode={mapUrlState.postcode}
|
initialPostcode={mapUrlState.postcode}
|
||||||
shareCode={mapUrlState.share}
|
shareCode={mapUrlState.share}
|
||||||
onSessionRejected={() => {
|
onSessionRejected={() => {
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,136 +0,0 @@
|
||||||
import { useCallback, useId, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
|
||||||
import { EyeOffIcon } from '../ui/icons/EyeOffIcon';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Landing page for the PocketBase password-reset email link
|
|
||||||
* (`/reset-password?token=…`). Reads the one-time token from the URL, takes a new
|
|
||||||
* password, and calls `confirmPasswordReset`. PocketBase does not issue a session
|
|
||||||
* on confirm, so on success we point the user at the log in screen rather than
|
|
||||||
* auto-authenticating them.
|
|
||||||
*/
|
|
||||||
export default function ResetPasswordPage({
|
|
||||||
onConfirm,
|
|
||||||
onLoginClick,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
onClearError,
|
|
||||||
}: {
|
|
||||||
onConfirm: (token: string, password: string) => Promise<void>;
|
|
||||||
onLoginClick: () => void;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
onClearError: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const token = useMemo(() => new URLSearchParams(window.location.search).get('token') ?? '', []);
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [done, setDone] = useState(false);
|
|
||||||
const fieldId = useId();
|
|
||||||
const passwordInputId = `${fieldId}-new-password`;
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
|
||||||
async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onClearError();
|
|
||||||
try {
|
|
||||||
await onConfirm(token, password);
|
|
||||||
setDone(true);
|
|
||||||
} catch {
|
|
||||||
// Error surfaces through the `error` prop (set by useAuth).
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onConfirm, token, password, onClearError]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loginButtonClass =
|
|
||||||
'w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 transition-colors text-center';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-start justify-center bg-warm-50 dark:bg-navy-950 px-4 py-16">
|
|
||||||
<div className="w-full max-w-sm bg-white dark:bg-warm-900 rounded-lg shadow-sm border border-warm-200 dark:border-warm-700 p-6">
|
|
||||||
<h1 className="text-lg font-semibold text-navy-950 dark:text-white mb-4">
|
|
||||||
{t('auth.resetPassword')}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{!token ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-warm-600 dark:text-warm-300">
|
|
||||||
{t('auth.resetMissingToken')}
|
|
||||||
</p>
|
|
||||||
<button type="button" onClick={onLoginClick} className={loginButtonClass}>
|
|
||||||
{t('auth.goToLogin')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : done ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-teal-700 dark:text-teal-400">{t('auth.resetSuccessBody')}</p>
|
|
||||||
<button type="button" onClick={onLoginClick} className={loginButtonClass}>
|
|
||||||
{t('auth.goToLogin')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor={passwordInputId}
|
|
||||||
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
|
|
||||||
>
|
|
||||||
{t('auth.newPassword')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id={passwordInputId}
|
|
||||||
name="new-password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
autoComplete="new-password"
|
|
||||||
autoFocus
|
|
||||||
className="w-full pl-3 pr-10 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
|
||||||
placeholder={t('auth.newPasswordPlaceholder')}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
|
||||||
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
|
||||||
aria-pressed={showPassword}
|
|
||||||
title={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 outline-none focus-visible:text-teal-600 dark:focus-visible:text-teal-400"
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOffIcon className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<EyeIcon filled={false} className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
|
||||||
>
|
|
||||||
{loading ? t('auth.pleaseWait') : t('auth.updatePassword')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onLoginClick}
|
|
||||||
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
|
||||||
>
|
|
||||||
{t('auth.backToLogin')}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -143,11 +143,11 @@ export default memo(function AiFilterInput({
|
||||||
|
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
|
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded(true)}
|
onClick={() => setExpanded(true)}
|
||||||
className="w-full flex items-center gap-2 px-3 py-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"
|
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"
|
||||||
>
|
>
|
||||||
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
|
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||||
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
|
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
|
||||||
|
|
@ -159,8 +159,8 @@ export default memo(function AiFilterInput({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
|
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
|
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||||
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">
|
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">
|
||||||
{t('aiFilter.aiSearch')}
|
{t('aiFilter.aiSearch')}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,7 @@ import type {
|
||||||
FeatureMeta,
|
FeatureMeta,
|
||||||
FilterExclusion,
|
FilterExclusion,
|
||||||
HexagonStatsResponse,
|
HexagonStatsResponse,
|
||||||
PriceMetric,
|
|
||||||
PricePoint,
|
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { postcodeOutcode, postcodeSector } from '../../lib/postcode';
|
|
||||||
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
|
||||||
import type { HexagonLocation } from '../../lib/external-search';
|
import type { HexagonLocation } from '../../lib/external-search';
|
||||||
import {
|
import {
|
||||||
|
|
@ -292,7 +289,6 @@ export default function AreaPane({
|
||||||
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
|
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
|
||||||
}, [paneFeatureGroups, hexagonLocation]);
|
}, [paneFeatureGroups, hexagonLocation]);
|
||||||
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
|
||||||
const [priceMetric, setPriceMetric] = useState<PriceMetric>('price');
|
|
||||||
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
|
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
|
||||||
restoreKey: scrollRestoreKey ?? hexagonId,
|
restoreKey: scrollRestoreKey ?? hexagonId,
|
||||||
scrollTopRef,
|
scrollTopRef,
|
||||||
|
|
@ -552,105 +548,14 @@ export default function AreaPane({
|
||||||
{stats.price_history &&
|
{stats.price_history &&
|
||||||
(() => {
|
(() => {
|
||||||
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
|
||||||
if (uniqueYears.size <= 1) return null;
|
return uniqueYears.size > 1 ? (
|
||||||
|
|
||||||
const charts: {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
dot: string;
|
|
||||||
points: PricePoint[];
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
key: 'area',
|
|
||||||
label: t('areaPane.priceHistoryThisArea'),
|
|
||||||
dot: 'bg-teal-500 dark:bg-teal-400',
|
|
||||||
points: stats.price_history,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
// Sector/outcode context is postcode-only: a hexagon cell
|
|
||||||
// straddles arbitrary postcodes, so its sector/outcode is not a
|
|
||||||
// meaningful aggregation unit. The Total/Per-m² toggle still
|
|
||||||
// applies to the area chart in both cases.
|
|
||||||
if (isPostcode && hexagonId) {
|
|
||||||
const sector = postcodeSector(hexagonId);
|
|
||||||
const outcode = postcodeOutcode(hexagonId);
|
|
||||||
if (sector && stats.sector_price_history?.length) {
|
|
||||||
charts.push({
|
|
||||||
key: 'sector',
|
|
||||||
label: sector,
|
|
||||||
dot: 'bg-indigo-500 dark:bg-indigo-400',
|
|
||||||
points: stats.sector_price_history,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (outcode && stats.outcode_price_history?.length) {
|
|
||||||
charts.push({
|
|
||||||
key: 'outcode',
|
|
||||||
label: outcode,
|
|
||||||
dot: 'bg-amber-500 dark:bg-amber-400',
|
|
||||||
points: stats.outcode_price_history,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasPerSqm = charts.some((c) =>
|
|
||||||
c.points.some((p) => p.price_per_sqm != null)
|
|
||||||
);
|
|
||||||
const metric: PriceMetric = hasPerSqm ? priceMetric : 'price';
|
|
||||||
const optionClass = (active: boolean) =>
|
|
||||||
`px-2 py-0.5 text-[11px] font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
|
|
||||||
active
|
|
||||||
? 'bg-teal-600 text-white dark:bg-teal-500'
|
|
||||||
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
|
|
||||||
}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
|
||||||
<div className="mb-1 flex items-center justify-between gap-2">
|
<span className="text-xs text-warm-700 dark:text-warm-300">
|
||||||
<span className="text-xs text-warm-700 dark:text-warm-300">
|
{t('areaPane.priceHistory')}
|
||||||
{t('areaPane.priceHistory')}
|
</span>
|
||||||
</span>
|
<PriceHistoryChart points={stats.price_history} />
|
||||||
{hasPerSqm && (
|
|
||||||
<div
|
|
||||||
className="grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700"
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label={t('areaPane.priceMetric')}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={metric === 'price'}
|
|
||||||
onClick={() => setPriceMetric('price')}
|
|
||||||
className={optionClass(metric === 'price')}
|
|
||||||
>
|
|
||||||
{t('areaPane.priceMetricTotal')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={metric === 'price_per_sqm'}
|
|
||||||
onClick={() => setPriceMetric('price_per_sqm')}
|
|
||||||
className={optionClass(metric === 'price_per_sqm')}
|
|
||||||
>
|
|
||||||
{t('areaPane.priceMetricPerSqm')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{charts.map((chart) => (
|
|
||||||
<div key={chart.key} className={chart.key === 'area' ? '' : 'mt-1.5'}>
|
|
||||||
{charts.length > 1 && (
|
|
||||||
<span className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-warm-500 dark:text-warm-400">
|
|
||||||
<span
|
|
||||||
className={`inline-block h-1.5 w-1.5 rounded-full ${chart.dot}`}
|
|
||||||
/>
|
|
||||||
{chart.label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<PriceHistoryChart points={chart.points} metric={metric} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
{displayFeatureGroups.map((group) => {
|
{displayFeatureGroups.map((group) => {
|
||||||
const showNearbyStations =
|
const showNearbyStations =
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ export default function FeatureBrowser({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="shrink-0 px-2 py-1 border-b border-warm-200 dark:border-navy-700">
|
<div className="shrink-0 px-2 py-1.5 border-b border-warm-200 dark:border-navy-700">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
value={search}
|
value={search}
|
||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
|
|
@ -125,7 +125,7 @@ export default function FeatureBrowser({
|
||||||
toggleGroup(group.name);
|
toggleGroup(group.name);
|
||||||
onGroupToggle?.(group.name, willExpand);
|
onGroupToggle?.(group.name, willExpand);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-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"
|
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"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
|
||||||
{group.features.length +
|
{group.features.length +
|
||||||
|
|
@ -141,7 +141,7 @@ export default function FeatureBrowser({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={mode}
|
key={mode}
|
||||||
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 min-w-0"
|
className="flex items-center gap-2 min-w-0"
|
||||||
|
|
@ -184,7 +184,7 @@ export default function FeatureBrowser({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={f.name}
|
key={f.name}
|
||||||
className="flex items-center justify-between px-3 py-1.5 md:py-1 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 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 mr-2">
|
<div className="min-w-0 mr-2">
|
||||||
<FeatureLabel feature={f} size="sm" description={f.description} />
|
<FeatureLabel feature={f} size="sm" description={f.description} />
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
// @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,89 +2,12 @@ import { memo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
|
|
||||||
import type { ActualListing, PriceHistoryPoint } from '../../types';
|
import type { ActualListing } from '../../types';
|
||||||
|
|
||||||
function formatListingPrice(price: number): string {
|
function formatListingPrice(price: number): string {
|
||||||
return `£${price.toLocaleString()}`;
|
return `£${price.toLocaleString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHistoryDate(iso: string, locale: string): string {
|
|
||||||
const parsed = new Date(iso);
|
|
||||||
if (Number.isNaN(parsed.getTime())) return iso;
|
|
||||||
// `iso` is a UTC calendar date ("YYYY-MM-DD"), parsed as UTC midnight. Format
|
|
||||||
// in UTC too, or a viewer west of UTC would see every date shifted a day back.
|
|
||||||
return parsed.toLocaleDateString(locale, {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function reasonLabel(reason: string, t: TFunction): string {
|
|
||||||
switch (reason) {
|
|
||||||
case 'reduced':
|
|
||||||
return t('listing.priceReduced');
|
|
||||||
case 'increased':
|
|
||||||
return t('listing.priceIncreased');
|
|
||||||
default:
|
|
||||||
return t('listing.priceListed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compact asking-price timeline for the hover card. Renders most-recent first,
|
|
||||||
* with the £ change from the prior point on each reduction/increase. Accrues from
|
|
||||||
* the scraper's forward-only store, so a fresh listing shows a single point. */
|
|
||||||
function ListingPriceHistory({ history }: { history: PriceHistoryPoint[] }) {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
if (history.length === 0) return null;
|
|
||||||
// history is oldest -> newest; show up to the 5 most recent, newest first.
|
|
||||||
const shown = history.slice(-5);
|
|
||||||
const baseIndex = history.length - shown.length;
|
|
||||||
const rows = shown
|
|
||||||
.map((point, idx) => {
|
|
||||||
const globalIdx = baseIndex + idx;
|
|
||||||
const prev = globalIdx > 0 ? history[globalIdx - 1] : null;
|
|
||||||
const delta = prev ? point.price - prev.price : 0;
|
|
||||||
return { point, delta };
|
|
||||||
})
|
|
||||||
.reverse();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 border-t border-warm-100 pt-1.5 dark:border-warm-700/60">
|
|
||||||
<div className="text-[11px] font-medium text-warm-500 dark:text-warm-400">
|
|
||||||
{t('listing.priceHistory')}
|
|
||||||
</div>
|
|
||||||
<ol className="mt-1 space-y-0.5">
|
|
||||||
{rows.map(({ point, delta }, idx) => {
|
|
||||||
const isReduction = point.reason === 'reduced';
|
|
||||||
const isIncrease = point.reason === 'increased';
|
|
||||||
const accent = isReduction
|
|
||||||
? 'text-emerald-600 dark:text-emerald-400'
|
|
||||||
: isIncrease
|
|
||||||
? 'text-red-600 dark:text-red-400'
|
|
||||||
: 'text-warm-600 dark:text-warm-300';
|
|
||||||
return (
|
|
||||||
<li key={idx} className="flex items-baseline justify-between gap-2 text-[11px]">
|
|
||||||
<span className="flex items-baseline gap-1.5">
|
|
||||||
<span className={`font-semibold ${accent}`}>{formatListingPrice(point.price)}</span>
|
|
||||||
{delta !== 0 && (
|
|
||||||
<span className={accent}>
|
|
||||||
{delta < 0 ? '−' : '+'}£{Math.abs(delta).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-warm-400 dark:text-warm-500">
|
|
||||||
{reasonLabel(point.reason, t)} · {formatHistoryDate(point.date, i18n.language)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
|
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
|
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
|
||||||
|
|
@ -155,9 +78,6 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{listing.price_history && listing.price_history.length > 0 && (
|
|
||||||
<ListingPriceHistory history={listing.price_history} />
|
|
||||||
)}
|
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
|
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
|
||||||
{visited && (
|
{visited && (
|
||||||
<span className="text-violet-600 dark:text-violet-400">✓ {t('listing.viewed')}</span>
|
<span className="text-violet-600 dark:text-violet-400">✓ {t('listing.viewed')}</span>
|
||||||
|
|
|
||||||
|
|
@ -710,11 +710,6 @@ export default function MapPage({
|
||||||
const shareAndSaveView = isMobile
|
const shareAndSaveView = isMobile
|
||||||
? (mapData.currentVisibleView ?? mapData.currentView)
|
? (mapData.currentVisibleView ?? mapData.currentView)
|
||||||
: mapData.currentView;
|
: mapData.currentView;
|
||||||
// Params for share/save/checkout-return/last-session, deliberately WITHOUT the
|
|
||||||
// selected postcode: the lat/lon/zoom already convey the location, so focusing
|
|
||||||
// a postcode adds nothing to a shared link and shouldn't be baked into a saved
|
|
||||||
// search. The live URL (useUrlSync above) still carries `pc` so a reload
|
|
||||||
// re-opens the selection.
|
|
||||||
const dashboardParams = useMemo(
|
const dashboardParams = useMemo(
|
||||||
() =>
|
() =>
|
||||||
stateToParams(
|
stateToParams(
|
||||||
|
|
@ -728,7 +723,7 @@ export default function MapPage({
|
||||||
activeOverlays,
|
activeOverlays,
|
||||||
basemap,
|
basemap,
|
||||||
crimeTypes,
|
crimeTypes,
|
||||||
undefined,
|
selectedPostcodeParam,
|
||||||
colorOpacity,
|
colorOpacity,
|
||||||
listingsMode
|
listingsMode
|
||||||
).toString(),
|
).toString(),
|
||||||
|
|
@ -743,6 +738,7 @@ export default function MapPage({
|
||||||
listingsMode,
|
listingsMode,
|
||||||
rightPaneTab,
|
rightPaneTab,
|
||||||
selectedPOICategories,
|
selectedPOICategories,
|
||||||
|
selectedPostcodeParam,
|
||||||
shareCode,
|
shareCode,
|
||||||
shareAndSaveView,
|
shareAndSaveView,
|
||||||
]
|
]
|
||||||
|
|
@ -898,9 +894,6 @@ export default function MapPage({
|
||||||
total={propertiesTotal}
|
total={propertiesTotal}
|
||||||
loading={loadingProperties}
|
loading={loadingProperties}
|
||||||
hexagonId={selectedHexagon?.id || null}
|
hexagonId={selectedHexagon?.id || null}
|
||||||
statsUseFilters={areaStatsUseFilters}
|
|
||||||
onStatsUseFiltersChange={setAreaStatsUseFilters}
|
|
||||||
filtersActive={Object.keys(filters).length + activeEntries.length > 0}
|
|
||||||
onLoadMore={handleLoadMoreProperties}
|
onLoadMore={handleLoadMoreProperties}
|
||||||
scrollTopRef={propertiesPaneScrollTopRef}
|
scrollTopRef={propertiesPaneScrollTopRef}
|
||||||
scrollRestoreKey={
|
scrollRestoreKey={
|
||||||
|
|
@ -910,17 +903,7 @@ export default function MapPage({
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
[
|
[handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon]
|
||||||
activeEntries,
|
|
||||||
areaStatsUseFilters,
|
|
||||||
filters,
|
|
||||||
handleLoadMoreProperties,
|
|
||||||
loadingProperties,
|
|
||||||
properties,
|
|
||||||
propertiesTotal,
|
|
||||||
selectedHexagon,
|
|
||||||
setAreaStatsUseFilters,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const poiPane = useMemo(
|
const poiPane = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||||
import type { PriceMetric, PricePoint } from '../../types';
|
import type { PricePoint } from '../../types';
|
||||||
import { formatValue } from '../../lib/format';
|
import { formatValue } from '../../lib/format';
|
||||||
|
|
||||||
interface PriceHistoryChartProps {
|
interface PriceHistoryChartProps {
|
||||||
points: PricePoint[];
|
points: PricePoint[];
|
||||||
/** Which value to plot. Defaults to the absolute sale price. */
|
|
||||||
metric?: PriceMetric;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
|
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
|
||||||
|
|
@ -24,7 +22,7 @@ interface PriceScale {
|
||||||
ticks: number[];
|
ticks: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PriceHistoryChart({ points, metric = 'price' }: PriceHistoryChartProps) {
|
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [width, setWidth] = useState(0);
|
const [width, setWidth] = useState(0);
|
||||||
|
|
||||||
|
|
@ -39,18 +37,7 @@ export default function PriceHistoryChart({ points, metric = 'price' }: PriceHis
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Project each point onto the chosen metric as a plain {year, price} so the
|
|
||||||
// rest of the chart is metric-agnostic. In per-m² mode, points without a
|
|
||||||
// recorded floor area drop out.
|
|
||||||
const plotPoints = useMemo<PricePoint[]>(() => {
|
|
||||||
if (metric === 'price') return points;
|
|
||||||
return points
|
|
||||||
.filter((p) => Number.isFinite(p.price_per_sqm))
|
|
||||||
.map((p) => ({ year: p.year, price: p.price_per_sqm as number }));
|
|
||||||
}, [points, metric]);
|
|
||||||
|
|
||||||
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
|
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
|
||||||
const points = plotPoints;
|
|
||||||
let yMin = Infinity,
|
let yMin = Infinity,
|
||||||
yMax = -Infinity;
|
yMax = -Infinity;
|
||||||
for (const p of points) {
|
for (const p of points) {
|
||||||
|
|
@ -96,7 +83,7 @@ export default function PriceHistoryChart({ points, metric = 'price' }: PriceHis
|
||||||
priceScale: getPriceScale(points),
|
priceScale: getPriceScale(points),
|
||||||
medians: meds,
|
medians: meds,
|
||||||
};
|
};
|
||||||
}, [plotPoints]);
|
}, [points]);
|
||||||
|
|
||||||
const plotW = width - PADDING.left - PADDING.right;
|
const plotW = width - PADDING.left - PADDING.right;
|
||||||
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
|
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
|
||||||
|
|
@ -117,7 +104,7 @@ export default function PriceHistoryChart({ points, metric = 'price' }: PriceHis
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ height: HEIGHT }}>
|
<div ref={containerRef} style={{ height: HEIGHT }}>
|
||||||
{width > 0 && plotPoints.length > 0 && (
|
{width > 0 && (
|
||||||
<svg width={width} height={HEIGHT}>
|
<svg width={width} height={HEIGHT}>
|
||||||
{/* Grid lines */}
|
{/* Grid lines */}
|
||||||
{priceScale.ticks.map((tick) => (
|
{priceScale.ticks.map((tick) => (
|
||||||
|
|
@ -133,7 +120,7 @@ export default function PriceHistoryChart({ points, metric = 'price' }: PriceHis
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Dots */}
|
{/* Dots */}
|
||||||
{plotPoints.map((p, i) => (
|
{points.map((p, i) => (
|
||||||
<circle
|
<circle
|
||||||
key={i}
|
key={i}
|
||||||
cx={scaleX(p.year)}
|
cx={scaleX(p.year)}
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,6 @@ interface PropertiesPaneProps {
|
||||||
total: number;
|
total: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
hexagonId: string | null;
|
hexagonId: string | null;
|
||||||
statsUseFilters: boolean;
|
|
||||||
onStatsUseFiltersChange: (useFilters: boolean) => void;
|
|
||||||
filtersActive: boolean;
|
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
onNavigateToSource?: (slug: string) => void;
|
onNavigateToSource?: (slug: string) => void;
|
||||||
scrollTopRef?: MutableRefObject<number>;
|
scrollTopRef?: MutableRefObject<number>;
|
||||||
|
|
@ -38,9 +35,6 @@ export function PropertiesPane({
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
hexagonId,
|
hexagonId,
|
||||||
statsUseFilters,
|
|
||||||
onStatsUseFiltersChange,
|
|
||||||
filtersActive,
|
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
onNavigateToSource,
|
onNavigateToSource,
|
||||||
scrollTopRef,
|
scrollTopRef,
|
||||||
|
|
@ -112,41 +106,13 @@ export function PropertiesPane({
|
||||||
</InfoPopup>
|
</InfoPopup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-2 space-y-2">
|
<div className="p-2">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
value={search}
|
value={search}
|
||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
placeholder={t('propertyCard.searchPlaceholder')}
|
placeholder={t('propertyCard.searchPlaceholder')}
|
||||||
className="p-2"
|
className="p-2"
|
||||||
/>
|
/>
|
||||||
{filtersActive && (
|
|
||||||
<div className="grid grid-cols-2 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-pressed={statsUseFilters}
|
|
||||||
onClick={() => onStatsUseFiltersChange(true)}
|
|
||||||
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
|
||||||
statsUseFilters
|
|
||||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
|
||||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t('areaPane.matchingFiltersOption')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-pressed={!statsUseFilters}
|
|
||||||
onClick={() => onStatsUseFiltersChange(false)}
|
|
||||||
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
|
|
||||||
!statsUseFilters
|
|
||||||
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
|
|
||||||
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t('areaPane.allPropertiesOption')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -307,9 +307,7 @@ export function ActiveFilterList({
|
||||||
dragValue={dragValue}
|
dragValue={dragValue}
|
||||||
pinnedFeature={pinnedFeature}
|
pinnedFeature={pinnedFeature}
|
||||||
filterImpact={councilBackendName ? filterImpacts?.[councilBackendName] : undefined}
|
filterImpact={councilBackendName ? filterImpacts?.[councilBackendName] : undefined}
|
||||||
percentileScale={
|
percentileScale={councilBackendName ? percentileScales.get(councilBackendName) : undefined}
|
||||||
councilBackendName ? percentileScales.get(councilBackendName) : undefined
|
|
||||||
}
|
|
||||||
onFilterChange={onFilterChange}
|
onFilterChange={onFilterChange}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragChange={onDragChange}
|
onDragChange={onDragChange}
|
||||||
|
|
@ -450,12 +448,12 @@ export function ActiveFilterList({
|
||||||
onToggleGroup(group.name);
|
onToggleGroup(group.name);
|
||||||
onGroupToggle?.(group.name, !expanded);
|
onGroupToggle?.(group.name, !expanded);
|
||||||
}}
|
}}
|
||||||
className="sticky top-0 z-30 px-3 py-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"
|
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"
|
||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
|
||||||
</CollapsibleGroupHeader>
|
</CollapsibleGroupHeader>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="px-2 py-1 space-y-2">
|
<div className="px-2 py-1.5 space-y-3.5">
|
||||||
{group.name === TRANSPORT_GROUP && travelCards}
|
{group.name === TRANSPORT_GROUP && travelCards}
|
||||||
{group.features.map((feature) => renderFeatureCard(feature))}
|
{group.features.map((feature) => renderFeatureCard(feature))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ export function ActiveFiltersPanel({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapsed}
|
onClick={onToggleCollapsed}
|
||||||
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-2 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
|
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
|
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
|
||||||
|
|
@ -202,7 +202,7 @@ export function ActiveFiltersPanel({
|
||||||
onLoginRequired={onLoginRequired}
|
onLoginRequired={onLoginRequired}
|
||||||
/>
|
/>
|
||||||
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
||||||
<p className="px-3 py-1 text-xs text-warm-400 dark:text-warm-500">
|
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
||||||
{t('filters.addFiltersHint')}
|
{t('filters.addFiltersHint')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -32,29 +32,6 @@ import {
|
||||||
type PoiFilterName,
|
type PoiFilterName,
|
||||||
} from '../../../lib/poi-distance-filter';
|
} from '../../../lib/poi-distance-filter';
|
||||||
|
|
||||||
// Each resolver maps a raw pinned feature name to the folded filter card that
|
|
||||||
// represents it in the browser, or returns null when it doesn't belong to that fold.
|
|
||||||
const FOLDED_FILTER_RESOLVERS: Array<(name: string) => string | null> = [
|
|
||||||
(name) => (isSchoolFilterName(name) ? SCHOOL_FILTER_NAME : null),
|
|
||||||
(name) => (isSpecificCrimeFilterName(name) ? SPECIFIC_CRIMES_FILTER_NAME : null),
|
|
||||||
(name) => (isElectionVoteShareFilterName(name) ? ELECTION_VOTE_SHARE_FILTER_NAME : null),
|
|
||||||
(name) => (isEthnicityFilterName(name) ? ETHNICITIES_FILTER_NAME : null),
|
|
||||||
(name) => (isQualificationFilterName(name) ? QUALIFICATIONS_FILTER_NAME : null),
|
|
||||||
(name) => (isTenureFilterName(name) ? TENURE_FILTER_NAME : null),
|
|
||||||
(name) => (isCouncilFilterName(name) ? COUNCIL_FILTER_NAME : null),
|
|
||||||
(name) =>
|
|
||||||
isPoiDistanceFilterName(name) ? (getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME) : null,
|
|
||||||
(name) => (isCrimeSeverityFilterName(name) ? (getCrimeSeverityFilterName(name) ?? name) : null),
|
|
||||||
];
|
|
||||||
|
|
||||||
function resolveBrowserPinnedFeature(name: string): string {
|
|
||||||
for (const resolve of FOLDED_FILTER_RESOLVERS) {
|
|
||||||
const folded = resolve(name);
|
|
||||||
if (folded) return folded;
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddFilterPanelProps {
|
interface AddFilterPanelProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
|
|
@ -114,7 +91,26 @@ export function AddFilterPanel({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
|
const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand();
|
||||||
|
|
||||||
const browserPinnedFeature = pinnedFeature && resolveBrowserPinnedFeature(pinnedFeature);
|
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 handleTogglePin = (name: string) => {
|
const handleTogglePin = (name: string) => {
|
||||||
if (name === SCHOOL_FILTER_NAME) {
|
if (name === SCHOOL_FILTER_NAME) {
|
||||||
|
|
@ -167,7 +163,7 @@ export function AddFilterPanel({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapsed}
|
onClick={onToggleCollapsed}
|
||||||
className="shrink-0 flex items-center justify-between border-b border-teal-300 bg-teal-100 px-3 py-2 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
|
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"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
|
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
|
||||||
{t('filters.addFilter')}
|
{t('filters.addFilter')}
|
||||||
|
|
@ -180,7 +176,7 @@ export function AddFilterPanel({
|
||||||
{(!collapsed || !isLicensed) && (
|
{(!collapsed || !isLicensed) && (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto">
|
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto md:min-h-[12rem]">
|
||||||
<FeatureBrowser
|
<FeatureBrowser
|
||||||
availableFeatures={availableFeatures}
|
availableFeatures={availableFeatures}
|
||||||
allFeatures={allFeatures}
|
allFeatures={allFeatures}
|
||||||
|
|
@ -198,7 +194,7 @@ export function AddFilterPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLicensed && (
|
{!isLicensed && (
|
||||||
<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">
|
<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">
|
||||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-snug">
|
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-snug">
|
||||||
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '}
|
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '}
|
||||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||||
|
|
@ -207,7 +203,7 @@ export function AddFilterPanel({
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick}
|
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
|
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME}
|
data-filter-name={ELECTION_VOTE_SHARE_FILTER_NAME}
|
||||||
className={`space-y-1 px-2 py-1 rounded ${
|
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||||
isActive
|
isActive
|
||||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||||
: isPinned
|
: isPinned
|
||||||
|
|
@ -151,7 +151,7 @@ export function ElectionVoteShareFilterCard({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
{t('filters.party')}
|
{t('filters.party')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export function EnumFeatureFilterCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-filter-name={feature.name}
|
data-filter-name={feature.name}
|
||||||
className={`space-y-0.5 px-2 py-1 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.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-1">
|
<div className="flex items-start justify-between gap-1">
|
||||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap />
|
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap />
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ export function EthnicityFilterCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-filter-name={ETHNICITIES_FILTER_NAME}
|
data-filter-name={ETHNICITIES_FILTER_NAME}
|
||||||
className={`space-y-1 px-2 py-1 rounded ${
|
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||||
isActive
|
isActive
|
||||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||||
: isPinned
|
: isPinned
|
||||||
|
|
@ -147,7 +147,7 @@ export function EthnicityFilterCard({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
{t('filters.ethnicity')}
|
{t('filters.ethnicity')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export function NumericFeatureFilterCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-filter-name={feature.name}
|
data-filter-name={feature.name}
|
||||||
className={`space-y-0.5 px-2 py-1 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.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' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||||
<FeatureLabel
|
<FeatureLabel
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ export function PoiDistanceFilterCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-filter-name={filterName}
|
data-filter-name={filterName}
|
||||||
className={`space-y-1 px-2 py-1 rounded ${
|
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||||
isActive
|
isActive
|
||||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||||
: isPinned
|
: isPinned
|
||||||
|
|
@ -156,7 +156,7 @@ export function PoiDistanceFilterCard({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
{t('filters.poiType')}
|
{t('filters.poiType')}
|
||||||
</label>
|
</label>
|
||||||
<PoiTypeDropdown
|
<PoiTypeDropdown
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ export function SchoolFilterCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-filter-name={SCHOOL_FILTER_NAME}
|
data-filter-name={SCHOOL_FILTER_NAME}
|
||||||
className={`space-y-1 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' : ''}`}
|
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' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="relative z-10 flex items-center justify-between gap-1">
|
<div className="relative z-10 flex items-center justify-between gap-1">
|
||||||
<FeatureLabel
|
<FeatureLabel
|
||||||
|
|
@ -127,7 +127,7 @@ export function SchoolFilterCard({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
{t('filters.schoolType')}
|
{t('filters.schoolType')}
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ export function SliderLabels({
|
||||||
|
|
||||||
if (feature && onValueChange) {
|
if (feature && onValueChange) {
|
||||||
return (
|
return (
|
||||||
<div className="relative h-4 mt-1.5 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
|
||||||
<EditableLabel
|
<EditableLabel
|
||||||
value={labels[0]}
|
value={labels[0]}
|
||||||
formatted={minLabel}
|
formatted={minLabel}
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ export function VariantFilterCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-filter-name={config.filterName}
|
data-filter-name={config.filterName}
|
||||||
className={`space-y-1 px-2 py-1 rounded ${
|
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
||||||
isActive
|
isActive
|
||||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||||
: isPinned
|
: isPinned
|
||||||
|
|
@ -190,7 +190,7 @@ export function VariantFilterCard({
|
||||||
so the dropdown is hidden and only the window toggle + slider remain. */}
|
so the dropdown is hidden and only the window toggle + slider remain. */}
|
||||||
{variantOptions.length > 1 && (
|
{variantOptions.length > 1 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
{t(config.dropdownLabelKey)}
|
{t(config.dropdownLabelKey)}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -216,7 +216,7 @@ export function VariantFilterCard({
|
||||||
{windowConfig && currentWindow && windowOptions.length > 1 && (
|
{windowConfig && currentWindow && windowOptions.length > 1 && (
|
||||||
<div>
|
<div>
|
||||||
{windowConfig.labelKey && (
|
{windowConfig.labelKey && (
|
||||||
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
{t(windowConfig.labelKey)}
|
{t(windowConfig.labelKey)}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,7 @@ export function DesktopMapPage({
|
||||||
>
|
>
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
|
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
|
||||||
<div
|
<div
|
||||||
className="w-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"
|
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"
|
||||||
{...leftPaneHandlers}
|
{...leftPaneHandlers}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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,8 +3,6 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||||
import type { ParseKeys } from 'i18next';
|
import type { ParseKeys } from 'i18next';
|
||||||
import { CloseIcon } from './icons/CloseIcon';
|
import { CloseIcon } from './icons/CloseIcon';
|
||||||
import { GoogleIcon } from './icons/GoogleIcon';
|
import { GoogleIcon } from './icons/GoogleIcon';
|
||||||
import { EyeIcon } from './icons/EyeIcon';
|
|
||||||
import { EyeOffIcon } from './icons/EyeOffIcon';
|
|
||||||
import { trackEvent } from '../../lib/analytics';
|
import { trackEvent } from '../../lib/analytics';
|
||||||
import { useModalA11y } from '../../hooks/useModalA11y';
|
import { useModalA11y } from '../../hooks/useModalA11y';
|
||||||
import type { AuthErrorAction } from '../../lib/auth-errors';
|
import type { AuthErrorAction } from '../../lib/auth-errors';
|
||||||
|
|
@ -44,7 +42,6 @@ export default function AuthModal({
|
||||||
const [view, setView] = useState<View>(initialTab);
|
const [view, setView] = useState<View>(initialTab);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [resetSent, setResetSent] = useState(false);
|
const [resetSent, setResetSent] = useState(false);
|
||||||
const dialogRef = useModalA11y();
|
const dialogRef = useModalA11y();
|
||||||
const fieldId = useId();
|
const fieldId = useId();
|
||||||
|
|
@ -235,38 +232,22 @@ export default function AuthModal({
|
||||||
>
|
>
|
||||||
{t('auth.password')}
|
{t('auth.password')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
id={passwordInputId}
|
||||||
id={passwordInputId}
|
name="password"
|
||||||
name="password"
|
type="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
value={password}
|
||||||
value={password}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
required
|
||||||
required
|
minLength={8}
|
||||||
minLength={8}
|
autoComplete={view === 'register' ? 'new-password' : 'current-password'}
|
||||||
autoComplete={view === 'register' ? 'new-password' : 'current-password'}
|
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||||
className="w-full pl-3 pr-10 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
placeholder={
|
||||||
placeholder={
|
view === 'register'
|
||||||
view === 'register'
|
? t('auth.passwordPlaceholderRegister')
|
||||||
? t('auth.passwordPlaceholderRegister')
|
: t('auth.passwordPlaceholderLogin')
|
||||||
: t('auth.passwordPlaceholderLogin')
|
}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword((v) => !v)}
|
|
||||||
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
|
||||||
aria-pressed={showPassword}
|
|
||||||
title={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 outline-none focus-visible:text-teal-600 dark:focus-visible:text-teal-400"
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOffIcon className="w-5 h-5" />
|
|
||||||
) : (
|
|
||||||
<EyeIcon filled={false} className="w-5 h-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{view === 'login' && (
|
{view === 'login' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,7 @@ export type Page =
|
||||||
| 'privacy'
|
| 'privacy'
|
||||||
| 'account'
|
| 'account'
|
||||||
| 'saved'
|
| 'saved'
|
||||||
| 'invite'
|
| 'invite';
|
||||||
| 'reset-password';
|
|
||||||
|
|
||||||
export interface HeaderExportState {
|
export interface HeaderExportState {
|
||||||
onExport: (options?: { postcodes?: string[] }) => void;
|
onExport: (options?: { postcodes?: string[] }) => void;
|
||||||
|
|
@ -66,7 +65,6 @@ export const PAGE_PATHS: Record<Page, string> = {
|
||||||
saved: '/saved',
|
saved: '/saved',
|
||||||
account: '/account',
|
account: '/account',
|
||||||
invite: '/invite',
|
invite: '/invite',
|
||||||
'reset-password': '/reset-password',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
|
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
|
||||||
|
|
@ -208,6 +206,33 @@ export default function Header({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
|
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
|
||||||
|
{showEditingBar && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-0 flex items-center justify-center px-4">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-3 max-w-[60%]">
|
||||||
|
<span className="text-sm text-warm-300 truncate" title={editingSearch.name}>
|
||||||
|
<Trans
|
||||||
|
i18nKey="savedPage.isBeingUpdated"
|
||||||
|
values={{ name: editingSearch.name }}
|
||||||
|
components={{ strong: <strong className="font-semibold text-white" /> }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onCancelEdit}
|
||||||
|
className="cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onUpdateEdit}
|
||||||
|
disabled={savingSearch || dashboardActionsBlocked}
|
||||||
|
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||||
|
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Left: Logo + nav */}
|
{/* Left: Logo + nav */}
|
||||||
<div className="flex min-w-0 items-center gap-4">
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
<a
|
<a
|
||||||
|
|
@ -221,9 +246,9 @@ export default function Header({
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Desktop nav: hidden while the saved-search "is being updated" bar is
|
{/* Desktop nav: hidden while the saved-search "is being updated" banner
|
||||||
shown so the centered edit bar has room and the header can't get
|
is shown so the centered pointer-events-auto banner can't overlap (and
|
||||||
cramped (the bar would otherwise crowd the Learn / Pricing links). */}
|
block clicks on) the Invite Friends / Learn / Pricing links at ~1366px. */}
|
||||||
{!useSidebarNav && !showEditingBar && (
|
{!useSidebarNav && !showEditingBar && (
|
||||||
<nav className="top-menu flex items-center">
|
<nav className="top-menu flex items-center">
|
||||||
<a
|
<a
|
||||||
|
|
@ -262,36 +287,6 @@ export default function Header({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Saved-search "is being updated" bar. In normal flow (not an absolute
|
|
||||||
overlay) between the logo and the right-side actions so flexbox
|
|
||||||
reserves its space: the Cancel / Update buttons can never overlap the
|
|
||||||
Share / Export buttons. Text truncates; the buttons stay put. */}
|
|
||||||
{showEditingBar && (
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-3 px-3">
|
|
||||||
<span className="min-w-0 truncate text-sm text-warm-300" title={editingSearch.name}>
|
|
||||||
<Trans
|
|
||||||
i18nKey="savedPage.isBeingUpdated"
|
|
||||||
values={{ name: editingSearch.name }}
|
|
||||||
components={{ strong: <strong className="font-semibold text-white" /> }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={onCancelEdit}
|
|
||||||
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onUpdateEdit}
|
|
||||||
disabled={savingSearch || dashboardActionsBlocked}
|
|
||||||
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
|
||||||
{savingSearch ? t('savedPage.updating') : t('common.update')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
{/* Desktop-only dashboard actions: shown to everyone; a logged-out click
|
{/* Desktop-only dashboard actions: shown to everyone; a logged-out click
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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,7 +9,6 @@ export { CloseIcon } from './CloseIcon';
|
||||||
export { DownloadIcon } from './DownloadIcon';
|
export { DownloadIcon } from './DownloadIcon';
|
||||||
export { ExpandIcon } from './ExpandIcon';
|
export { ExpandIcon } from './ExpandIcon';
|
||||||
export { EyeIcon } from './EyeIcon';
|
export { EyeIcon } from './EyeIcon';
|
||||||
export { EyeOffIcon } from './EyeOffIcon';
|
|
||||||
export { FilterIcon } from './FilterIcon';
|
export { FilterIcon } from './FilterIcon';
|
||||||
export { GoogleIcon } from './GoogleIcon';
|
export { GoogleIcon } from './GoogleIcon';
|
||||||
export { GraduationCapIcon } from './GraduationCapIcon';
|
export { GraduationCapIcon } from './GraduationCapIcon';
|
||||||
|
|
|
||||||
|
|
@ -32,16 +32,6 @@ function authRefreshInvalidated(error: unknown): boolean {
|
||||||
return status === 401 || status === 403;
|
return status === 401 || status === 403;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize an email so login and registration are case- and
|
|
||||||
* whitespace-insensitive: ` John@Example.com` and `john@example.com`
|
|
||||||
* resolve to the same account. Applied at the PocketBase boundary rather
|
|
||||||
* than on the input field, so the user still sees exactly what they typed.
|
|
||||||
*/
|
|
||||||
function normalizeEmail(email: string): string {
|
|
||||||
return email.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [user, setUser] = useState<AuthUser | null>(() => {
|
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||||
|
|
@ -72,9 +62,7 @@ export function useAuth() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setErrorAction(null);
|
setErrorAction(null);
|
||||||
try {
|
try {
|
||||||
const result = await pb
|
const result = await pb.collection('users').authWithPassword(email, password);
|
||||||
.collection('users')
|
|
||||||
.authWithPassword(normalizeEmail(email), password);
|
|
||||||
setUser(recordToUser(result.record));
|
setUser(recordToUser(result.record));
|
||||||
trackEvent('Login', { method: 'email' });
|
trackEvent('Login', { method: 'email' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -94,13 +82,12 @@ export function useAuth() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setErrorAction(null);
|
setErrorAction(null);
|
||||||
const normalizedEmail = normalizeEmail(email);
|
|
||||||
try {
|
try {
|
||||||
// Step 1: create the account. A failure here means nothing was created,
|
// Step 1: create the account. A failure here means nothing was created,
|
||||||
// so surface a friendly, field-aware message (e.g. "email already exists").
|
// so surface a friendly, field-aware message (e.g. "email already exists").
|
||||||
try {
|
try {
|
||||||
await pb.collection('users').create({
|
await pb.collection('users').create({
|
||||||
email: normalizedEmail,
|
email,
|
||||||
password,
|
password,
|
||||||
passwordConfirm: password,
|
passwordConfirm: password,
|
||||||
newsletter: true,
|
newsletter: true,
|
||||||
|
|
@ -116,7 +103,7 @@ export function useAuth() {
|
||||||
// orphaned: tell the user it was created and let them log in with the
|
// orphaned: tell the user it was created and let them log in with the
|
||||||
// same credentials, instead of a misleading "registration failed".
|
// same credentials, instead of a misleading "registration failed".
|
||||||
try {
|
try {
|
||||||
const result = await pb.collection('users').authWithPassword(normalizedEmail, password);
|
const result = await pb.collection('users').authWithPassword(email, password);
|
||||||
setUser(recordToUser(result.record));
|
setUser(recordToUser(result.record));
|
||||||
trackEvent('Register');
|
trackEvent('Register');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -172,7 +159,7 @@ export function useAuth() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setErrorAction(null);
|
setErrorAction(null);
|
||||||
try {
|
try {
|
||||||
await pb.collection('users').requestPasswordReset(normalizeEmail(email));
|
await pb.collection('users').requestPasswordReset(email);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { message, action } = resolveAuthError(err, 'reset', t);
|
const { message, action } = resolveAuthError(err, 'reset', t);
|
||||||
setError(message);
|
setError(message);
|
||||||
|
|
@ -185,27 +172,6 @@ export function useAuth() {
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmPasswordReset = useCallback(
|
|
||||||
async (token: string, password: string) => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setErrorAction(null);
|
|
||||||
try {
|
|
||||||
// PocketBase requires the password twice; the reset form uses a single
|
|
||||||
// (show/hide) field, so both arguments are the same value.
|
|
||||||
await pb.collection('users').confirmPasswordReset(token, password, password);
|
|
||||||
} catch (err) {
|
|
||||||
const { message, action } = resolveAuthError(err, 'confirmReset', t);
|
|
||||||
setError(message);
|
|
||||||
setErrorAction(action);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshAuth = useCallback(async () => {
|
const refreshAuth = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -242,7 +208,6 @@ export function useAuth() {
|
||||||
loginWithOAuth,
|
loginWithOAuth,
|
||||||
logout,
|
logout,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
confirmPasswordReset,
|
|
||||||
refreshAuth,
|
refreshAuth,
|
||||||
clearError,
|
clearError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,8 @@ function currentUserId(): string | null {
|
||||||
*
|
*
|
||||||
* Reads the user from the shared PocketBase authStore so it stays self-contained, with no need to
|
* Reads the user from the shared PocketBase authStore so it stays self-contained, with no need to
|
||||||
* thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in
|
* thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in
|
||||||
* the map's colour accessor; a fresh Set instance is produced on every change so React re-renders.
|
* the map's colour accessor; a fresh Set instance is produced on every change so it can double
|
||||||
* Note: a Set can NOT be used directly as a deck.gl `updateTrigger` — deck diffs triggers with
|
* as a deck.gl `updateTrigger` (identity-compared).
|
||||||
* `compareProps`, which finds no enumerable keys on a Set and reports "no change". Callers must
|
|
||||||
* pass `Array.from(clickedUrls)` (or a version counter) as the trigger instead.
|
|
||||||
*
|
*
|
||||||
* Writes are optimistic: `markClicked` updates local state synchronously so the pin recolours
|
* Writes are optimistic: `markClicked` updates local state synchronously so the pin recolours
|
||||||
* the instant it is clicked, independent of the PocketBase round-trip or the next listings fetch.
|
* the instant it is clicked, independent of the PocketBase round-trip or the next listings fetch.
|
||||||
|
|
|
||||||
|
|
@ -93,23 +93,23 @@ interface UseFiltersOptions {
|
||||||
onFilterLimitReached?: () => void;
|
onFilterLimitReached?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applied in order: each normalizer folds its own raw feature names into a single
|
|
||||||
// folded filter. Council folds AFTER tenure so a bare "% Social rent" is claimed by
|
|
||||||
// tenure first.
|
|
||||||
const FILTER_NORMALIZERS: Array<(filters: FeatureFilters) => FeatureFilters> = [
|
|
||||||
normalizeSchoolFilters,
|
|
||||||
normalizeSpecificCrimeFilters,
|
|
||||||
normalizeCrimeSeverityFilters,
|
|
||||||
normalizeElectionVoteShareFilters,
|
|
||||||
normalizeEthnicityFilters,
|
|
||||||
normalizeQualificationFilters,
|
|
||||||
normalizeTenureFilters,
|
|
||||||
normalizeCouncilFilters,
|
|
||||||
normalizePoiDistanceFilters,
|
|
||||||
];
|
|
||||||
|
|
||||||
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
||||||
return FILTER_NORMALIZERS.reduce((acc, normalize) => normalize(acc), filters);
|
return normalizePoiDistanceFilters(
|
||||||
|
// Council folds AFTER tenure so a bare "% Social rent" is claimed by tenure.
|
||||||
|
normalizeCouncilFilters(
|
||||||
|
normalizeTenureFilters(
|
||||||
|
normalizeQualificationFilters(
|
||||||
|
normalizeEthnicityFilters(
|
||||||
|
normalizeElectionVoteShareFilters(
|
||||||
|
normalizeCrimeSeverityFilters(
|
||||||
|
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBackendFeatureName(name: string): string {
|
function getBackendFeatureName(name: string): string {
|
||||||
|
|
@ -215,10 +215,7 @@ export function useFilters({
|
||||||
const normalizedOriginal = normalizeFilters(initialFilters);
|
const normalizedOriginal = normalizeFilters(initialFilters);
|
||||||
originalNormalizedRef.current = normalizedOriginal;
|
originalNormalizedRef.current = normalizedOriginal;
|
||||||
const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null;
|
const budget = filterLimit != null ? Math.max(0, filterLimit - reservedFilterSlots) : null;
|
||||||
initialFiltersRef.current = clampToBudget(
|
initialFiltersRef.current = clampToBudget(dropUnknownFilters(normalizedOriginal, features), budget);
|
||||||
dropUnknownFilters(normalizedOriginal, features),
|
|
||||||
budget
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
|
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
|
||||||
|
|
|
||||||
|
|
@ -385,9 +385,7 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
||||||
highlightColor: [29, 228, 195, 220],
|
highlightColor: [29, 228, 195, 220],
|
||||||
onHover: stableHover,
|
onHover: stableHover,
|
||||||
onClick: stableClick,
|
onClick: stableClick,
|
||||||
// deck.gl can't diff a Set (no enumerable keys), so a click never
|
updateTriggers: { getFillColor: clickedUrls },
|
||||||
// re-runs getFillColor until data changes. Feed it a diffable array.
|
|
||||||
updateTriggers: { getFillColor: Array.from(clickedUrls) },
|
|
||||||
}),
|
}),
|
||||||
[visibleListings, clickedUrls, stableHover, stableClick]
|
[visibleListings, clickedUrls, stableHover, stableClick]
|
||||||
);
|
);
|
||||||
|
|
@ -485,9 +483,7 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro
|
||||||
highlightColor: [29, 228, 195, 220],
|
highlightColor: [29, 228, 195, 220],
|
||||||
onHover: stableExpandedHover,
|
onHover: stableExpandedHover,
|
||||||
onClick: stableExpandedClick,
|
onClick: stableExpandedClick,
|
||||||
// deck.gl can't diff a Set (no enumerable keys), so a click never
|
updateTriggers: { getFillColor: clickedUrls },
|
||||||
// re-runs getFillColor until data changes. Feed it a diffable array.
|
|
||||||
updateTriggers: { getFillColor: Array.from(clickedUrls) },
|
|
||||||
}),
|
}),
|
||||||
[expandedListings, clickedUrls, stableExpandedHover, stableExpandedClick]
|
[expandedListings, clickedUrls, stableExpandedHover, stableExpandedClick]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,7 @@ async function detectSessionRejection(
|
||||||
onSessionRejected?: () => void
|
onSessionRejected?: () => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (res.status !== 400 || !onSessionRejected || !pb.authStore.isValid) return;
|
if (res.status !== 400 || !onSessionRejected || !pb.authStore.isValid) return;
|
||||||
const body = await res
|
const body = await res.clone().json().catch(() => null);
|
||||||
.clone()
|
|
||||||
.json()
|
|
||||||
.catch(() => null);
|
|
||||||
if (body && (body as { error?: string }).error === 'filter_limit') {
|
if (body && (body as { error?: string }).error === 'filter_limit') {
|
||||||
onSessionRejected();
|
onSessionRejected();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
|
||||||
import pb from '../lib/pocketbase';
|
import pb from '../lib/pocketbase';
|
||||||
import { apiUrl, authHeaders } from '../lib/api';
|
import { apiUrl, authHeaders } from '../lib/api';
|
||||||
import { trackEvent } from '../lib/analytics';
|
import { trackEvent } from '../lib/analytics';
|
||||||
import { stripSelectedPostcodeParam } from '../lib/url-state';
|
|
||||||
|
|
||||||
export interface SavedSearch {
|
export interface SavedSearch {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -142,11 +141,7 @@ export function useSavedSearches(userId: string | null) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// A saved search stores filter criteria, so drop the transient
|
const params = paramsOverride ?? window.location.search.replace(/^\?/, '');
|
||||||
// selected-postcode param (a search/map-click leaves it in the URL).
|
|
||||||
const params = stripSelectedPostcodeParam(
|
|
||||||
paramsOverride ?? window.location.search.replace(/^\?/, '')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create record immediately without screenshot
|
// Create record immediately without screenshot
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
@ -225,14 +220,11 @@ export function useSavedSearches(userId: string | null) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateSearchParams = useCallback(
|
const updateSearchParams = useCallback(
|
||||||
async (id: string, rawParams: string) => {
|
async (id: string, params: string) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Match saveSearch: a saved search never carries the transient
|
|
||||||
// selected-postcode param.
|
|
||||||
const params = stripSelectedPostcodeParam(rawParams);
|
|
||||||
const record = await pb.collection('saved_searches').update(id, { params });
|
const record = await pb.collection('saved_searches').update(id, { params });
|
||||||
trackEvent('Search Update');
|
trackEvent('Search Update');
|
||||||
setSearches((prev) =>
|
setSearches((prev) =>
|
||||||
|
|
|
||||||
|
|
@ -97,8 +97,7 @@ const descriptions: Record<string, Record<string, string>> = {
|
||||||
'% Private rent': 'Part des ménages locataires dans le privé ou logés à titre gratuit',
|
'% Private rent': 'Part des ménages locataires dans le privé ou logés à titre gratuit',
|
||||||
'% Council housing':
|
'% Council housing':
|
||||||
'Part des logements du code postal déjà enregistrés comme logement municipal ou social',
|
'Part des logements du code postal déjà enregistrés comme logement municipal ou social',
|
||||||
'% Ex-council':
|
'% Ex-council': 'Part des logements du code postal autrefois municipaux mais qui ne le sont plus',
|
||||||
'Part des logements du code postal autrefois municipaux mais qui ne le sont plus',
|
|
||||||
'% White': 'Part de la population s’identifiant comme blanche',
|
'% White': 'Part de la population s’identifiant comme blanche',
|
||||||
'% South Asian': 'Part de la population s’identifiant comme sud-asiatique',
|
'% South Asian': 'Part de la population s’identifiant comme sud-asiatique',
|
||||||
'% Black': 'Part de la population s’identifiant comme noire',
|
'% Black': 'Part de la population s’identifiant comme noire',
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ const de: Translations = {
|
||||||
minute: 'Min.',
|
minute: 'Min.',
|
||||||
or: 'oder',
|
or: 'oder',
|
||||||
area: 'Gebiet',
|
area: 'Gebiet',
|
||||||
properties: 'Verkaufte Immobilien',
|
properties: 'Immobilien',
|
||||||
postcode: 'Postcode',
|
postcode: 'Postcode',
|
||||||
noAreaSelected: 'Kein Gebiet ausgewählt',
|
noAreaSelected: 'Kein Gebiet ausgewählt',
|
||||||
noAreaSelectedDesc:
|
noAreaSelectedDesc:
|
||||||
|
|
@ -631,8 +631,6 @@ const de: Translations = {
|
||||||
email: 'E-Mail',
|
email: 'E-Mail',
|
||||||
emailPlaceholder: 'name@beispiel.de',
|
emailPlaceholder: 'name@beispiel.de',
|
||||||
password: 'Passwort',
|
password: 'Passwort',
|
||||||
showPassword: 'Passwort anzeigen',
|
|
||||||
hidePassword: 'Passwort verbergen',
|
|
||||||
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
|
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
|
||||||
passwordPlaceholderLogin: 'Dein Passwort',
|
passwordPlaceholderLogin: 'Dein Passwort',
|
||||||
forgotPassword: 'Passwort vergessen?',
|
forgotPassword: 'Passwort vergessen?',
|
||||||
|
|
@ -646,16 +644,6 @@ const de: Translations = {
|
||||||
registrationFailed: 'Registrierung fehlgeschlagen',
|
registrationFailed: 'Registrierung fehlgeschlagen',
|
||||||
oauthFailed: 'OAuth-Anmeldung fehlgeschlagen',
|
oauthFailed: 'OAuth-Anmeldung fehlgeschlagen',
|
||||||
passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts fehlgeschlagen',
|
passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts fehlgeschlagen',
|
||||||
newPassword: 'Neues Passwort',
|
|
||||||
newPasswordPlaceholder: 'Mindestens 8 Zeichen',
|
|
||||||
updatePassword: 'Passwort aktualisieren',
|
|
||||||
resetSuccessBody:
|
|
||||||
'Dein Passwort wurde geändert. Du kannst dich jetzt mit deinem neuen Passwort anmelden.',
|
|
||||||
resetMissingToken:
|
|
||||||
'Dieser Link zum Zurücksetzen ist unvollständig. Fordere über die Anmeldeseite einen neuen an.',
|
|
||||||
resetInvalidLink:
|
|
||||||
'Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Fordere über die Anmeldeseite einen neuen an.',
|
|
||||||
goToLogin: 'Zur Anmeldung',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
|
|
@ -963,10 +951,6 @@ const de: Translations = {
|
||||||
viewProperties: '{{count}} Immobilien ansehen',
|
viewProperties: '{{count}} Immobilien ansehen',
|
||||||
viewPropertiesShort: 'Immobilien ansehen',
|
viewPropertiesShort: 'Immobilien ansehen',
|
||||||
priceHistory: 'Preisentwicklung',
|
priceHistory: 'Preisentwicklung',
|
||||||
priceHistoryThisArea: 'Dieses Gebiet',
|
|
||||||
priceMetric: 'Preisbasis',
|
|
||||||
priceMetricTotal: 'Gesamt',
|
|
||||||
priceMetricPerSqm: 'Pro m²',
|
|
||||||
journeysFrom: 'Fahrzeiten für {{label}}',
|
journeysFrom: 'Fahrzeiten für {{label}}',
|
||||||
to: 'Nach {{destination}}',
|
to: 'Nach {{destination}}',
|
||||||
noJourneyData: 'Keine Verbindungsdaten verfügbar',
|
noJourneyData: 'Keine Verbindungsdaten verfügbar',
|
||||||
|
|
@ -1592,10 +1576,6 @@ const de: Translations = {
|
||||||
listing: 'Inserat',
|
listing: 'Inserat',
|
||||||
showingOf: '{{visible}} von {{count}} werden angezeigt',
|
showingOf: '{{visible}} von {{count}} werden angezeigt',
|
||||||
groupedNear: 'Gruppiert an dieser Kartenposition',
|
groupedNear: 'Gruppiert an dieser Kartenposition',
|
||||||
priceHistory: 'Preisverlauf',
|
|
||||||
priceListed: 'Gelistet',
|
|
||||||
priceReduced: 'Reduziert',
|
|
||||||
priceIncreased: 'Erhöht',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
overlays: {
|
overlays: {
|
||||||
|
|
@ -1916,7 +1896,6 @@ const de: Translations = {
|
||||||
'Tube station': 'U-Bahn-Station',
|
'Tube station': 'U-Bahn-Station',
|
||||||
'DLR station': 'DLR-Station',
|
'DLR station': 'DLR-Station',
|
||||||
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
|
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
|
||||||
'Any station': 'Beliebige Station',
|
|
||||||
Café: 'Café',
|
Café: 'Café',
|
||||||
Restaurant: 'Restaurant',
|
Restaurant: 'Restaurant',
|
||||||
Pub: 'Pub',
|
Pub: 'Pub',
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ const en = {
|
||||||
minute: 'min',
|
minute: 'min',
|
||||||
or: 'or',
|
or: 'or',
|
||||||
area: 'Area',
|
area: 'Area',
|
||||||
properties: 'Sold properties',
|
properties: 'Properties',
|
||||||
postcode: 'Postcode',
|
postcode: 'Postcode',
|
||||||
noAreaSelected: 'No area selected',
|
noAreaSelected: 'No area selected',
|
||||||
noAreaSelectedDesc:
|
noAreaSelectedDesc:
|
||||||
|
|
@ -618,8 +618,6 @@ const en = {
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
emailPlaceholder: 'you@example.com',
|
emailPlaceholder: 'you@example.com',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
showPassword: 'Show password',
|
|
||||||
hidePassword: 'Hide password',
|
|
||||||
passwordPlaceholderRegister: 'Min 8 characters',
|
passwordPlaceholderRegister: 'Min 8 characters',
|
||||||
passwordPlaceholderLogin: 'Your password',
|
passwordPlaceholderLogin: 'Your password',
|
||||||
forgotPassword: 'Forgot password?',
|
forgotPassword: 'Forgot password?',
|
||||||
|
|
@ -633,15 +631,6 @@ const en = {
|
||||||
registrationFailed: 'Registration failed',
|
registrationFailed: 'Registration failed',
|
||||||
oauthFailed: 'OAuth login failed',
|
oauthFailed: 'OAuth login failed',
|
||||||
passwordResetFailed: 'Password reset request failed',
|
passwordResetFailed: 'Password reset request failed',
|
||||||
newPassword: 'New password',
|
|
||||||
newPasswordPlaceholder: 'At least 8 characters',
|
|
||||||
updatePassword: 'Update password',
|
|
||||||
resetSuccessBody: 'Your password has been changed. You can now log in with your new password.',
|
|
||||||
resetMissingToken:
|
|
||||||
'This password reset link is incomplete. Request a new one from the log in screen.',
|
|
||||||
resetInvalidLink:
|
|
||||||
'This reset link is invalid or has expired. Request a new one from the log in screen.',
|
|
||||||
goToLogin: 'Go to log in',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
|
|
@ -950,10 +939,6 @@ const en = {
|
||||||
viewProperties: 'View {{count}} properties',
|
viewProperties: 'View {{count}} properties',
|
||||||
viewPropertiesShort: 'View properties',
|
viewPropertiesShort: 'View properties',
|
||||||
priceHistory: 'Price History',
|
priceHistory: 'Price History',
|
||||||
priceHistoryThisArea: 'This area',
|
|
||||||
priceMetric: 'Price basis',
|
|
||||||
priceMetricTotal: 'Total',
|
|
||||||
priceMetricPerSqm: 'Per m²',
|
|
||||||
journeysFrom: 'Journey times for {{label}}',
|
journeysFrom: 'Journey times for {{label}}',
|
||||||
to: 'To {{destination}}',
|
to: 'To {{destination}}',
|
||||||
noJourneyData: 'No journey data available',
|
noJourneyData: 'No journey data available',
|
||||||
|
|
@ -1571,10 +1556,6 @@ const en = {
|
||||||
listing: 'Listing',
|
listing: 'Listing',
|
||||||
showingOf: 'Showing {{visible}} of {{count}}',
|
showingOf: 'Showing {{visible}} of {{count}}',
|
||||||
groupedNear: 'Grouped near this map position',
|
groupedNear: 'Grouped near this map position',
|
||||||
priceHistory: 'Price history',
|
|
||||||
priceListed: 'Listed',
|
|
||||||
priceReduced: 'Reduced',
|
|
||||||
priceIncreased: 'Increased',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Overlays Pane ──────────────────────────────
|
// ── Map Overlays Pane ──────────────────────────────
|
||||||
|
|
@ -1897,7 +1878,6 @@ const en = {
|
||||||
'Tube station': 'Tube station',
|
'Tube station': 'Tube station',
|
||||||
'DLR station': 'DLR station',
|
'DLR station': 'DLR station',
|
||||||
'Tram & Metro stop': 'Tram & Metro stop',
|
'Tram & Metro stop': 'Tram & Metro stop',
|
||||||
'Any station': 'Any station',
|
|
||||||
Café: 'Café',
|
Café: 'Café',
|
||||||
Restaurant: 'Restaurant',
|
Restaurant: 'Restaurant',
|
||||||
Pub: 'Pub',
|
Pub: 'Pub',
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ const fr: Translations = {
|
||||||
minute: 'min',
|
minute: 'min',
|
||||||
or: 'ou',
|
or: 'ou',
|
||||||
area: 'Secteur',
|
area: 'Secteur',
|
||||||
properties: 'Biens vendus',
|
properties: 'Biens',
|
||||||
postcode: 'Code postal',
|
postcode: 'Code postal',
|
||||||
noAreaSelected: 'Aucun secteur sélectionné',
|
noAreaSelected: 'Aucun secteur sélectionné',
|
||||||
noAreaSelectedDesc:
|
noAreaSelectedDesc:
|
||||||
|
|
@ -642,8 +642,6 @@ const fr: Translations = {
|
||||||
email: 'E-mail',
|
email: 'E-mail',
|
||||||
emailPlaceholder: 'vous@exemple.com',
|
emailPlaceholder: 'vous@exemple.com',
|
||||||
password: 'Mot de passe',
|
password: 'Mot de passe',
|
||||||
showPassword: 'Afficher le mot de passe',
|
|
||||||
hidePassword: 'Masquer le mot de passe',
|
|
||||||
passwordPlaceholderRegister: '8 caractères minimum',
|
passwordPlaceholderRegister: '8 caractères minimum',
|
||||||
passwordPlaceholderLogin: 'Votre mot de passe',
|
passwordPlaceholderLogin: 'Votre mot de passe',
|
||||||
forgotPassword: 'Mot de passe oublié ?',
|
forgotPassword: 'Mot de passe oublié ?',
|
||||||
|
|
@ -657,16 +655,6 @@ const fr: Translations = {
|
||||||
registrationFailed: 'Échec de l’inscription',
|
registrationFailed: 'Échec de l’inscription',
|
||||||
oauthFailed: 'Échec de la connexion OAuth',
|
oauthFailed: 'Échec de la connexion OAuth',
|
||||||
passwordResetFailed: 'Échec de la demande de réinitialisation du mot de passe',
|
passwordResetFailed: 'Échec de la demande de réinitialisation du mot de passe',
|
||||||
newPassword: 'Nouveau mot de passe',
|
|
||||||
newPasswordPlaceholder: 'Au moins 8 caractères',
|
|
||||||
updatePassword: 'Mettre à jour le mot de passe',
|
|
||||||
resetSuccessBody:
|
|
||||||
'Votre mot de passe a été modifié. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.',
|
|
||||||
resetMissingToken:
|
|
||||||
'Ce lien de réinitialisation est incomplet. Demandez-en un nouveau depuis l’écran de connexion.',
|
|
||||||
resetInvalidLink:
|
|
||||||
'Ce lien de réinitialisation est invalide ou a expiré. Demandez-en un nouveau depuis l’écran de connexion.',
|
|
||||||
goToLogin: 'Aller à la connexion',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
|
|
@ -973,10 +961,6 @@ const fr: Translations = {
|
||||||
viewProperties: 'Voir {{count}} biens',
|
viewProperties: 'Voir {{count}} biens',
|
||||||
viewPropertiesShort: 'Voir les biens',
|
viewPropertiesShort: 'Voir les biens',
|
||||||
priceHistory: 'Historique des prix',
|
priceHistory: 'Historique des prix',
|
||||||
priceHistoryThisArea: 'Cette zone',
|
|
||||||
priceMetric: 'Base de prix',
|
|
||||||
priceMetricTotal: 'Total',
|
|
||||||
priceMetricPerSqm: 'Au m²',
|
|
||||||
journeysFrom: 'Temps de trajet pour {{label}}',
|
journeysFrom: 'Temps de trajet pour {{label}}',
|
||||||
to: 'Vers {{destination}}',
|
to: 'Vers {{destination}}',
|
||||||
noJourneyData: 'Aucune donnée de trajet disponible',
|
noJourneyData: 'Aucune donnée de trajet disponible',
|
||||||
|
|
@ -1609,10 +1593,6 @@ const fr: Translations = {
|
||||||
listing: 'Annonce',
|
listing: 'Annonce',
|
||||||
showingOf: 'Affichage de {{visible}} sur {{count}}',
|
showingOf: 'Affichage de {{visible}} sur {{count}}',
|
||||||
groupedNear: 'Regroupées près de cette position sur la carte',
|
groupedNear: 'Regroupées près de cette position sur la carte',
|
||||||
priceHistory: 'Historique des prix',
|
|
||||||
priceListed: 'Mis en vente',
|
|
||||||
priceReduced: 'Réduit',
|
|
||||||
priceIncreased: 'Augmenté',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Overlays ───────────────────────────────────────
|
// ── Overlays ───────────────────────────────────────
|
||||||
|
|
@ -1936,7 +1916,6 @@ const fr: Translations = {
|
||||||
'Tube station': 'Station de métro londonien',
|
'Tube station': 'Station de métro londonien',
|
||||||
'DLR station': 'Station DLR',
|
'DLR station': 'Station DLR',
|
||||||
'Tram & Metro stop': 'Arrêt de tramway et métro',
|
'Tram & Metro stop': 'Arrêt de tramway et métro',
|
||||||
'Any station': 'Toute station',
|
|
||||||
Café: 'Café',
|
Café: 'Café',
|
||||||
Restaurant: 'Restaurant',
|
Restaurant: 'Restaurant',
|
||||||
Pub: 'Pub',
|
Pub: 'Pub',
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ const hi: Translations = {
|
||||||
minute: 'मिनट',
|
minute: 'मिनट',
|
||||||
or: 'या',
|
or: 'या',
|
||||||
area: 'क्षेत्र',
|
area: 'क्षेत्र',
|
||||||
properties: 'बिकी हुई प्रॉपर्टीज़',
|
properties: 'प्रॉपर्टीज़',
|
||||||
postcode: 'पोस्टकोड',
|
postcode: 'पोस्टकोड',
|
||||||
noAreaSelected: 'कोई क्षेत्र चुना नहीं गया',
|
noAreaSelected: 'कोई क्षेत्र चुना नहीं गया',
|
||||||
noAreaSelectedDesc:
|
noAreaSelectedDesc:
|
||||||
|
|
@ -615,8 +615,6 @@ const hi: Translations = {
|
||||||
email: 'ईमेल',
|
email: 'ईमेल',
|
||||||
emailPlaceholder: 'you@example.com',
|
emailPlaceholder: 'you@example.com',
|
||||||
password: 'पासवर्ड',
|
password: 'पासवर्ड',
|
||||||
showPassword: 'पासवर्ड दिखाएं',
|
|
||||||
hidePassword: 'पासवर्ड छिपाएं',
|
|
||||||
passwordPlaceholderRegister: 'कम से कम 8 अक्षर',
|
passwordPlaceholderRegister: 'कम से कम 8 अक्षर',
|
||||||
passwordPlaceholderLogin: 'आपका पासवर्ड',
|
passwordPlaceholderLogin: 'आपका पासवर्ड',
|
||||||
forgotPassword: 'पासवर्ड भूल गए?',
|
forgotPassword: 'पासवर्ड भूल गए?',
|
||||||
|
|
@ -630,14 +628,6 @@ const hi: Translations = {
|
||||||
registrationFailed: 'पंजीकरण विफल रहा',
|
registrationFailed: 'पंजीकरण विफल रहा',
|
||||||
oauthFailed: 'OAuth लॉग इन विफल रहा',
|
oauthFailed: 'OAuth लॉग इन विफल रहा',
|
||||||
passwordResetFailed: 'पासवर्ड रीसेट अनुरोध विफल रहा',
|
passwordResetFailed: 'पासवर्ड रीसेट अनुरोध विफल रहा',
|
||||||
newPassword: 'नया पासवर्ड',
|
|
||||||
newPasswordPlaceholder: 'कम से कम 8 अक्षर',
|
|
||||||
updatePassword: 'पासवर्ड अपडेट करें',
|
|
||||||
resetSuccessBody: 'आपका पासवर्ड बदल दिया गया है। अब आप अपने नए पासवर्ड से लॉग इन कर सकते हैं।',
|
|
||||||
resetMissingToken: 'यह रीसेट लिंक अधूरा है। लॉग इन स्क्रीन से नया लिंक प्राप्त करें।',
|
|
||||||
resetInvalidLink:
|
|
||||||
'यह रीसेट लिंक अमान्य है या समाप्त हो चुका है। लॉग इन स्क्रीन से नया लिंक प्राप्त करें।',
|
|
||||||
goToLogin: 'लॉग इन पर जाएँ',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
upgrade: {
|
upgrade: {
|
||||||
|
|
@ -929,10 +919,6 @@ const hi: Translations = {
|
||||||
viewProperties: '{{count}} संपत्तियां देखें',
|
viewProperties: '{{count}} संपत्तियां देखें',
|
||||||
viewPropertiesShort: 'संपत्तियां देखें',
|
viewPropertiesShort: 'संपत्तियां देखें',
|
||||||
priceHistory: 'कीमत इतिहास',
|
priceHistory: 'कीमत इतिहास',
|
||||||
priceHistoryThisArea: 'यह क्षेत्र',
|
|
||||||
priceMetric: 'मूल्य आधार',
|
|
||||||
priceMetricTotal: 'कुल',
|
|
||||||
priceMetricPerSqm: 'प्रति मी²',
|
|
||||||
journeysFrom: '{{label}} के लिए यात्रा समय',
|
journeysFrom: '{{label}} के लिए यात्रा समय',
|
||||||
to: '{{destination}} तक',
|
to: '{{destination}} तक',
|
||||||
noJourneyData: 'कोई यात्रा डेटा उपलब्ध नहीं',
|
noJourneyData: 'कोई यात्रा डेटा उपलब्ध नहीं',
|
||||||
|
|
@ -1525,10 +1511,6 @@ const hi: Translations = {
|
||||||
listing: 'लिस्टिंग',
|
listing: 'लिस्टिंग',
|
||||||
showingOf: '{{count}} में से {{visible}} दिखाई जा रही हैं',
|
showingOf: '{{count}} में से {{visible}} दिखाई जा रही हैं',
|
||||||
groupedNear: 'इस मैप स्थिति के पास समूहीकृत',
|
groupedNear: 'इस मैप स्थिति के पास समूहीकृत',
|
||||||
priceHistory: 'मूल्य इतिहास',
|
|
||||||
priceListed: 'सूचीबद्ध',
|
|
||||||
priceReduced: 'घटाया गया',
|
|
||||||
priceIncreased: 'बढ़ाया गया',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
overlays: {
|
overlays: {
|
||||||
|
|
@ -1818,7 +1800,6 @@ const hi: Translations = {
|
||||||
'Tube station': 'ट्यूब स्टेशन',
|
'Tube station': 'ट्यूब स्टेशन',
|
||||||
'DLR station': 'DLR स्टेशन',
|
'DLR station': 'DLR स्टेशन',
|
||||||
'Tram & Metro stop': 'ट्राम और मेट्रो स्टॉप',
|
'Tram & Metro stop': 'ट्राम और मेट्रो स्टॉप',
|
||||||
'Any station': 'कोई भी स्टेशन',
|
|
||||||
Café: 'कैफे',
|
Café: 'कैफे',
|
||||||
Restaurant: 'रेस्तरां',
|
Restaurant: 'रेस्तरां',
|
||||||
Pub: 'पब',
|
Pub: 'पब',
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ const hu: Translations = {
|
||||||
minute: 'perc',
|
minute: 'perc',
|
||||||
or: 'vagy',
|
or: 'vagy',
|
||||||
area: 'Terület',
|
area: 'Terület',
|
||||||
properties: 'Eladott ingatlanok',
|
properties: 'Ingatlanok',
|
||||||
postcode: 'Irányítószám',
|
postcode: 'Irányítószám',
|
||||||
noAreaSelected: 'Nincs kiválasztott terület',
|
noAreaSelected: 'Nincs kiválasztott terület',
|
||||||
noAreaSelectedDesc:
|
noAreaSelectedDesc:
|
||||||
|
|
@ -633,8 +633,6 @@ const hu: Translations = {
|
||||||
email: 'E-mail',
|
email: 'E-mail',
|
||||||
emailPlaceholder: 'te@pelda.hu',
|
emailPlaceholder: 'te@pelda.hu',
|
||||||
password: 'Jelszó',
|
password: 'Jelszó',
|
||||||
showPassword: 'Jelszó megjelenítése',
|
|
||||||
hidePassword: 'Jelszó elrejtése',
|
|
||||||
passwordPlaceholderRegister: 'Minimum 8 karakter',
|
passwordPlaceholderRegister: 'Minimum 8 karakter',
|
||||||
passwordPlaceholderLogin: 'Jelszavad',
|
passwordPlaceholderLogin: 'Jelszavad',
|
||||||
forgotPassword: 'Elfelejtetted a jelszavad?',
|
forgotPassword: 'Elfelejtetted a jelszavad?',
|
||||||
|
|
@ -648,15 +646,6 @@ const hu: Translations = {
|
||||||
registrationFailed: 'A regisztráció sikertelen',
|
registrationFailed: 'A regisztráció sikertelen',
|
||||||
oauthFailed: 'Az OAuth-bejelentkezés sikertelen',
|
oauthFailed: 'Az OAuth-bejelentkezés sikertelen',
|
||||||
passwordResetFailed: 'A jelszó-visszaállítási kérés sikertelen',
|
passwordResetFailed: 'A jelszó-visszaállítási kérés sikertelen',
|
||||||
newPassword: 'Új jelszó',
|
|
||||||
newPasswordPlaceholder: 'Legalább 8 karakter',
|
|
||||||
updatePassword: 'Jelszó frissítése',
|
|
||||||
resetSuccessBody: 'A jelszavad megváltozott. Mostantól bejelentkezhetsz az új jelszavaddal.',
|
|
||||||
resetMissingToken:
|
|
||||||
'Ez a jelszó-visszaállító link hiányos. Kérj egy újat a bejelentkezési képernyőről.',
|
|
||||||
resetInvalidLink:
|
|
||||||
'Ez a visszaállító link érvénytelen vagy lejárt. Kérj egy újat a bejelentkezési képernyőről.',
|
|
||||||
goToLogin: 'Tovább a bejelentkezéshez',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
|
|
@ -960,10 +949,6 @@ const hu: Translations = {
|
||||||
viewProperties: '{{count}} ingatlan megtekintése',
|
viewProperties: '{{count}} ingatlan megtekintése',
|
||||||
viewPropertiesShort: 'Ingatlanok megtekintése',
|
viewPropertiesShort: 'Ingatlanok megtekintése',
|
||||||
priceHistory: 'Ártörténet',
|
priceHistory: 'Ártörténet',
|
||||||
priceHistoryThisArea: 'Ez a terület',
|
|
||||||
priceMetric: 'Ár alapja',
|
|
||||||
priceMetricTotal: 'Teljes',
|
|
||||||
priceMetricPerSqm: 'm²-enként',
|
|
||||||
journeysFrom: 'Utazási idők ehhez: {{label}}',
|
journeysFrom: 'Utazási idők ehhez: {{label}}',
|
||||||
to: 'Ide: {{destination}}',
|
to: 'Ide: {{destination}}',
|
||||||
noJourneyData: 'Nincs elérhető utazási adat',
|
noJourneyData: 'Nincs elérhető utazási adat',
|
||||||
|
|
@ -1591,10 +1576,6 @@ const hu: Translations = {
|
||||||
listing: 'Hirdetés',
|
listing: 'Hirdetés',
|
||||||
showingOf: '{{visible}} / {{count}} megjelenítve',
|
showingOf: '{{visible}} / {{count}} megjelenítve',
|
||||||
groupedNear: 'Ennél a térképponton csoportosítva',
|
groupedNear: 'Ennél a térképponton csoportosítva',
|
||||||
priceHistory: 'Ártörténet',
|
|
||||||
priceListed: 'Meghirdetve',
|
|
||||||
priceReduced: 'Csökkentve',
|
|
||||||
priceIncreased: 'Növelve',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Overlays ───────────────────────────────────────
|
// ── Overlays ───────────────────────────────────────
|
||||||
|
|
@ -1917,7 +1898,6 @@ const hu: Translations = {
|
||||||
'Tube station': 'Londoni metróállomás',
|
'Tube station': 'Londoni metróállomás',
|
||||||
'DLR station': 'DLR-állomás',
|
'DLR station': 'DLR-állomás',
|
||||||
'Tram & Metro stop': 'Villamos- és metrómegálló',
|
'Tram & Metro stop': 'Villamos- és metrómegálló',
|
||||||
'Any station': 'Bármely állomás',
|
|
||||||
Café: 'Kávézó',
|
Café: 'Kávézó',
|
||||||
Restaurant: 'Étterem',
|
Restaurant: 'Étterem',
|
||||||
Pub: 'Kocsma',
|
Pub: 'Kocsma',
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ const zh: Translations = {
|
||||||
minute: '分钟',
|
minute: '分钟',
|
||||||
or: '或',
|
or: '或',
|
||||||
area: '区域',
|
area: '区域',
|
||||||
properties: '已售房产',
|
properties: '房产',
|
||||||
postcode: '邮编',
|
postcode: '邮编',
|
||||||
noAreaSelected: '未选择区域',
|
noAreaSelected: '未选择区域',
|
||||||
noAreaSelectedDesc: '点击地图上的任意彩色区域,查看治安、学校、房价等信息',
|
noAreaSelectedDesc: '点击地图上的任意彩色区域,查看治安、学校、房价等信息',
|
||||||
|
|
@ -579,8 +579,6 @@ const zh: Translations = {
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
emailPlaceholder: 'you@example.com',
|
emailPlaceholder: 'you@example.com',
|
||||||
password: '密码',
|
password: '密码',
|
||||||
showPassword: '显示密码',
|
|
||||||
hidePassword: '隐藏密码',
|
|
||||||
passwordPlaceholderRegister: '至少 8 个字符',
|
passwordPlaceholderRegister: '至少 8 个字符',
|
||||||
passwordPlaceholderLogin: '您的密码',
|
passwordPlaceholderLogin: '您的密码',
|
||||||
forgotPassword: '忘记密码?',
|
forgotPassword: '忘记密码?',
|
||||||
|
|
@ -594,13 +592,6 @@ const zh: Translations = {
|
||||||
registrationFailed: '注册失败',
|
registrationFailed: '注册失败',
|
||||||
oauthFailed: 'OAuth 登录失败',
|
oauthFailed: 'OAuth 登录失败',
|
||||||
passwordResetFailed: '密码重置请求失败',
|
passwordResetFailed: '密码重置请求失败',
|
||||||
newPassword: '新密码',
|
|
||||||
newPasswordPlaceholder: '至少 8 个字符',
|
|
||||||
updatePassword: '更新密码',
|
|
||||||
resetSuccessBody: '您的密码已更改。现在可以使用新密码登录。',
|
|
||||||
resetMissingToken: '此重置链接不完整。请从登录界面重新获取。',
|
|
||||||
resetInvalidLink: '此重置链接无效或已过期。请从登录界面重新获取。',
|
|
||||||
goToLogin: '前往登录',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
|
|
@ -896,10 +887,6 @@ const zh: Translations = {
|
||||||
viewProperties: '查看 {{count}} 套房产',
|
viewProperties: '查看 {{count}} 套房产',
|
||||||
viewPropertiesShort: '查看房产',
|
viewPropertiesShort: '查看房产',
|
||||||
priceHistory: '价格历史',
|
priceHistory: '价格历史',
|
||||||
priceHistoryThisArea: '此区域',
|
|
||||||
priceMetric: '价格基准',
|
|
||||||
priceMetricTotal: '总价',
|
|
||||||
priceMetricPerSqm: '每平方米',
|
|
||||||
journeysFrom: '{{label}} 的出行时间',
|
journeysFrom: '{{label}} 的出行时间',
|
||||||
to: '前往 {{destination}}',
|
to: '前往 {{destination}}',
|
||||||
noJourneyData: '暂无出行数据',
|
noJourneyData: '暂无出行数据',
|
||||||
|
|
@ -1505,10 +1492,6 @@ const zh: Translations = {
|
||||||
listing: '房源',
|
listing: '房源',
|
||||||
showingOf: '显示 {{count}} 套中的 {{visible}} 套',
|
showingOf: '显示 {{count}} 套中的 {{visible}} 套',
|
||||||
groupedNear: '按此地图位置聚合',
|
groupedNear: '按此地图位置聚合',
|
||||||
priceHistory: '价格记录',
|
|
||||||
priceListed: '上架',
|
|
||||||
priceReduced: '降价',
|
|
||||||
priceIncreased: '涨价',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Overlays ───────────────────────────────────────
|
// ── Overlays ───────────────────────────────────────
|
||||||
|
|
@ -1828,7 +1811,6 @@ const zh: Translations = {
|
||||||
'Tube station': '伦敦地铁站',
|
'Tube station': '伦敦地铁站',
|
||||||
'DLR station': 'DLR 轻轨站',
|
'DLR station': 'DLR 轻轨站',
|
||||||
'Tram & Metro stop': '有轨电车与城市轨道站',
|
'Tram & Metro stop': '有轨电车与城市轨道站',
|
||||||
'Any station': '任意车站',
|
|
||||||
Café: '咖啡馆',
|
Café: '咖啡馆',
|
||||||
Restaurant: '餐厅',
|
Restaurant: '餐厅',
|
||||||
Pub: '酒吧',
|
Pub: '酒吧',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { TFunction } from 'i18next';
|
||||||
*/
|
*/
|
||||||
export type AuthErrorAction = 'switchToLogin' | null;
|
export type AuthErrorAction = 'switchToLogin' | null;
|
||||||
|
|
||||||
export type AuthContext = 'login' | 'register' | 'oauth' | 'reset' | 'confirmReset';
|
export type AuthContext = 'login' | 'register' | 'oauth' | 'reset';
|
||||||
|
|
||||||
export interface ResolvedAuthError {
|
export interface ResolvedAuthError {
|
||||||
/** User-facing message (already resolved to a string). */
|
/** User-facing message (already resolved to a string). */
|
||||||
|
|
@ -75,17 +75,5 @@ export function resolveAuthError(
|
||||||
return { message: t('auth.oauthFailed'), action: null };
|
return { message: t('auth.oauthFailed'), action: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submitting a new password from the emailed reset link. A weak password comes
|
|
||||||
// back as a field error; anything else (bad/expired/used token) is a dead link.
|
|
||||||
if (context === 'confirmReset') {
|
|
||||||
if (fieldCode(e, 'password')) {
|
|
||||||
return { message: t('auth.errorPasswordWeak'), action: null };
|
|
||||||
}
|
|
||||||
if (status === 400 || status === 404) {
|
|
||||||
return { message: t('auth.resetInvalidLink'), action: null };
|
|
||||||
}
|
|
||||||
return { message: t('auth.passwordResetFailed'), action: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: t('auth.passwordResetFailed'), action: null };
|
return { message: t('auth.passwordResetFailed'), action: null };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { ViewState } from '../types';
|
import type { ViewState } from '../types';
|
||||||
import type { TravelTimeInitial } from '../hooks/useTravelTime';
|
|
||||||
|
|
||||||
export const INITIAL_RETRY_MS = 1000;
|
export const INITIAL_RETRY_MS = 1000;
|
||||||
export const MAX_RETRY_MS = 10000;
|
export const MAX_RETRY_MS = 10000;
|
||||||
|
|
@ -46,32 +45,14 @@ export function filterCapFor(isLoggedIn: boolean, filtersUnlimited: boolean): nu
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Funnel fix (growth): a cold map open lands empty, so first-time visitors never feel the value
|
/** Funnel fix (growth): a cold map open lands empty, so first-time visitors never feel the value
|
||||||
* or the 3-filter cap. On a cold open (no filters AND no travel time in the URL) we pre-seed the
|
* or the 3-filter cap. These two high-intent filters (value for money + good secondary schools)
|
||||||
* price filter plus a public-transport commute card, so the map is immediately useful and framed
|
* are pre-seeded when the map opens with no filters in the URL, so the map is immediately useful
|
||||||
* around the two highest-intent decisions: budget and commute. Deep links (OG screenshots, the
|
* and one more filter hits the cap. Deep links (OG screenshots, the SEO landing-page CTAs) carry
|
||||||
* SEO landing-page CTAs) carry their own filters/travel and are left untouched. Unknown feature
|
* their own filters and are left untouched. Unknown feature names are dropped safely by useFilters.
|
||||||
* names are dropped safely by useFilters. Tune or empty these to change/disable the behaviour. */
|
* Tune or empty this object to change/disable the behaviour. */
|
||||||
export const DEFAULT_DEMO_FILTERS: Record<string, [number, number]> = {
|
export const DEFAULT_DEMO_FILTERS: Record<string, [number, number]> = {
|
||||||
'Estimated current price': [0, 600000],
|
// 'Est. price per sqm': [0, 7000],
|
||||||
};
|
// 'Good+ secondary school catchments': [1, 11],
|
||||||
|
|
||||||
/** The public-transport commute half of the cold-open defaults. A single transit entry with no
|
|
||||||
* destination selected: it renders the "Public Transport" card (prompting the visitor to pick a
|
|
||||||
* destination) without filtering the map or touching the URL until they choose one. Mirrors what
|
|
||||||
* clicking "add public transport" produces, so it flows through useTravelTime unchanged. */
|
|
||||||
export const DEFAULT_DEMO_TRAVEL: TravelTimeInitial = {
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
mode: 'transit',
|
|
||||||
slug: '',
|
|
||||||
label: '',
|
|
||||||
timeRange: null,
|
|
||||||
useBest: false,
|
|
||||||
noChange: false,
|
|
||||||
oneChange: false,
|
|
||||||
noBuses: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ export const POI_DISTANCE_FILTER_KEY_PREFIX = `${POI_DISTANCE_FILTER_NAME}:`;
|
||||||
|
|
||||||
const TRANSPORT_POI_CATEGORIES = new Set([
|
const TRANSPORT_POI_CATEGORIES = new Set([
|
||||||
'Airport',
|
'Airport',
|
||||||
'Any station',
|
|
||||||
'Bus station',
|
'Bus station',
|
||||||
'Bus stop',
|
'Bus stop',
|
||||||
'DLR station',
|
'DLR station',
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
/**
|
|
||||||
* Postcode-component helpers, mirroring the server's `postcode_outcode` /
|
|
||||||
* `postcode_sector` (server-rs/src/utils.rs) so labels match the aggregations.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Outward code of a postcode: the part before the space. "E14 2DG" -> "E14". */
|
|
||||||
export function postcodeOutcode(postcode: string): string | null {
|
|
||||||
const trimmed = postcode.trim().toUpperCase();
|
|
||||||
const space = trimmed.indexOf(' ');
|
|
||||||
return space > 0 ? trimmed.slice(0, space) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Postcode sector: the outward code, the space, and the first character of the
|
|
||||||
* inward code. "E14 2DG" -> "E14 2".
|
|
||||||
*/
|
|
||||||
export function postcodeSector(postcode: string): string | null {
|
|
||||||
const trimmed = postcode.trim().toUpperCase();
|
|
||||||
const space = trimmed.indexOf(' ');
|
|
||||||
if (space <= 0 || space + 1 >= trimmed.length) return null;
|
|
||||||
return trimmed.slice(0, space + 2);
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { beforeEach, describe, expect, it } from 'vitest';
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { FeatureMeta } from '../types';
|
import type { FeatureMeta } from '../types';
|
||||||
import { parseUrlState, stateToParams, stripSelectedPostcodeParam } from './url-state';
|
import { parseUrlState, stateToParams } from './url-state';
|
||||||
import { DEFAULT_OVERLAY_IDS } from './overlays';
|
import { DEFAULT_OVERLAY_IDS } from './overlays';
|
||||||
import { INITIAL_VIEW_STATE } from './consts';
|
import { INITIAL_VIEW_STATE } from './consts';
|
||||||
import { createSchoolFilterKey } from './school-filter';
|
import { createSchoolFilterKey } from './school-filter';
|
||||||
|
|
@ -60,24 +60,6 @@ describe('url-state', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips the selected-postcode param for saved searches, keeping filters intact', () => {
|
|
||||||
const params =
|
|
||||||
'lat=51.5074&lon=-0.1278&zoom=12.5&filter=Last%20known%20price:100000:500000&pc=SW1A%201AA&poi=supermarket';
|
|
||||||
|
|
||||||
const stripped = stripSelectedPostcodeParam(params);
|
|
||||||
const result = new URLSearchParams(stripped);
|
|
||||||
|
|
||||||
expect(result.has('pc')).toBe(false);
|
|
||||||
expect(result.get('filter')).toBe('Last known price:100000:500000');
|
|
||||||
expect(result.get('poi')).toBe('supermarket');
|
|
||||||
expect(result.get('lat')).toBe('51.5074');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the query string unchanged when no selected-postcode param is present', () => {
|
|
||||||
const params = 'filter=Last%20known%20price:100000:500000&poi=supermarket';
|
|
||||||
expect(stripSelectedPostcodeParam(params)).toBe(params);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves POIs unselected when URL params are omitted', () => {
|
it('leaves POIs unselected when URL params are omitted', () => {
|
||||||
const state = parseUrlState();
|
const state = parseUrlState();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,23 +92,6 @@ const CRIME_TYPES_NONE_PARAM = '__none';
|
||||||
const OVERLAY_NONE_PARAM = '__none';
|
const OVERLAY_NONE_PARAM = '__none';
|
||||||
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
|
||||||
|
|
||||||
/** URL param holding the currently focused postcode (from a search or a map
|
|
||||||
* click). It reflects a transient selection, not search criteria, so a live or
|
|
||||||
* shared link keeps it but a saved search must not bake it in. */
|
|
||||||
export const SELECTED_POSTCODE_PARAM = 'pc';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop the transient selected-postcode param from a serialized query string so a
|
|
||||||
* saved search captures the filter criteria, not whichever postcode the user
|
|
||||||
* last clicked. Takes and returns a query string without the leading '?'.
|
|
||||||
*/
|
|
||||||
export function stripSelectedPostcodeParam(params: string): string {
|
|
||||||
const parsed = new URLSearchParams(params);
|
|
||||||
if (!parsed.has(SELECTED_POSTCODE_PARAM)) return params;
|
|
||||||
parsed.delete(SELECTED_POSTCODE_PARAM);
|
|
||||||
return parsed.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UrlState {
|
export interface UrlState {
|
||||||
viewState: ViewState;
|
viewState: ViewState;
|
||||||
/** True only when the URL carried explicit lat/lon/zoom (shared/dashboard link).
|
/** True only when the URL carried explicit lat/lon/zoom (shared/dashboard link).
|
||||||
|
|
@ -423,7 +406,7 @@ export function parseUrlState(): UrlState {
|
||||||
|
|
||||||
// Selected postcode. This is also accepted as the historical one-time
|
// Selected postcode. This is also accepted as the historical one-time
|
||||||
// navigate-to-postcode param used by saved-property links.
|
// navigate-to-postcode param used by saved-property links.
|
||||||
const pc = params.get(SELECTED_POSTCODE_PARAM);
|
const pc = params.get('pc');
|
||||||
if (pc) {
|
if (pc) {
|
||||||
result.postcode = pc;
|
result.postcode = pc;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,15 +158,6 @@ export interface ActualListing {
|
||||||
listing_status?: string;
|
listing_status?: string;
|
||||||
listing_date_iso?: string;
|
listing_date_iso?: string;
|
||||||
features: string[];
|
features: string[];
|
||||||
price_history?: PriceHistoryPoint[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One observed point on a listing's asking-price timeline, accrued across
|
|
||||||
* scrapes. `reason` is 'listed' | 'reduced' | 'increased'; `date` is YYYY-MM-DD. */
|
|
||||||
export interface PriceHistoryPoint {
|
|
||||||
date: string;
|
|
||||||
price: number;
|
|
||||||
reason: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActualListingsResponse {
|
export interface ActualListingsResponse {
|
||||||
|
|
@ -312,14 +303,8 @@ export interface EnumFeatureStats {
|
||||||
export interface PricePoint {
|
export interface PricePoint {
|
||||||
year: number;
|
year: number;
|
||||||
price: number;
|
price: number;
|
||||||
/** Sale price per square metre (sale price / EPC floor area). Absent where no
|
|
||||||
* floor area is recorded; the per-m² view drops those points. */
|
|
||||||
price_per_sqm?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Which value a price-history chart plots. */
|
|
||||||
export type PriceMetric = 'price' | 'price_per_sqm';
|
|
||||||
|
|
||||||
export interface CrimeYearPoint {
|
export interface CrimeYearPoint {
|
||||||
year: number;
|
year: number;
|
||||||
count: number;
|
count: number;
|
||||||
|
|
@ -384,12 +369,6 @@ export interface HexagonStatsResponse {
|
||||||
numeric_features: NumericFeatureStats[];
|
numeric_features: NumericFeatureStats[];
|
||||||
enum_features: EnumFeatureStats[];
|
enum_features: EnumFeatureStats[];
|
||||||
price_history?: PricePoint[];
|
price_history?: PricePoint[];
|
||||||
/** Price history for every sale in the selection's postcode sector (e.g.
|
|
||||||
* "E14 2"), filter-independent: wider-area context for the selection's chart. */
|
|
||||||
sector_price_history?: PricePoint[];
|
|
||||||
/** Price history for every sale in the selection's outward code (e.g. "E14"),
|
|
||||||
* filter-independent. */
|
|
||||||
outcode_price_history?: PricePoint[];
|
|
||||||
/** Per-crime-type per-year counts averaged across the selection. */
|
/** Per-crime-type per-year counts averaged across the selection. */
|
||||||
crime_by_year?: CrimeYearStats[];
|
crime_by_year?: CrimeYearStats[];
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import json
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
@ -36,11 +35,6 @@ ARCHIVE_LINK_RE = re.compile(
|
||||||
)
|
)
|
||||||
VALID_MD5_RE = re.compile(r"^[0-9a-fA-F]{32}$")
|
VALID_MD5_RE = re.compile(r"^[0-9a-fA-F]{32}$")
|
||||||
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
||||||
# Each archive is up to ~1.6 GB, so a transient failure (network blip, or the
|
|
||||||
# work dir being removed underneath us by a concurrent run) must not abort the
|
|
||||||
# whole multi-archive download. Retry a few times with a short backoff.
|
|
||||||
DOWNLOAD_ATTEMPTS = 4
|
|
||||||
RETRY_BACKOFF_SECONDS = 5
|
|
||||||
STREET_CRIME_CSV_RE = re.compile(r"^\d{4}-\d{2}-.+-street\.csv$")
|
STREET_CRIME_CSV_RE = re.compile(r"^\d{4}-\d{2}-.+-street\.csv$")
|
||||||
CONTAINED_RANGE_RE = re.compile(
|
CONTAINED_RANGE_RE = re.compile(
|
||||||
r"Contains data from (?P<start_month>[A-Za-z]+) (?P<start_year>\d{4}) "
|
r"Contains data from (?P<start_month>[A-Za-z]+) (?P<start_year>\d{4}) "
|
||||||
|
|
@ -271,10 +265,6 @@ def file_md5(path: Path) -> str:
|
||||||
return digest.hexdigest()
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
class _RetryableDownloadError(Exception):
|
|
||||||
"""A download attempt failed in a way that is worth retrying."""
|
|
||||||
|
|
||||||
|
|
||||||
def download_archive(
|
def download_archive(
|
||||||
archive: CrimeArchive,
|
archive: CrimeArchive,
|
||||||
archive_dir: Path,
|
archive_dir: Path,
|
||||||
|
|
@ -283,12 +273,7 @@ def download_archive(
|
||||||
force: bool,
|
force: bool,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Download one archive ZIP, resuming an existing .part file when possible.
|
"""Download one archive ZIP, resuming an existing .part file when possible."""
|
||||||
|
|
||||||
Transient failures (network errors, or the work dir being removed
|
|
||||||
underneath us by a concurrent run) are retried with a short backoff so a
|
|
||||||
single hiccup does not abort a whole multi-archive download.
|
|
||||||
"""
|
|
||||||
dest = archive_dir / archive.filename
|
dest = archive_dir / archive.filename
|
||||||
partial = dest.with_suffix(dest.suffix + ".part")
|
partial = dest.with_suffix(dest.suffix + ".part")
|
||||||
|
|
||||||
|
|
@ -312,43 +297,6 @@ def download_archive(
|
||||||
print(f"{archive.filename}: already downloaded")
|
print(f"{archive.filename}: already downloaded")
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
last_error: Exception | None = None
|
|
||||||
for attempt in range(1, DOWNLOAD_ATTEMPTS + 1):
|
|
||||||
# The work dir can be removed underneath us (e.g. a concurrent run's
|
|
||||||
# startup cleanup), which is exactly what makes the final rename fail
|
|
||||||
# with ENOENT even after the download reached 100%. Recreate it so the
|
|
||||||
# (re)download has somewhere to land; a surviving .part is resumed.
|
|
||||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
try:
|
|
||||||
_stream_to_partial(archive, partial, timeout=timeout)
|
|
||||||
partial.replace(dest)
|
|
||||||
if verify and archive.md5 is not None:
|
|
||||||
actual_md5 = file_md5(dest)
|
|
||||||
if actual_md5 != archive.md5:
|
|
||||||
dest.unlink(missing_ok=True)
|
|
||||||
raise _RetryableDownloadError(
|
|
||||||
f"{archive.filename}: MD5 mismatch: "
|
|
||||||
f"expected {archive.md5}, got {actual_md5}"
|
|
||||||
)
|
|
||||||
return dest
|
|
||||||
except (httpx.HTTPError, OSError, _RetryableDownloadError) as error:
|
|
||||||
last_error = error
|
|
||||||
if attempt < DOWNLOAD_ATTEMPTS:
|
|
||||||
delay = RETRY_BACKOFF_SECONDS * attempt
|
|
||||||
print(
|
|
||||||
f"{archive.filename}: download attempt {attempt} failed "
|
|
||||||
f"({error}); retrying in {delay}s",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{archive.filename}: download failed after {DOWNLOAD_ATTEMPTS} attempts"
|
|
||||||
) from last_error
|
|
||||||
|
|
||||||
|
|
||||||
def _stream_to_partial(archive: CrimeArchive, partial: Path, *, timeout: float) -> None:
|
|
||||||
"""Stream one archive into its .part file, resuming a valid prefix."""
|
|
||||||
resume_from = partial.stat().st_size if partial.exists() else 0
|
resume_from = partial.stat().st_size if partial.exists() else 0
|
||||||
headers = {"Range": f"bytes={resume_from}-"} if resume_from else {}
|
headers = {"Range": f"bytes={resume_from}-"} if resume_from else {}
|
||||||
|
|
||||||
|
|
@ -384,6 +332,18 @@ def _stream_to_partial(archive: CrimeArchive, partial: Path, *, timeout: float)
|
||||||
output.write(chunk)
|
output.write(chunk)
|
||||||
progress.update(len(chunk))
|
progress.update(len(chunk))
|
||||||
|
|
||||||
|
partial.replace(dest)
|
||||||
|
|
||||||
|
if verify and archive.md5 is not None:
|
||||||
|
actual_md5 = file_md5(dest)
|
||||||
|
if actual_md5 != archive.md5:
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{archive.filename}: MD5 mismatch: expected {archive.md5}, got {actual_md5}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
def _is_street_crime_csv(path: PurePosixPath | Path) -> bool:
|
def _is_street_crime_csv(path: PurePosixPath | Path) -> bool:
|
||||||
return STREET_CRIME_CSV_RE.fullmatch(path.name) is not None
|
return STREET_CRIME_CSV_RE.fullmatch(path.name) is not None
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
import hashlib
|
|
||||||
import shutil
|
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from pipeline.download import crime
|
|
||||||
from pipeline.download.crime import (
|
from pipeline.download.crime import (
|
||||||
CrimeArchive,
|
CrimeArchive,
|
||||||
download_archive,
|
|
||||||
extract_csvs,
|
extract_csvs,
|
||||||
prepare_archive_dir,
|
prepare_archive_dir,
|
||||||
prune_unused_csvs,
|
prune_unused_csvs,
|
||||||
|
|
@ -16,19 +10,6 @@ from pipeline.download.crime import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_archive(md5: str | None = None) -> CrimeArchive:
|
|
||||||
return CrimeArchive(
|
|
||||||
month="2023-05",
|
|
||||||
label="May 2023",
|
|
||||||
url="https://data.police.uk/data/archive/2023-05.zip",
|
|
||||||
filename="2023-05.zip",
|
|
||||||
size="1.6 GB",
|
|
||||||
contained_range="",
|
|
||||||
md5=md5,
|
|
||||||
raw_md5=md5 or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_archives_reads_monthly_zip_links_only():
|
def test_parse_archives_reads_monthly_zip_links_only():
|
||||||
html = """
|
html = """
|
||||||
<p><a href="/data/archive/latest.zip">latest.zip</a></p>
|
<p><a href="/data/archive/latest.zip">latest.zip</a></p>
|
||||||
|
|
@ -213,47 +194,3 @@ def test_prune_unused_csvs_removes_non_street_csvs(tmp_path):
|
||||||
assert street.exists()
|
assert street.exists()
|
||||||
assert not outcomes.exists()
|
assert not outcomes.exists()
|
||||||
assert not stop_search.exists()
|
assert not stop_search.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_download_archive_recovers_when_workdir_wiped_mid_download(
|
|
||||||
tmp_path, monkeypatch
|
|
||||||
):
|
|
||||||
# Reproduces the crash where a completed .part vanished before the rename
|
|
||||||
# (a concurrent run wiped _download_tmp), so partial.replace raised ENOENT
|
|
||||||
# even though the download had reached 100%.
|
|
||||||
monkeypatch.setattr(crime.time, "sleep", lambda *_: None)
|
|
||||||
archive_dir = tmp_path / "_download_tmp"
|
|
||||||
archive_dir.mkdir()
|
|
||||||
payload = b"real-zip-bytes" * 4096
|
|
||||||
archive = _make_archive(md5=hashlib.md5(payload).hexdigest())
|
|
||||||
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
def fake_stream(_archive, partial, *, timeout):
|
|
||||||
calls["n"] += 1
|
|
||||||
partial.write_bytes(payload)
|
|
||||||
if calls["n"] == 1:
|
|
||||||
# Simulate the concurrent cleanup: the rename target disappears.
|
|
||||||
shutil.rmtree(partial.parent)
|
|
||||||
|
|
||||||
monkeypatch.setattr(crime, "_stream_to_partial", fake_stream)
|
|
||||||
|
|
||||||
dest = download_archive(archive, archive_dir, verify=True, force=False, timeout=1.0)
|
|
||||||
|
|
||||||
assert calls["n"] == 2
|
|
||||||
assert dest.read_bytes() == payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_download_archive_gives_up_after_repeated_failures(tmp_path, monkeypatch):
|
|
||||||
monkeypatch.setattr(crime.time, "sleep", lambda *_: None)
|
|
||||||
archive_dir = tmp_path / "_download_tmp"
|
|
||||||
|
|
||||||
def always_fail(_archive, _partial, *, timeout):
|
|
||||||
raise OSError("connection reset")
|
|
||||||
|
|
||||||
monkeypatch.setattr(crime, "_stream_to_partial", always_fail)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match=f"after {crime.DOWNLOAD_ATTEMPTS} attempts"):
|
|
||||||
download_archive(
|
|
||||||
_make_archive(), archive_dir, verify=False, force=False, timeout=1.0
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,9 @@ from pathlib import Path
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pipeline.download.transit_network import (
|
from pipeline.download.transit_network import (
|
||||||
STATION_COORD_OVERRIDES,
|
|
||||||
_repair_stop_coordinate,
|
|
||||||
clean_national_rail_gtfs,
|
clean_national_rail_gtfs,
|
||||||
convert_high_freq_to_frequency_based,
|
convert_high_freq_to_frequency_based,
|
||||||
validate_gtfs_feed,
|
validate_gtfs_feed,
|
||||||
validate_london_coverage,
|
|
||||||
validate_stop_geometry,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -235,7 +231,9 @@ def test_validate_gtfs_feed_zero_and_empty_coords(tmp_path: Path) -> None:
|
||||||
feed = _make_gtfs(
|
feed = _make_gtfs(
|
||||||
tmp_path / "feed.zip",
|
tmp_path / "feed.zip",
|
||||||
stops=(
|
stops=(
|
||||||
"stop_id,stop_name,stop_lat,stop_lon\nSTOP_A,Nowhere,0,0\nSTOP_B,Blank,,\n"
|
"stop_id,stop_name,stop_lat,stop_lon\n"
|
||||||
|
"STOP_A,Nowhere,0,0\n"
|
||||||
|
"STOP_B,Blank,,\n"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
with pytest.raises(RuntimeError, match=r"plausible UK coordinates"):
|
with pytest.raises(RuntimeError, match=r"plausible UK coordinates"):
|
||||||
|
|
@ -283,254 +281,3 @@ def test_validate_gtfs_feed_not_a_zip(tmp_path: Path) -> None:
|
||||||
bogus.write_text("not a zip")
|
bogus.write_text("not a zip")
|
||||||
with pytest.raises(RuntimeError, match="not a valid zip"):
|
with pytest.raises(RuntimeError, match="not a valid zip"):
|
||||||
validate_gtfs_feed(bogus, "bogus feed", today=TODAY)
|
validate_gtfs_feed(bogus, "bogus feed", today=TODAY)
|
||||||
|
|
||||||
|
|
||||||
# ── _repair_stop_coordinate ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"stop_id,lat,lon,expected",
|
|
||||||
[
|
|
||||||
# Known-bad stations get an authoritative override (TCR ships transposed,
|
|
||||||
# BDS ships a wrong-signed longitude; both are in STATION_COORD_OVERRIDES).
|
|
||||||
("TCR", -0.1306, 51.5163, (*STATION_COORD_OVERRIDES["TCR"], "override")),
|
|
||||||
("BDS", 51.514, 0.15, (*STATION_COORD_OVERRIDES["BDS"], "override")),
|
|
||||||
# A plausible UK coordinate is left untouched.
|
|
||||||
("ZFD", 51.5205, -0.1050, (51.5205, -0.1050, "keep")),
|
|
||||||
# An unknown station with lat/lon transposed is swapped back generically.
|
|
||||||
("ZZZ", -0.13, 51.51, (51.51, -0.13, "transpose")),
|
|
||||||
# Genuinely out-of-area garbage (Irish CIE South Atlantic) is neutralised.
|
|
||||||
("IEP", -4.172, -14.5154, (54.0, -2.0, "dump")),
|
|
||||||
# Missing coordinates are neutralised too.
|
|
||||||
("NUL", None, None, (54.0, -2.0, "dump")),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_repair_stop_coordinate(stop_id, lat, lon, expected) -> None:
|
|
||||||
assert _repair_stop_coordinate(stop_id, lat, lon) == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_clean_national_rail_repairs_broken_station_coords(tmp_path: Path) -> None:
|
|
||||||
"""End-to-end: the cleaner repairs the exact TCR/BDS failure modes.
|
|
||||||
|
|
||||||
TCR (transposed) and BDS (wrong-signed lon) are corrected to their override
|
|
||||||
coordinates; an unknown transposed stop is swapped back; genuine out-of-area
|
|
||||||
garbage is dumped; a good coordinate is preserved.
|
|
||||||
"""
|
|
||||||
src = tmp_path / "in.zip"
|
|
||||||
dst = tmp_path / "out.zip"
|
|
||||||
stops = (
|
|
||||||
"stop_id,stop_name,stop_lat,stop_lon\n"
|
|
||||||
"TCR,Tottenham Court Road (Elizabeth line),-0.1306,51.5163\n"
|
|
||||||
"BDS,Bond Street (Elizabeth line),51.514,0.15\n"
|
|
||||||
"ZZZ,Transposed Halt,-0.20,51.40\n"
|
|
||||||
"IEP,Cork (CIE),-4.172,-14.5154\n"
|
|
||||||
"GUD,Good Station,51.50,-0.10\n"
|
|
||||||
)
|
|
||||||
with zipfile.ZipFile(src, "w") as z:
|
|
||||||
z.writestr("stops.txt", stops)
|
|
||||||
z.writestr("routes.txt", "route_id,route_type\nR1,2\n")
|
|
||||||
z.writestr("trips.txt", "trip_id,route_id,service_id\nT1,R1,S1\n")
|
|
||||||
z.writestr(
|
|
||||||
"stop_times.txt",
|
|
||||||
"trip_id,stop_id,stop_sequence,departure_time\n"
|
|
||||||
"T1,TCR,1,06:00:00\n"
|
|
||||||
"T1,BDS,2,06:03:00\n"
|
|
||||||
"T1,GUD,3,06:06:00\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
clean_national_rail_gtfs(src, dst)
|
|
||||||
|
|
||||||
with zipfile.ZipFile(dst, "r") as z:
|
|
||||||
rows = z.read("stops.txt").decode("utf-8").splitlines()
|
|
||||||
coords = {r.split(",")[0]: r.split(",")[-2:] for r in rows[1:]}
|
|
||||||
assert (
|
|
||||||
float(coords["TCR"][0]),
|
|
||||||
float(coords["TCR"][1]),
|
|
||||||
) == STATION_COORD_OVERRIDES["TCR"]
|
|
||||||
assert (
|
|
||||||
float(coords["BDS"][0]),
|
|
||||||
float(coords["BDS"][1]),
|
|
||||||
) == STATION_COORD_OVERRIDES["BDS"]
|
|
||||||
assert (float(coords["ZZZ"][0]), float(coords["ZZZ"][1])) == (51.40, -0.20)
|
|
||||||
assert (float(coords["IEP"][0]), float(coords["IEP"][1])) == (54.0, -2.0)
|
|
||||||
assert (float(coords["GUD"][0]), float(coords["GUD"][1])) == (51.50, -0.10)
|
|
||||||
|
|
||||||
|
|
||||||
# ── validate_stop_geometry ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _geometry_gtfs(path: Path, *, n_trips: int, b_lat: float, b_lon: float) -> Path:
|
|
||||||
"""A metro line A–B–C (5-minute hops) repeated across n_trips.
|
|
||||||
|
|
||||||
Displacing B far from A and C makes it a displacement outlier; n_trips sets
|
|
||||||
its service level (the hard-fail vs warn tier).
|
|
||||||
"""
|
|
||||||
routes = "route_id,route_type\nR1,1\n"
|
|
||||||
trips = "trip_id,route_id,service_id\n" + "".join(
|
|
||||||
f"T{i},R1,S1\n" for i in range(n_trips)
|
|
||||||
)
|
|
||||||
stops = (
|
|
||||||
"stop_id,stop_name,stop_lat,stop_lon\n"
|
|
||||||
"A,Aaa,51.50,-0.10\n"
|
|
||||||
f"B,Bbb,{b_lat},{b_lon}\n"
|
|
||||||
"C,Ccc,51.52,-0.10\n"
|
|
||||||
)
|
|
||||||
header = "trip_id,stop_id,stop_sequence,arrival_time,departure_time\n"
|
|
||||||
body = "".join(
|
|
||||||
f"T{i},A,0,06:00:00,06:00:00\n"
|
|
||||||
f"T{i},B,1,06:05:00,06:05:00\n"
|
|
||||||
f"T{i},C,2,06:10:00,06:10:00\n"
|
|
||||||
for i in range(n_trips)
|
|
||||||
)
|
|
||||||
with zipfile.ZipFile(path, "w") as z:
|
|
||||||
z.writestr("routes.txt", routes)
|
|
||||||
z.writestr("trips.txt", trips)
|
|
||||||
z.writestr("stops.txt", stops)
|
|
||||||
z.writestr("stop_times.txt", header + body)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_stop_geometry_fails_on_high_service_displacement(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""A busy stop whose trains imply teleportation fails the build (TCR mode)."""
|
|
||||||
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=120, b_lat=58.0, b_lon=-2.0)
|
|
||||||
with pytest.raises(RuntimeError, match="stop-geometry validation failed"):
|
|
||||||
validate_stop_geometry(feed, "displaced feed")
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_stop_geometry_passes_when_stops_are_coherent(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=120, b_lat=51.51, b_lon=-0.10)
|
|
||||||
validate_stop_geometry(feed, "coherent feed") # must not raise
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_stop_geometry_only_warns_on_low_service_displacement(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Heritage-line quirks (few trips) warn but do not block a build."""
|
|
||||||
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=6, b_lat=58.0, b_lon=-2.0)
|
|
||||||
validate_stop_geometry(feed, "heritage feed") # must not raise
|
|
||||||
|
|
||||||
|
|
||||||
# ── validate_london_coverage ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_ALL_LU = (
|
|
||||||
"Bakerloo",
|
|
||||||
"Central",
|
|
||||||
"Circle",
|
|
||||||
"District",
|
|
||||||
"Hammersmith & City",
|
|
||||||
"Jubilee",
|
|
||||||
"Metropolitan",
|
|
||||||
"Northern",
|
|
||||||
"Piccadilly",
|
|
||||||
"Victoria",
|
|
||||||
"Waterloo & City",
|
|
||||||
)
|
|
||||||
|
|
||||||
_COVERAGE_CALENDAR = (
|
|
||||||
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
|
|
||||||
"start_date,end_date\n"
|
|
||||||
"S1,1,1,1,1,1,1,1,20260101,20271231\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _bods_coverage(
|
|
||||||
path: Path,
|
|
||||||
*,
|
|
||||||
lu_lines: tuple[str, ...] = _ALL_LU,
|
|
||||||
include_dlr: bool = True,
|
|
||||||
include_tramlink: bool = True,
|
|
||||||
) -> Path:
|
|
||||||
routes = ["route_id,agency_id,route_short_name,route_long_name,route_type"]
|
|
||||||
trips = ["trip_id,route_id,service_id"]
|
|
||||||
n = 0
|
|
||||||
for line in lu_lines:
|
|
||||||
routes.append(f"LU{n},LU,{line},,1")
|
|
||||||
trips.append(f"T{n},LU{n},S1")
|
|
||||||
n += 1
|
|
||||||
if include_dlr:
|
|
||||||
routes.append(f"DLR{n},DLRA,DLR,Docklands Light Railway,2")
|
|
||||||
trips.append(f"T{n},DLR{n},S1")
|
|
||||||
n += 1
|
|
||||||
if include_tramlink:
|
|
||||||
routes.append(f"TR{n},TRAM,Tram,London Tramlink,0")
|
|
||||||
trips.append(f"T{n},TR{n},S1")
|
|
||||||
n += 1
|
|
||||||
with zipfile.ZipFile(path, "w") as z:
|
|
||||||
z.writestr("calendar.txt", _COVERAGE_CALENDAR)
|
|
||||||
z.writestr("routes.txt", "\n".join(routes) + "\n")
|
|
||||||
z.writestr("trips.txt", "\n".join(trips) + "\n")
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def _nr_coverage(
|
|
||||||
path: Path, *, include_elizabeth: bool = True, include_overground: bool = True
|
|
||||||
) -> Path:
|
|
||||||
routes = ["route_id,agency_id,route_short_name,route_long_name,route_type"]
|
|
||||||
trips = ["trip_id,route_id,service_id"]
|
|
||||||
if include_elizabeth:
|
|
||||||
routes.append("XR1,XR,XR:PAD->ABW,Elizabeth line,2")
|
|
||||||
trips.append("TX,XR1,S1")
|
|
||||||
if include_overground:
|
|
||||||
routes.append("LO1,LO,LO,London Overground,2")
|
|
||||||
trips.append("TL,LO1,S1")
|
|
||||||
with zipfile.ZipFile(path, "w") as z:
|
|
||||||
z.writestr("calendar.txt", _COVERAGE_CALENDAR)
|
|
||||||
z.writestr("routes.txt", "\n".join(routes) + "\n")
|
|
||||||
z.writestr("trips.txt", "\n".join(trips) + "\n")
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_london_coverage_happy_path(tmp_path: Path) -> None:
|
|
||||||
bods = _bods_coverage(tmp_path / "bods.zip")
|
|
||||||
nr = _nr_coverage(tmp_path / "nr.zip")
|
|
||||||
validate_london_coverage(bods, nr, today=TODAY) # must not raise
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_london_coverage_missing_tube_line_fails(tmp_path: Path) -> None:
|
|
||||||
bods = _bods_coverage(
|
|
||||||
tmp_path / "bods.zip",
|
|
||||||
lu_lines=tuple(name for name in _ALL_LU if name != "Victoria"),
|
|
||||||
)
|
|
||||||
nr = _nr_coverage(tmp_path / "nr.zip")
|
|
||||||
with pytest.raises(RuntimeError, match="Victoria"):
|
|
||||||
validate_london_coverage(bods, nr, today=TODAY)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_london_coverage_missing_dlr_fails(tmp_path: Path) -> None:
|
|
||||||
bods = _bods_coverage(tmp_path / "bods.zip", include_dlr=False)
|
|
||||||
nr = _nr_coverage(tmp_path / "nr.zip")
|
|
||||||
with pytest.raises(RuntimeError, match="DLR"):
|
|
||||||
validate_london_coverage(bods, nr, today=TODAY)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_london_coverage_missing_elizabeth_fails(tmp_path: Path) -> None:
|
|
||||||
bods = _bods_coverage(tmp_path / "bods.zip")
|
|
||||||
nr = _nr_coverage(tmp_path / "nr.zip", include_elizabeth=False)
|
|
||||||
with pytest.raises(RuntimeError, match="Elizabeth line"):
|
|
||||||
validate_london_coverage(bods, nr, today=TODAY)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_london_coverage_expired_service_fails(tmp_path: Path) -> None:
|
|
||||||
"""A line whose only calendar expired years ago counts as missing (present in
|
|
||||||
routes.txt but with no active service — the retired-TfL-feed failure mode)."""
|
|
||||||
bods = _bods_coverage(tmp_path / "bods.zip")
|
|
||||||
nr = tmp_path / "nr.zip"
|
|
||||||
expired = (
|
|
||||||
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
|
|
||||||
"start_date,end_date\n"
|
|
||||||
"S1,1,1,1,1,1,1,1,20091201,20101224\n"
|
|
||||||
)
|
|
||||||
with zipfile.ZipFile(nr, "w") as z:
|
|
||||||
z.writestr("calendar.txt", expired)
|
|
||||||
z.writestr(
|
|
||||||
"routes.txt",
|
|
||||||
"route_id,agency_id,route_short_name,route_long_name,route_type\n"
|
|
||||||
"XR1,XR,XR:PAD->ABW,Elizabeth line,2\nLO1,LO,LO,London Overground,2\n",
|
|
||||||
)
|
|
||||||
z.writestr("trips.txt", "trip_id,route_id,service_id\nTX,XR1,S1\nTL,LO1,S1\n")
|
|
||||||
with pytest.raises(RuntimeError, match="Elizabeth line|Overground"):
|
|
||||||
validate_london_coverage(bods, nr, today=TODAY)
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ import zipfile
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import polars as pl
|
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
|
|
||||||
from pipeline.local_temp import local_tmp_dir
|
from pipeline.local_temp import local_tmp_dir
|
||||||
|
|
@ -67,56 +66,6 @@ GTFS_MIN_VALID_STOP_FRACTION = 0.95
|
||||||
UK_LAT_RANGE = (49.0, 61.0)
|
UK_LAT_RANGE = (49.0, 61.0)
|
||||||
UK_LON_RANGE = (-9.0, 2.5)
|
UK_LON_RANGE = (-9.0, 2.5)
|
||||||
|
|
||||||
# Authoritative (lat, lon) for stops that upstream feeds ship mislocated, keyed
|
|
||||||
# by GTFS stop_id (National Rail CRS code). The National Rail CIF → GTFS export
|
|
||||||
# ships the Elizabeth-line-only core stations broken: Tottenham Court Road (TCR)
|
|
||||||
# has its lat/lon transposed and Bond Street (BDS) has a wrong-signed longitude,
|
|
||||||
# leaving them ~300km and ~20km from reality. Both then fail to link to the
|
|
||||||
# street network, so nobody can board/alight the Elizabeth line there and every
|
|
||||||
# journey through them silently reroutes (e.g. TCR→Heathrow via the Central line
|
|
||||||
# + Heathrow Express instead of one seat on the Elizabeth line). Coordinates are
|
|
||||||
# the TfL/BODS station nodes for the same physical stations. New breakages that
|
|
||||||
# these overrides don't cover are caught by validate_stop_geometry() below, which
|
|
||||||
# fails the build rather than shipping a phantom stop.
|
|
||||||
STATION_COORD_OVERRIDES = {
|
|
||||||
"TCR": (51.51643, -0.13041), # Tottenham Court Road (Elizabeth line)
|
|
||||||
"BDS": (51.51430, -0.14972), # Bond Street (Elizabeth line)
|
|
||||||
}
|
|
||||||
|
|
||||||
# validate_stop_geometry(): a served tram/metro/rail stop is a "displacement
|
|
||||||
# outlier" when, over real timetabled hops (>= GEOMETRY_MIN_HOP_SECONDS, so that
|
|
||||||
# coarse timetables where adjacent stops share a minute don't count), the implied
|
|
||||||
# in-vehicle speed to a MAJORITY of its distinct trip-neighbours exceeds
|
|
||||||
# GEOMETRY_MAX_KMH. Such a stop sits nowhere near where its trains actually run
|
|
||||||
# (the TCR failure mode). Outliers with >= GEOMETRY_HARDFAIL_MIN_TRIPS timetabled
|
|
||||||
# trips FAIL the build; rarer ones (heritage lines) only warn. Scoped to
|
|
||||||
# rail/metro/tram route_types: buses carry demand-responsive "area" stops and
|
|
||||||
# same-minute urban hops that are noisy without being real geometry errors, and
|
|
||||||
# the R5-side unlinked-stop check covers gross bus displacement anyway.
|
|
||||||
GEOMETRY_RAIL_ROUTE_TYPES = frozenset({"0", "1", "2"})
|
|
||||||
GEOMETRY_MIN_HOP_SECONDS = 120
|
|
||||||
GEOMETRY_MAX_KMH = 300.0
|
|
||||||
GEOMETRY_HARDFAIL_MIN_TRIPS = 100
|
|
||||||
|
|
||||||
# London modes/lines that must be present with active service in the combined
|
|
||||||
# network. A feed regression that silently drops one (as the retired TfL
|
|
||||||
# TransXChange feed did — see module docstring) FAILS the build instead of
|
|
||||||
# quietly degrading every affected journey. Underground lines and Tramlink/DLR
|
|
||||||
# come from BODS; the Elizabeth line and Overground come from National Rail.
|
|
||||||
LONDON_UNDERGROUND_LINES = (
|
|
||||||
"Bakerloo",
|
|
||||||
"Central",
|
|
||||||
"Circle",
|
|
||||||
"District",
|
|
||||||
"Hammersmith & City",
|
|
||||||
"Jubilee",
|
|
||||||
"Metropolitan",
|
|
||||||
"Northern",
|
|
||||||
"Piccadilly",
|
|
||||||
"Victoria",
|
|
||||||
"Waterloo & City",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _download_http(
|
def _download_http(
|
||||||
url: str, dest: Path, *, desc: str, headers: dict | None = None
|
url: str, dest: Path, *, desc: str, headers: dict | None = None
|
||||||
|
|
@ -855,8 +804,6 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
bad_trips_removed = 0
|
bad_trips_removed = 0
|
||||||
seqs_renumbered = 0
|
seqs_renumbered = 0
|
||||||
coords_fixed = 0
|
coords_fixed = 0
|
||||||
coords_overridden = 0
|
|
||||||
coords_transposed = 0
|
|
||||||
route_types_fixed = 0
|
route_types_fixed = 0
|
||||||
|
|
||||||
with (
|
with (
|
||||||
|
|
@ -943,7 +890,6 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
with zin.open(info) as f:
|
with zin.open(info) as f:
|
||||||
header = f.readline()
|
header = f.readline()
|
||||||
cols = _parse_csv_line(header)
|
cols = _parse_csv_line(header)
|
||||||
stop_id_idx = cols.index("stop_id")
|
|
||||||
lat_idx = cols.index("stop_lat")
|
lat_idx = cols.index("stop_lat")
|
||||||
lon_idx = cols.index("stop_lon")
|
lon_idx = cols.index("stop_lon")
|
||||||
|
|
||||||
|
|
@ -959,24 +905,16 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
parts = _parse_csv_line(line)
|
parts = _parse_csv_line(line)
|
||||||
if not parts:
|
if not parts:
|
||||||
continue
|
continue
|
||||||
stop_id = parts[stop_id_idx].strip('"')
|
|
||||||
try:
|
try:
|
||||||
lat = float(parts[lat_idx])
|
lat = float(parts[lat_idx])
|
||||||
lon = float(parts[lon_idx])
|
# Fix bogus Irish CIE coordinates (South Atlantic)
|
||||||
except (ValueError, IndexError):
|
if lat < 0:
|
||||||
lat = lon = None
|
# Set to a neutral UK coordinate that won't be routed to
|
||||||
new_lat, new_lon, action = _repair_stop_coordinate(
|
parts[lat_idx] = "54.0"
|
||||||
stop_id, lat, lon
|
parts[lon_idx] = "-2.0"
|
||||||
)
|
|
||||||
if action != "keep":
|
|
||||||
parts[lat_idx] = repr(new_lat)
|
|
||||||
parts[lon_idx] = repr(new_lon)
|
|
||||||
if action == "override":
|
|
||||||
coords_overridden += 1
|
|
||||||
elif action == "transpose":
|
|
||||||
coords_transposed += 1
|
|
||||||
else: # "dump"
|
|
||||||
coords_fixed += 1
|
coords_fixed += 1
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
tmp.write(_format_csv_row(parts))
|
tmp.write(_format_csv_row(parts))
|
||||||
|
|
||||||
tmp.close()
|
tmp.close()
|
||||||
|
|
@ -1076,9 +1014,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
|
||||||
print(f" Orphan stop references removed: {orphan_stops_removed}")
|
print(f" Orphan stop references removed: {orphan_stops_removed}")
|
||||||
print(f" Bad trip stop_times removed: {bad_trips_removed}")
|
print(f" Bad trip stop_times removed: {bad_trips_removed}")
|
||||||
print(f" Stop sequences renumbered: {seqs_renumbered}")
|
print(f" Stop sequences renumbered: {seqs_renumbered}")
|
||||||
print(f" Coordinates overridden (known-bad stations): {coords_overridden}")
|
print(f" Bogus coordinates fixed: {coords_fixed}")
|
||||||
print(f" Coordinates de-transposed (lat/lon swapped): {coords_transposed}")
|
|
||||||
print(f" Bogus coordinates dumped (out-of-area): {coords_fixed}")
|
|
||||||
print(f" Route types 714→3 fixed: {route_types_fixed}")
|
print(f" Route types 714→3 fixed: {route_types_fixed}")
|
||||||
print(f" Saved to {dst}")
|
print(f" Saved to {dst}")
|
||||||
|
|
||||||
|
|
@ -1203,410 +1139,6 @@ def convert_national_rail_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
|
||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
def _in_uk(lat: float, lon: float) -> bool:
|
|
||||||
"""True if (lat, lon) falls inside the coarse UK routing bounding box."""
|
|
||||||
return (
|
|
||||||
UK_LAT_RANGE[0] <= lat <= UK_LAT_RANGE[1]
|
|
||||||
and UK_LON_RANGE[0] <= lon <= UK_LON_RANGE[1]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _repair_stop_coordinate(
|
|
||||||
stop_id: str, lat: float | None, lon: float | None
|
|
||||||
) -> tuple[float, float, str]:
|
|
||||||
"""Repair an obviously-broken stop coordinate. Returns (lat, lon, action).
|
|
||||||
|
|
||||||
action is one of:
|
|
||||||
"override" - an authoritative coordinate was substituted for a known-bad
|
|
||||||
station (see STATION_COORD_OVERRIDES).
|
|
||||||
"transpose" - the feed shipped lat/lon swapped (the coordinate is outside
|
|
||||||
the UK but swapping lands inside it), so they are swapped
|
|
||||||
back. This is the Tottenham Court Road failure mode and is
|
|
||||||
handled generically, not just for the hard-coded stations.
|
|
||||||
"dump" - the coordinate is genuinely out of area (Irish CIE stations
|
|
||||||
at 0,0-ish South Atlantic garbage, missing coordinates) and
|
|
||||||
is moved to a neutral inland point that will not be routed
|
|
||||||
to, preserving the historical behaviour for those stops.
|
|
||||||
"keep" - already a plausible UK coordinate; left unchanged.
|
|
||||||
"""
|
|
||||||
if stop_id in STATION_COORD_OVERRIDES:
|
|
||||||
return (*STATION_COORD_OVERRIDES[stop_id], "override")
|
|
||||||
if lat is None or lon is None:
|
|
||||||
return 54.0, -2.0, "dump"
|
|
||||||
if _in_uk(lat, lon):
|
|
||||||
return lat, lon, "keep"
|
|
||||||
if _in_uk(lon, lat):
|
|
||||||
return lon, lat, "transpose"
|
|
||||||
return 54.0, -2.0, "dump"
|
|
||||||
|
|
||||||
|
|
||||||
def _secs_expr(col: str) -> pl.Expr:
|
|
||||||
"""Polars expression parsing an HH:MM:SS GTFS time to seconds since midnight."""
|
|
||||||
parts = pl.col(col).str.split(":")
|
|
||||||
return (
|
|
||||||
parts.list.get(0).cast(pl.Int64, strict=False) * 3600
|
|
||||||
+ parts.list.get(1).cast(pl.Int64, strict=False) * 60
|
|
||||||
+ parts.list.get(2).cast(pl.Int64, strict=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_member(path: Path, member: str, dest_dir: str) -> Path:
|
|
||||||
"""Stream one file out of a GTFS zip to dest_dir (avoids holding it in RAM)."""
|
|
||||||
out = Path(dest_dir) / member
|
|
||||||
with zipfile.ZipFile(path) as z, z.open(member) as src, open(out, "wb") as dst:
|
|
||||||
shutil.copyfileobj(src, dst)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def validate_stop_geometry(path: Path, feed_name: str) -> None:
|
|
||||||
"""Fail if a served rail/metro/tram stop is a coordinate displacement outlier.
|
|
||||||
|
|
||||||
Guards against the Tottenham Court Road failure mode: a stop whose timetabled
|
|
||||||
trains imply teleportation (>{GEOMETRY_MAX_KMH:.0f} km/h over a real hop) to a
|
|
||||||
MAJORITY of its distinct trip-neighbours is not where the feed places it, so
|
|
||||||
it cannot link to the street network and every journey through it silently
|
|
||||||
reroutes. High-service outliers (>= {GEOMETRY_HARDFAIL_MIN_TRIPS} trips) raise;
|
|
||||||
negligible-service ones (heritage lines) only warn. Scoped to tram/metro/rail
|
|
||||||
route types; see GEOMETRY_* constants.
|
|
||||||
"""
|
|
||||||
print(f"Validating stop geometry for feed '{feed_name}'...")
|
|
||||||
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as td:
|
|
||||||
routes = pl.read_csv(
|
|
||||||
_extract_member(path, "routes.txt", td), infer_schema_length=0
|
|
||||||
).select(
|
|
||||||
pl.col("route_id").cast(pl.Utf8),
|
|
||||||
pl.col("route_type").cast(pl.Utf8),
|
|
||||||
)
|
|
||||||
rail_route_ids = routes.filter(
|
|
||||||
pl.col("route_type").is_in(list(GEOMETRY_RAIL_ROUTE_TYPES))
|
|
||||||
).select("route_id")
|
|
||||||
trips = pl.read_csv(
|
|
||||||
_extract_member(path, "trips.txt", td), infer_schema_length=0
|
|
||||||
).select(
|
|
||||||
pl.col("trip_id").cast(pl.Utf8),
|
|
||||||
pl.col("route_id").cast(pl.Utf8),
|
|
||||||
)
|
|
||||||
rail_trips = trips.join(rail_route_ids, on="route_id", how="inner").select(
|
|
||||||
"trip_id"
|
|
||||||
)
|
|
||||||
if rail_trips.height == 0:
|
|
||||||
print(" no rail/metro/tram trips in feed; nothing to check")
|
|
||||||
return
|
|
||||||
stops = pl.read_csv(
|
|
||||||
_extract_member(path, "stops.txt", td), infer_schema_length=0
|
|
||||||
).select(
|
|
||||||
pl.col("stop_id").cast(pl.Utf8),
|
|
||||||
pl.col("stop_name").cast(pl.Utf8).alias("name"),
|
|
||||||
pl.col("stop_lat").cast(pl.Float64, strict=False).alias("lat"),
|
|
||||||
pl.col("stop_lon").cast(pl.Float64, strict=False).alias("lon"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Only rail/metro/tram stop_times, ordered within each trip.
|
|
||||||
st = (
|
|
||||||
pl.scan_csv(
|
|
||||||
_extract_member(path, "stop_times.txt", td), infer_schema_length=0
|
|
||||||
)
|
|
||||||
.select(
|
|
||||||
pl.col("trip_id").cast(pl.Utf8),
|
|
||||||
pl.col("stop_id").cast(pl.Utf8),
|
|
||||||
pl.col("stop_sequence").cast(pl.Int64, strict=False).alias("seq"),
|
|
||||||
_secs_expr("departure_time").alias("dep"),
|
|
||||||
_secs_expr("arrival_time").alias("arr"),
|
|
||||||
)
|
|
||||||
.join(rail_trips.lazy(), on="trip_id", how="inner")
|
|
||||||
.join(
|
|
||||||
stops.lazy().select(["stop_id", "lat", "lon"]), on="stop_id", how="left"
|
|
||||||
)
|
|
||||||
.collect()
|
|
||||||
.sort(["trip_id", "seq"])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Service level: distinct trips serving each stop (the hard-fail tier).
|
|
||||||
svc = st.group_by("stop_id").agg(pl.col("trip_id").n_unique().alias("trips"))
|
|
||||||
|
|
||||||
# Consecutive-stop hops within a trip.
|
|
||||||
st = st.with_columns(
|
|
||||||
pl.col("lat").shift(1).over("trip_id").alias("plat"),
|
|
||||||
pl.col("lon").shift(1).over("trip_id").alias("plon"),
|
|
||||||
pl.col("stop_id").shift(1).over("trip_id").alias("pid"),
|
|
||||||
pl.col("dep").shift(1).over("trip_id").alias("pdep"),
|
|
||||||
)
|
|
||||||
earth_km = 6371.0
|
|
||||||
dlat = (pl.col("lat") - pl.col("plat")).radians()
|
|
||||||
dlon = (pl.col("lon") - pl.col("plon")).radians()
|
|
||||||
hav = (dlat / 2).sin() ** 2 + pl.col("plat").radians().cos() * pl.col(
|
|
||||||
"lat"
|
|
||||||
).radians().cos() * (dlon / 2).sin() ** 2
|
|
||||||
hops = (
|
|
||||||
st.with_columns(
|
|
||||||
(2 * earth_km * hav.sqrt().arcsin()).alias("dist_km"),
|
|
||||||
(pl.col("arr") - pl.col("pdep")).alias("dt_s"),
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
pl.col("plat").is_not_null()
|
|
||||||
& pl.col("dist_km").is_not_null()
|
|
||||||
& (pl.col("dt_s") >= GEOMETRY_MIN_HOP_SECONDS)
|
|
||||||
)
|
|
||||||
.with_columns((pl.col("dist_km") / (pl.col("dt_s") / 3600.0)).alias("kmh"))
|
|
||||||
)
|
|
||||||
if hops.height == 0:
|
|
||||||
print(" no timetabled hops long enough to assess; skipping")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Undirected stop-neighbour edges, flagged if any hop teleports.
|
|
||||||
fwd = hops.select(
|
|
||||||
pl.col("stop_id").alias("a"),
|
|
||||||
pl.col("pid").alias("b"),
|
|
||||||
(pl.col("kmh") > GEOMETRY_MAX_KMH).alias("tp"),
|
|
||||||
)
|
|
||||||
rev = fwd.select(pl.col("b").alias("a"), pl.col("a").alias("b"), "tp")
|
|
||||||
edges = (
|
|
||||||
pl.concat([fwd, rev])
|
|
||||||
.group_by(["a", "b"])
|
|
||||||
.agg(pl.col("tp").max().alias("tp"))
|
|
||||||
)
|
|
||||||
per_stop = edges.group_by("a").agg(
|
|
||||||
pl.len().alias("nbrs"), pl.col("tp").sum().alias("tp_nbrs")
|
|
||||||
)
|
|
||||||
outliers = (
|
|
||||||
per_stop.filter(
|
|
||||||
(pl.col("nbrs") >= 2) & (pl.col("tp_nbrs") / pl.col("nbrs") >= 0.5)
|
|
||||||
)
|
|
||||||
.join(stops, left_on="a", right_on="stop_id", how="left")
|
|
||||||
.join(svc, left_on="a", right_on="stop_id", how="left")
|
|
||||||
.with_columns(pl.col("trips").fill_null(0))
|
|
||||||
.sort("trips", descending=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
hard = outliers.filter(pl.col("trips") >= GEOMETRY_HARDFAIL_MIN_TRIPS)
|
|
||||||
soft = outliers.filter(pl.col("trips") < GEOMETRY_HARDFAIL_MIN_TRIPS)
|
|
||||||
for r in soft.iter_rows(named=True):
|
|
||||||
print(
|
|
||||||
f" WARN low-service displacement outlier: {r['a']} "
|
|
||||||
f"'{r['name']}' ({r['trips']} trips) at ({r['lat']}, {r['lon']})"
|
|
||||||
)
|
|
||||||
if hard.height > 0:
|
|
||||||
lines = [
|
|
||||||
f" {r['a']} '{r['name']}' ({r['trips']} trips) "
|
|
||||||
f"at ({r['lat']}, {r['lon']})"
|
|
||||||
for r in hard.iter_rows(named=True)
|
|
||||||
]
|
|
||||||
raise RuntimeError(
|
|
||||||
f"stop-geometry validation failed for feed '{feed_name}': "
|
|
||||||
f"{hard.height} high-service rail/metro/tram stop(s) sit nowhere "
|
|
||||||
f"near where their trains run (implied speed > {GEOMETRY_MAX_KMH:.0f} "
|
|
||||||
f"km/h to most neighbours). These cannot link to the street network "
|
|
||||||
f"and every journey through them silently reroutes. Add an entry to "
|
|
||||||
f"STATION_COORD_OVERRIDES or fix the upstream feed:\n"
|
|
||||||
+ "\n".join(lines)
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
f" OK: {outliers.height} displacement outlier(s), none at or above "
|
|
||||||
f"{GEOMETRY_HARDFAIL_MIN_TRIPS} trips"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _active_service_ids(path: Path, window_start: int, window_end: int) -> set[str]:
|
|
||||||
"""Service ids with at least one running day in [window_start, window_end]."""
|
|
||||||
active: set[str] = set()
|
|
||||||
weekdays = (
|
|
||||||
"monday",
|
|
||||||
"tuesday",
|
|
||||||
"wednesday",
|
|
||||||
"thursday",
|
|
||||||
"friday",
|
|
||||||
"saturday",
|
|
||||||
"sunday",
|
|
||||||
)
|
|
||||||
with zipfile.ZipFile(path) as z:
|
|
||||||
names = set(z.namelist())
|
|
||||||
if "calendar.txt" in names:
|
|
||||||
with z.open("calendar.txt") as f:
|
|
||||||
cols = _parse_csv_line(f.readline())
|
|
||||||
sid_i = cols.index("service_id")
|
|
||||||
start_i = cols.index("start_date")
|
|
||||||
end_i = cols.index("end_date")
|
|
||||||
day_i = [cols.index(d) for d in weekdays if d in cols]
|
|
||||||
for line in f:
|
|
||||||
parts = _parse_csv_line(line)
|
|
||||||
if not parts:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
start = int(parts[start_i].strip('"'))
|
|
||||||
end = int(parts[end_i].strip('"'))
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
continue
|
|
||||||
if start > window_end or end < window_start:
|
|
||||||
continue
|
|
||||||
if day_i and not any(
|
|
||||||
parts[i].strip('"') == "1" for i in day_i if i < len(parts)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
active.add(parts[sid_i].strip('"'))
|
|
||||||
if "calendar_dates.txt" in names:
|
|
||||||
with z.open("calendar_dates.txt") as f:
|
|
||||||
cols = _parse_csv_line(f.readline())
|
|
||||||
sid_i = cols.index("service_id")
|
|
||||||
date_i = cols.index("date")
|
|
||||||
exc_i = cols.index("exception_type")
|
|
||||||
for line in f:
|
|
||||||
parts = _parse_csv_line(line)
|
|
||||||
if not parts:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
date = int(parts[date_i].strip('"'))
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
continue
|
|
||||||
if exc_i < len(parts) and parts[exc_i].strip('"') != "1":
|
|
||||||
continue
|
|
||||||
if window_start <= date <= window_end:
|
|
||||||
active.add(parts[sid_i].strip('"'))
|
|
||||||
return active
|
|
||||||
|
|
||||||
|
|
||||||
def _lines_with_active_service(
|
|
||||||
path: Path,
|
|
||||||
active_services: set[str],
|
|
||||||
*,
|
|
||||||
route_predicate,
|
|
||||||
) -> set[str]:
|
|
||||||
"""Return the set of route labels (agency_id::short_name matched by
|
|
||||||
route_predicate) that have at least one trip on an active service.
|
|
||||||
|
|
||||||
route_predicate((agency_id, route_type, short_name, long_name)) -> label|None.
|
|
||||||
A returned label marks the route as one we care about; None ignores it.
|
|
||||||
"""
|
|
||||||
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as td:
|
|
||||||
routes = pl.read_csv(
|
|
||||||
_extract_member(path, "routes.txt", td), infer_schema_length=0
|
|
||||||
)
|
|
||||||
route_label: dict[str, str] = {}
|
|
||||||
for r in routes.iter_rows(named=True):
|
|
||||||
label = route_predicate(
|
|
||||||
(
|
|
||||||
str(r.get("agency_id", "")),
|
|
||||||
str(r.get("route_type", "")),
|
|
||||||
str(r.get("route_short_name", "")),
|
|
||||||
str(r.get("route_long_name", "")),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if label is not None:
|
|
||||||
route_label[str(r["route_id"])] = label
|
|
||||||
|
|
||||||
if not route_label:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
trips = pl.read_csv(
|
|
||||||
_extract_member(path, "trips.txt", td), infer_schema_length=0
|
|
||||||
).select(
|
|
||||||
pl.col("route_id").cast(pl.Utf8),
|
|
||||||
pl.col("service_id").cast(pl.Utf8),
|
|
||||||
)
|
|
||||||
present: set[str] = set()
|
|
||||||
for row in trips.iter_rows():
|
|
||||||
route_id, service_id = str(row[0]), str(row[1])
|
|
||||||
label = route_label.get(route_id)
|
|
||||||
if label is not None and service_id in active_services:
|
|
||||||
present.add(label)
|
|
||||||
return present
|
|
||||||
|
|
||||||
|
|
||||||
def validate_london_coverage(
|
|
||||||
bods_path: Path, nr_path: Path, *, today: dt.date | None = None
|
|
||||||
) -> None:
|
|
||||||
"""Fail if any must-have London line/mode lacks active service in the window.
|
|
||||||
|
|
||||||
A silent regression that drops the Underground, DLR, Tramlink, the Elizabeth
|
|
||||||
line or the Overground (as the retired TfL TransXChange feed did) would leave
|
|
||||||
the map quietly under-serving huge swaths of journeys. This turns that into a
|
|
||||||
hard build failure. See LONDON_UNDERGROUND_LINES.
|
|
||||||
"""
|
|
||||||
if today is None:
|
|
||||||
today = dt.date.today()
|
|
||||||
window_start = int(today.strftime("%Y%m%d"))
|
|
||||||
window_end = int(
|
|
||||||
(today + dt.timedelta(days=GTFS_CALENDAR_LOOKAHEAD_DAYS)).strftime("%Y%m%d")
|
|
||||||
)
|
|
||||||
print("Validating London mode/line coverage...")
|
|
||||||
|
|
||||||
bods_active = _active_service_ids(bods_path, window_start, window_end)
|
|
||||||
nr_active = _active_service_ids(nr_path, window_start, window_end)
|
|
||||||
|
|
||||||
# BODS underground lines: agency 'London Underground (TfL)', route_type=1.
|
|
||||||
def lu_pred(row):
|
|
||||||
agency_id, route_type, short, _long = row
|
|
||||||
return (
|
|
||||||
short if route_type == "1" and short in LONDON_UNDERGROUND_LINES else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# DLR: metro/light-rail named DLR (agency 'London Docklands Light Railway').
|
|
||||||
def dlr_pred(row):
|
|
||||||
_agency_id, _route_type, short, long = row
|
|
||||||
text = f"{short} {long}".lower()
|
|
||||||
return "DLR" if ("dlr" in text or "docklands light" in text) else None
|
|
||||||
|
|
||||||
# London Tramlink tram service.
|
|
||||||
def tramlink_pred(row):
|
|
||||||
_agency_id, route_type, short, long = row
|
|
||||||
text = f"{short} {long}".lower()
|
|
||||||
return "Tramlink" if (route_type == "0" and "tram" in text) else None
|
|
||||||
|
|
||||||
lu_present = _lines_with_active_service(
|
|
||||||
bods_path, bods_active, route_predicate=lu_pred
|
|
||||||
)
|
|
||||||
dlr_present = _lines_with_active_service(
|
|
||||||
bods_path, bods_active, route_predicate=dlr_pred
|
|
||||||
)
|
|
||||||
tramlink_present = _lines_with_active_service(
|
|
||||||
bods_path, bods_active, route_predicate=tramlink_pred
|
|
||||||
)
|
|
||||||
|
|
||||||
# National Rail: Elizabeth line (agency_id XR), Overground (agency_id LO).
|
|
||||||
def nr_agency_pred(target_id, label):
|
|
||||||
def pred(row):
|
|
||||||
agency_id, _route_type, _short, _long = row
|
|
||||||
return label if agency_id == target_id else None
|
|
||||||
|
|
||||||
return pred
|
|
||||||
|
|
||||||
elizabeth_present = _lines_with_active_service(
|
|
||||||
nr_path, nr_active, route_predicate=nr_agency_pred("XR", "Elizabeth line")
|
|
||||||
)
|
|
||||||
overground_present = _lines_with_active_service(
|
|
||||||
nr_path, nr_active, route_predicate=nr_agency_pred("LO", "London Overground")
|
|
||||||
)
|
|
||||||
|
|
||||||
problems: list[str] = []
|
|
||||||
missing_lu = [line for line in LONDON_UNDERGROUND_LINES if line not in lu_present]
|
|
||||||
if missing_lu:
|
|
||||||
problems.append(
|
|
||||||
"London Underground lines missing/without active service: "
|
|
||||||
+ ", ".join(missing_lu)
|
|
||||||
)
|
|
||||||
if not dlr_present:
|
|
||||||
problems.append("DLR missing or without active service (BODS)")
|
|
||||||
if not tramlink_present:
|
|
||||||
problems.append("London Tramlink missing or without active service (BODS)")
|
|
||||||
if not elizabeth_present:
|
|
||||||
problems.append(
|
|
||||||
"Elizabeth line missing or without active service (National Rail, XR)"
|
|
||||||
)
|
|
||||||
if not overground_present:
|
|
||||||
problems.append(
|
|
||||||
"London Overground missing or without active service (National Rail, LO)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if problems:
|
|
||||||
raise RuntimeError(
|
|
||||||
"London coverage validation failed (window "
|
|
||||||
f"{window_start}-{window_end}):\n " + "\n ".join(problems)
|
|
||||||
)
|
|
||||||
print(
|
|
||||||
f" OK: {len(lu_present)}/{len(LONDON_UNDERGROUND_LINES)} Underground lines, "
|
|
||||||
"DLR, Tramlink, Elizabeth line and Overground all present with active service"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Download and prepare transit network data for R5 routing engine"
|
description="Download and prepare transit network data for R5 routing engine"
|
||||||
|
|
@ -1635,7 +1167,6 @@ def main() -> None:
|
||||||
bods_final = output_dir / "bods_gtfs.zip"
|
bods_final = output_dir / "bods_gtfs.zip"
|
||||||
convert_high_freq_to_frequency_based(bods_cleaned, bods_final)
|
convert_high_freq_to_frequency_based(bods_cleaned, bods_final)
|
||||||
validate_gtfs_feed(bods_final, "BODS GTFS")
|
validate_gtfs_feed(bods_final, "BODS GTFS")
|
||||||
validate_stop_geometry(bods_final, "BODS GTFS")
|
|
||||||
|
|
||||||
# 2. National Rail CIF → GTFS. Heavy rail is mandatory: trains are how people
|
# 2. National Rail CIF → GTFS. Heavy rail is mandatory: trains are how people
|
||||||
# reach the ~2,725 railway-station destinations, so a bus/metro-only network
|
# reach the ~2,725 railway-station destinations, so a bus/metro-only network
|
||||||
|
|
@ -1652,11 +1183,6 @@ def main() -> None:
|
||||||
)
|
)
|
||||||
nr_final = convert_national_rail_to_gtfs(raw_dir, output_dir)
|
nr_final = convert_national_rail_to_gtfs(raw_dir, output_dir)
|
||||||
validate_gtfs_feed(nr_final, "National Rail GTFS")
|
validate_gtfs_feed(nr_final, "National Rail GTFS")
|
||||||
validate_stop_geometry(nr_final, "National Rail GTFS")
|
|
||||||
|
|
||||||
# 3. Cross-feed check: every must-have London mode/line is present with
|
|
||||||
# active service. Catches a feed regression that silently drops a whole mode.
|
|
||||||
validate_london_coverage(bods_final, nr_final)
|
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
print()
|
print()
|
||||||
|
|
|
||||||
|
|
@ -1018,14 +1018,6 @@ _LISTING_OVERLAY_SOURCES: tuple[tuple[str, str, pl.DataType], ...] = (
|
||||||
("Listing date", "_actual_listing_date", pl.Datetime("us")),
|
("Listing date", "_actual_listing_date", pl.Datetime("us")),
|
||||||
("Listing status", "_actual_listing_status", pl.Utf8),
|
("Listing status", "_actual_listing_status", pl.Utf8),
|
||||||
("Listing features", "_actual_listing_features", pl.List(pl.Utf8)),
|
("Listing features", "_actual_listing_features", pl.List(pl.Utf8)),
|
||||||
# Accrued asking-price history (oldest -> newest) from the scraper's
|
|
||||||
# forward-only store (finder/price_history.py). Carried through untouched and
|
|
||||||
# surfaced verbatim in `_finalize_listings`.
|
|
||||||
(
|
|
||||||
"price_history",
|
|
||||||
"_actual_price_history",
|
|
||||||
pl.List(pl.Struct({"date": pl.Utf8, "price": pl.Int64, "reason": pl.Utf8})),
|
|
||||||
),
|
|
||||||
("Bedrooms", "_actual_bedrooms", pl.Int32),
|
("Bedrooms", "_actual_bedrooms", pl.Int32),
|
||||||
("Bathrooms", "_actual_bathrooms", pl.Int32),
|
("Bathrooms", "_actual_bathrooms", pl.Int32),
|
||||||
("Price qualifier", "_actual_price_qualifier", pl.Utf8),
|
("Price qualifier", "_actual_price_qualifier", pl.Utf8),
|
||||||
|
|
@ -1529,14 +1521,7 @@ def _load_listings_for_merge(listings_path: Path, arcgis_path: Path) -> pl.DataF
|
||||||
# treats NaN as distinct from null and the downstream `latest_price /
|
# treats NaN as distinct from null and the downstream `latest_price /
|
||||||
# total_floor_area` cast to Int32 explodes on a NaN, so we normalise floats
|
# total_floor_area` cast to Int32 explodes on a NaN, so we normalise floats
|
||||||
# to null at load time.
|
# to null at load time.
|
||||||
raw_columns = set(raw.collect_schema().names())
|
|
||||||
|
|
||||||
def _overlay_expr(src: str, dst: str, dtype: pl.DataType) -> pl.Expr:
|
def _overlay_expr(src: str, dst: str, dtype: pl.DataType) -> pl.Expr:
|
||||||
# A listings parquet written before a column existed (e.g. price_history)
|
|
||||||
# must not crash the merge: substitute a typed-null overlay so the rest of
|
|
||||||
# the pipeline sees a well-typed, empty column instead of ColumnNotFound.
|
|
||||||
if src not in raw_columns:
|
|
||||||
return pl.lit(None, dtype=dtype).alias(dst)
|
|
||||||
expr = pl.col(src).cast(dtype, strict=False)
|
expr = pl.col(src).cast(dtype, strict=False)
|
||||||
if dtype in (pl.Float32, pl.Float64):
|
if dtype in (pl.Float32, pl.Float64):
|
||||||
expr = expr.fill_nan(None)
|
expr = expr.fill_nan(None)
|
||||||
|
|
@ -2211,7 +2196,6 @@ def _finalize_listings(df: pl.DataFrame) -> pl.DataFrame:
|
||||||
pl.col("_actual_listing_date").alias("Listing date"),
|
pl.col("_actual_listing_date").alias("Listing date"),
|
||||||
pl.col("_actual_listing_status").alias("Listing status"),
|
pl.col("_actual_listing_status").alias("Listing status"),
|
||||||
pl.col("_actual_listing_features").alias("Listing features"),
|
pl.col("_actual_listing_features").alias("Listing features"),
|
||||||
pl.col("_actual_price_history").alias("price_history"),
|
|
||||||
pl.col("_actual_asking_price").alias("Asking price"),
|
pl.col("_actual_asking_price").alias("Asking price"),
|
||||||
pl.col("_actual_asking_price_per_sqm").alias("Asking price per sqm"),
|
pl.col("_actual_asking_price_per_sqm").alias("Asking price per sqm"),
|
||||||
pl.col("_actual_bedrooms").alias("Bedrooms"),
|
pl.col("_actual_bedrooms").alias("Bedrooms"),
|
||||||
|
|
|
||||||
|
|
@ -56,22 +56,6 @@ DYNAMIC_FILTER_ALL_GROUPS = {"Public Transport", "Leisure", "Health"}
|
||||||
DYNAMIC_FILTER_COUNT_THRESHOLD_GROUPS = {"Groceries"}
|
DYNAMIC_FILTER_COUNT_THRESHOLD_GROUPS = {"Groceries"}
|
||||||
DYNAMIC_FILTER_EXCLUDED_CATEGORIES = {"Park"}
|
DYNAMIC_FILTER_EXCLUDED_CATEGORIES = {"Park"}
|
||||||
|
|
||||||
# Combined "nearest of any rail mode" distance: the distance to the closest of a
|
|
||||||
# Rail station, Tube station, DLR station, or Tram & Metro stop. Materialized as
|
|
||||||
# its own real column so the server loads it like any other per-category
|
|
||||||
# distance. min_distance_per_postcode already supports multi-category groups, so
|
|
||||||
# this is a genuine nearest-of-the-union query (identical to the per-mode
|
|
||||||
# minimum). Distance only: a combined "within Xkm" count is not meaningful, so
|
|
||||||
# it is intentionally omitted from the count groups.
|
|
||||||
COMBINED_STATION_GROUP_KEY = "poi_any_station"
|
|
||||||
COMBINED_STATION_DISPLAY_NAME = "Any station"
|
|
||||||
COMBINED_STATION_SOURCE_CATEGORIES = [
|
|
||||||
"Rail station",
|
|
||||||
"Tube station",
|
|
||||||
"DLR station",
|
|
||||||
"Tram & Metro stop",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _poi_category_slug(category: str) -> str:
|
def _poi_category_slug(category: str) -> str:
|
||||||
ascii_text = (
|
ascii_text = (
|
||||||
|
|
@ -243,21 +227,10 @@ def main():
|
||||||
dynamic_counts_5km = count_pois_per_postcode(
|
dynamic_counts_5km = count_pois_per_postcode(
|
||||||
postcodes, pois, groups=poi_category_groups, radius_km=5
|
postcodes, pois, groups=poi_category_groups, radius_km=5
|
||||||
)
|
)
|
||||||
# Distances additionally include the combined "Any station" group (nearest of
|
|
||||||
# any rail mode). It is distance-only, so it is added here but not to the
|
|
||||||
# 2km/5km count groups above.
|
|
||||||
distance_groups = {
|
|
||||||
**poi_category_groups,
|
|
||||||
COMBINED_STATION_GROUP_KEY: COMBINED_STATION_SOURCE_CATEGORIES,
|
|
||||||
}
|
|
||||||
distance_display_names = {
|
|
||||||
**poi_display_names,
|
|
||||||
COMBINED_STATION_GROUP_KEY: COMBINED_STATION_DISPLAY_NAME,
|
|
||||||
}
|
|
||||||
dynamic_distances = min_distance_per_postcode(
|
dynamic_distances = min_distance_per_postcode(
|
||||||
postcodes, pois, groups=distance_groups
|
postcodes, pois, groups=poi_category_groups
|
||||||
)
|
)
|
||||||
dynamic_renames = _dynamic_poi_metric_renames(distance_display_names)
|
dynamic_renames = _dynamic_poi_metric_renames(poi_display_names)
|
||||||
dynamic_counts_2km = dynamic_counts_2km.rename(
|
dynamic_counts_2km = dynamic_counts_2km.rename(
|
||||||
{k: v for k, v in dynamic_renames.items() if k in dynamic_counts_2km.columns}
|
{k: v for k, v in dynamic_renames.items() if k in dynamic_counts_2km.columns}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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