fine
This commit is contained in:
parent
6df2812a4e
commit
9e4e65fa2a
35 changed files with 1172 additions and 70 deletions
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-snapshot2
|
property-data-snapshot*
|
||||||
video/.audit*
|
video/.audit*
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ services:
|
||||||
- cargo-home:/usr/local/cargo
|
- cargo-home:/usr/local/cargo
|
||||||
- cargo-target:/app/server-rs/target
|
- cargo-target:/app/server-rs/target
|
||||||
environment:
|
environment:
|
||||||
|
# Disable incremental compilation. cargo-watch does incremental
|
||||||
|
# rebuilds over the mounted source; editing a struct with a generic
|
||||||
|
# serde impl mid-flight can corrupt the incremental cache and produce
|
||||||
|
# `rust-lld: undefined hidden symbol ...::serialize` link failures
|
||||||
|
# that never resolve until a clean rebuild. Off = slightly slower
|
||||||
|
# warm rebuilds, but no such corruption.
|
||||||
|
CARGO_INCREMENTAL: "0"
|
||||||
# Fallback only: the binary uses jemalloc as its global allocator
|
# Fallback only: the binary uses jemalloc as its global allocator
|
||||||
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
|
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
|
||||||
MALLOC_ARENA_MAX: "2"
|
MALLOC_ARENA_MAX: "2"
|
||||||
|
|
|
||||||
120
finder/price_history.py
Normal file
120
finder/price_history.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"""Forward-only asking-price history, accrued across recurring scrape runs.
|
||||||
|
|
||||||
|
Rightmove (and the other portals) never expose a listing's full asking-price
|
||||||
|
timeline: a detail page carries only the current price plus one most-recent
|
||||||
|
"Reduced on <date>" event, and the previous price is never published. The only
|
||||||
|
way to obtain a real "listed at X, reduced to Y" series is therefore to record
|
||||||
|
each listing's price ourselves every run and diff it over time.
|
||||||
|
|
||||||
|
This module keeps a persistent, listing-id-keyed store of price observations:
|
||||||
|
|
||||||
|
{"<listing id>": [{"date": "YYYY-MM-DD", "price": 425000, "reason": "listed"},
|
||||||
|
{"date": "YYYY-MM-DD", "price": 410000, "reason": "reduced"}]}
|
||||||
|
|
||||||
|
Entries are oldest -> newest. A new point is appended only when the price
|
||||||
|
actually changes (or on first sight), so an unchanged listing costs nothing. The
|
||||||
|
store is seeded from / dumped to disk with the same atomic JSON helpers as the
|
||||||
|
detail-postcode caches (see postcode_cache.py), so a recurring scrape extends the
|
||||||
|
history rather than rebuilding it.
|
||||||
|
|
||||||
|
Two hard limitations, both inherent to the data source:
|
||||||
|
* There is NO backfill. History accrues only from the first instrumented run;
|
||||||
|
prior asking prices cannot be reconstructed (portals don't publish them and
|
||||||
|
we kept no snapshots).
|
||||||
|
* On a listing's first sight we know exactly one price, so we record one point.
|
||||||
|
If the portal's own most-recent event says that price was itself a reduction/
|
||||||
|
increase, we date and label that single point accordingly; we still cannot
|
||||||
|
invent the pre-change price.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from postcode_cache import load_cache, save_cache
|
||||||
|
|
||||||
|
log = logging.getLogger("rightmove")
|
||||||
|
|
||||||
|
# Portal `listingUpdateReason` codes -> our canonical reasons. Anything not a
|
||||||
|
# recognised price move (e.g. "new", "under_offer", "auction", or absent) leaves
|
||||||
|
# the first-sight point labelled "listed" and never fabricates a change.
|
||||||
|
_REASON_MAP = {
|
||||||
|
"price_reduced": "reduced",
|
||||||
|
"price_increased": "increased",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ISO_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_reason(raw: object) -> str | None:
|
||||||
|
"""Map a portal update-reason code to 'reduced'/'increased', else None."""
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
return _REASON_MAP.get(raw.strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_to_date(value: object) -> str | None:
|
||||||
|
"""Return the YYYY-MM-DD prefix of an ISO timestamp, or None."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
match = _ISO_DATE_RE.match(value.strip())
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def load_history(path: str | Path) -> dict:
|
||||||
|
"""Load the persisted asking-price history. Returns {} when absent/unreadable."""
|
||||||
|
return load_cache(path)
|
||||||
|
|
||||||
|
|
||||||
|
def save_history(path: str | Path, history: dict) -> None:
|
||||||
|
"""Atomically persist the asking-price history to disk."""
|
||||||
|
save_cache(path, history)
|
||||||
|
|
||||||
|
|
||||||
|
def update_history(history: dict, listings: list[dict], run_date: str) -> None:
|
||||||
|
"""Fold one run's listings into ``history`` in place.
|
||||||
|
|
||||||
|
``run_date`` is the ISO date (YYYY-MM-DD) of the scrape. For each listing with
|
||||||
|
a stable id and a positive price:
|
||||||
|
|
||||||
|
* first sight -> append one point. Its date/reason come from the portal's
|
||||||
|
most-recent change event when that event is a price move (so a listing we
|
||||||
|
first meet already-reduced reads "Reduced on <event date>"); otherwise the
|
||||||
|
point is the "listed" price dated to ``first_visible_date`` (falling back
|
||||||
|
to ``run_date``).
|
||||||
|
* later runs -> append a point ONLY when the price differs from the last
|
||||||
|
recorded one, labelled "reduced"/"increased" by direction and dated to
|
||||||
|
``run_date`` (we only know the change happened by this run).
|
||||||
|
|
||||||
|
An unchanged price is a no-op, so the series stays a list of genuine moves.
|
||||||
|
"""
|
||||||
|
for listing in listings:
|
||||||
|
listing_id_raw = listing.get("id")
|
||||||
|
if listing_id_raw is None:
|
||||||
|
continue
|
||||||
|
listing_id = str(listing_id_raw).strip()
|
||||||
|
if not listing_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
price = int(listing.get("price") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if price <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entries = history.get(listing_id)
|
||||||
|
if not isinstance(entries, list) or not entries:
|
||||||
|
reason = normalize_reason(listing.get("listing_update_reason"))
|
||||||
|
if reason is not None:
|
||||||
|
date = _iso_to_date(listing.get("listing_update_date")) or run_date
|
||||||
|
else:
|
||||||
|
reason = "listed"
|
||||||
|
date = _iso_to_date(listing.get("first_visible_date")) or run_date
|
||||||
|
history[listing_id] = [{"date": date, "price": price, "reason": reason}]
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_price = entries[-1].get("price")
|
||||||
|
if last_price == price:
|
||||||
|
continue
|
||||||
|
reason = "reduced" if last_price is not None and price < last_price else "increased"
|
||||||
|
entries.append({"date": run_date, "price": price, "reason": reason})
|
||||||
|
|
@ -5,6 +5,7 @@ import signal
|
||||||
import time
|
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
|
||||||
|
|
@ -34,6 +35,7 @@ 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
|
||||||
|
|
@ -839,7 +841,18 @@ 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:
|
||||||
write_parquet(merged, output_path)
|
# Accrue the per-listing asking-price history before writing: load the
|
||||||
|
# persistent store, append this run's price moves, dump it, then embed
|
||||||
|
# each listing's series in the parquet. Forward-only by nature — the
|
||||||
|
# first run seeds one point per listing and reductions appear over time.
|
||||||
|
history_path = output_base / "price_history" / "listings.json"
|
||||||
|
history = load_history(history_path)
|
||||||
|
run_date = (
|
||||||
|
datetime.fromtimestamp(started_at, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
)
|
||||||
|
update_history(history, merged, run_date)
|
||||||
|
save_history(history_path, history)
|
||||||
|
write_parquet(merged, output_path, price_history=history)
|
||||||
else:
|
else:
|
||||||
if output_path.exists():
|
if output_path.exists():
|
||||||
output_path.unlink()
|
output_path.unlink()
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,16 @@ from transform import map_property_type, normalize_postcode
|
||||||
log = logging.getLogger("rightmove")
|
log = logging.getLogger("rightmove")
|
||||||
|
|
||||||
|
|
||||||
def write_parquet(properties: list[dict], path: Path) -> None:
|
def write_parquet(
|
||||||
"""Write sale properties list to parquet with server-ready column names."""
|
properties: list[dict], path: Path, price_history: dict | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Write sale properties list to parquet with server-ready column names.
|
||||||
|
|
||||||
|
``price_history`` is the persistent listing-id -> [{date, price, reason}]
|
||||||
|
store (see finder/price_history.py); each listing's accrued asking-price
|
||||||
|
series is embedded as a ``price_history`` list column. Absent id -> empty
|
||||||
|
list, so pre-instrumentation runs and tests simply write empty series.
|
||||||
|
"""
|
||||||
if not properties:
|
if not properties:
|
||||||
log.warning("No properties to write to %s", path)
|
log.warning("No properties to write to %s", path)
|
||||||
return
|
return
|
||||||
|
|
@ -95,6 +103,14 @@ def write_parquet(properties: list[dict], path: Path) -> None:
|
||||||
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
|
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
|
||||||
listing_statuses = ["For sale"] * len(properties)
|
listing_statuses = ["For sale"] * len(properties)
|
||||||
|
|
||||||
|
# Accrued asking-price history per listing (oldest -> newest). Look up with
|
||||||
|
# the SAME normalisation the store keys on (str(id).strip(), matching
|
||||||
|
# price_history.update_history and scraper dedup); an unseen id -> empty.
|
||||||
|
history_lookup = price_history or {}
|
||||||
|
price_history_col = [
|
||||||
|
history_lookup.get(str(p.get("id")).strip(), []) for p in properties
|
||||||
|
]
|
||||||
|
|
||||||
df = pl.DataFrame(
|
df = pl.DataFrame(
|
||||||
{
|
{
|
||||||
"Bedrooms": [p["Bedrooms"] for p in properties],
|
"Bedrooms": [p["Bedrooms"] for p in properties],
|
||||||
|
|
@ -144,6 +160,7 @@ def write_parquet(properties: list[dict], path: Path) -> None:
|
||||||
"Listing date": listing_dates,
|
"Listing date": listing_dates,
|
||||||
"Listing status": listing_statuses,
|
"Listing status": listing_statuses,
|
||||||
"Asking price": asking_prices,
|
"Asking price": asking_prices,
|
||||||
|
"price_history": price_history_col,
|
||||||
},
|
},
|
||||||
schema={
|
schema={
|
||||||
"Bedrooms": pl.Int32,
|
"Bedrooms": pl.Int32,
|
||||||
|
|
@ -169,6 +186,11 @@ def write_parquet(properties: list[dict], path: Path) -> None:
|
||||||
"Listing date": pl.Datetime("us"),
|
"Listing date": pl.Datetime("us"),
|
||||||
"Listing status": pl.Utf8,
|
"Listing status": pl.Utf8,
|
||||||
"Asking price": pl.Int64,
|
"Asking price": pl.Int64,
|
||||||
|
"price_history": pl.List(
|
||||||
|
pl.Struct(
|
||||||
|
{"date": pl.Utf8, "price": pl.Int64, "reason": pl.Utf8}
|
||||||
|
)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
77
finder/test_price_history.py
Normal file
77
finder/test_price_history.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""Tests for the forward-only asking-price history store (price_history.py)."""
|
||||||
|
|
||||||
|
from price_history import normalize_reason, update_history
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_sight_new_listing_records_listed_at_first_visible_date() -> None:
|
||||||
|
history: dict = {}
|
||||||
|
update_history(
|
||||||
|
history,
|
||||||
|
[{"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}],
|
||||||
|
"2026-07-12",
|
||||||
|
)
|
||||||
|
assert history["A"] == [{"date": "2026-07-01", "price": 500000, "reason": "listed"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_sight_already_reduced_uses_event_date_and_reason() -> None:
|
||||||
|
history: dict = {}
|
||||||
|
update_history(
|
||||||
|
history,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "B",
|
||||||
|
"price": 425000,
|
||||||
|
"first_visible_date": "2026-06-01T09:00:00Z",
|
||||||
|
"listing_update_reason": "price_reduced",
|
||||||
|
"listing_update_date": "2026-07-10T14:00:00Z",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"2026-07-12",
|
||||||
|
)
|
||||||
|
assert history["B"] == [{"date": "2026-07-10", "price": 425000, "reason": "reduced"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_later_run_appends_only_on_price_change_with_direction() -> None:
|
||||||
|
history: dict = {}
|
||||||
|
listing = {"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}
|
||||||
|
update_history(history, [listing], "2026-07-12")
|
||||||
|
# Unchanged price -> no new point.
|
||||||
|
update_history(history, [{"id": "A", "price": 500000}], "2026-07-19")
|
||||||
|
assert len(history["A"]) == 1
|
||||||
|
# Reduced -> appended, dated to the run.
|
||||||
|
update_history(history, [{"id": "A", "price": 480000}], "2026-07-26")
|
||||||
|
# Increased -> appended.
|
||||||
|
update_history(history, [{"id": "A", "price": 490000}], "2026-08-02")
|
||||||
|
assert history["A"] == [
|
||||||
|
{"date": "2026-07-01", "price": 500000, "reason": "listed"},
|
||||||
|
{"date": "2026-07-26", "price": 480000, "reason": "reduced"},
|
||||||
|
{"date": "2026-08-02", "price": 490000, "reason": "increased"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_or_missing_price_and_id_are_skipped() -> None:
|
||||||
|
history: dict = {}
|
||||||
|
update_history(
|
||||||
|
history,
|
||||||
|
[
|
||||||
|
{"id": "POA", "price": 0},
|
||||||
|
{"id": "", "price": 100000},
|
||||||
|
{"price": 100000}, # no id
|
||||||
|
],
|
||||||
|
"2026-07-12",
|
||||||
|
)
|
||||||
|
assert history == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_date_fallback_when_dates_absent() -> None:
|
||||||
|
history: dict = {}
|
||||||
|
update_history(history, [{"id": "A", "price": 300000}], "2026-07-12")
|
||||||
|
assert history["A"] == [{"date": "2026-07-12", "price": 300000, "reason": "listed"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_reason_maps_only_price_moves() -> None:
|
||||||
|
assert normalize_reason("price_reduced") == "reduced"
|
||||||
|
assert normalize_reason("price_increased") == "increased"
|
||||||
|
assert normalize_reason("new") is None
|
||||||
|
assert normalize_reason("under_offer") is None
|
||||||
|
assert normalize_reason(None) is None
|
||||||
|
|
@ -125,7 +125,9 @@ def test_run_scrape_runs_all_sources_merges_and_persists_caches(tmp_path, monkey
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
scraper,
|
scraper,
|
||||||
"write_parquet",
|
"write_parquet",
|
||||||
lambda props, path: written.update(count=len(props), path=path),
|
lambda props, path, price_history=None: written.update(
|
||||||
|
count=len(props), path=path, price_history=price_history
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
result = scraper.run_scrape(
|
result = scraper.run_scrape(
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,16 @@ def transform_property(
|
||||||
if not listing_id:
|
if not listing_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# The search API already carries the single most-recent price-change event
|
||||||
|
# (reason + ISO date) with no extra request. Rightmove never exposes the
|
||||||
|
# previous price, so this only seeds the accruing asking-price history (see
|
||||||
|
# finder/price_history.py); it is not written to the output parquet itself.
|
||||||
|
listing_update = prop.get("listingUpdate")
|
||||||
|
if not isinstance(listing_update, dict):
|
||||||
|
listing_update = {}
|
||||||
|
listing_update_reason = listing_update.get("listingUpdateReason")
|
||||||
|
listing_update_date = listing_update.get("listingUpdateDate")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": listing_id,
|
"id": listing_id,
|
||||||
"Bedrooms": bedrooms,
|
"Bedrooms": bedrooms,
|
||||||
|
|
@ -411,4 +421,7 @@ def transform_property(
|
||||||
"Listing URL": build_listing_url(property_url, bool(prop.get("development"))),
|
"Listing URL": build_listing_url(property_url, bool(prop.get("development"))),
|
||||||
"Listing features": key_features,
|
"Listing features": key_features,
|
||||||
"first_visible_date": prop.get("firstVisibleDate", ""),
|
"first_visible_date": prop.get("firstVisibleDate", ""),
|
||||||
|
# Fed into the asking-price history store, not the parquet directly.
|
||||||
|
"listing_update_reason": listing_update_reason,
|
||||||
|
"listing_update_date": listing_update_date,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
||||||
import { trackEvent } from './lib/analytics';
|
import { trackEvent } from './lib/analytics';
|
||||||
import { parseUrlState } from './lib/url-state';
|
import { parseUrlState } from './lib/url-state';
|
||||||
import pb from './lib/pocketbase';
|
import pb from './lib/pocketbase';
|
||||||
import { DEFAULT_DEMO_FILTERS, INITIAL_VIEW_STATE } from './lib/consts';
|
import { DEFAULT_DEMO_FILTERS, DEFAULT_DEMO_TRAVEL, INITIAL_VIEW_STATE } from './lib/consts';
|
||||||
import { useTheme } from './hooks/useTheme';
|
import { useTheme } from './hooks/useTheme';
|
||||||
import { useIsMobile } from './hooks/useIsMobile';
|
import { useIsMobile } from './hooks/useIsMobile';
|
||||||
import { useAuth } from './hooks/useAuth';
|
import { useAuth } from './hooks/useAuth';
|
||||||
|
|
@ -38,6 +38,7 @@ const LearnPage = lazy(() => import('./components/learn/LearnPage'));
|
||||||
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
|
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
|
||||||
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
|
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
|
||||||
const AccountPage = lazy(() => import('./components/account/AccountPage'));
|
const AccountPage = lazy(() => import('./components/account/AccountPage'));
|
||||||
|
const ResetPasswordPage = lazy(() => import('./components/account/ResetPasswordPage'));
|
||||||
const SavedPage = lazy(() =>
|
const SavedPage = lazy(() =>
|
||||||
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
||||||
);
|
);
|
||||||
|
|
@ -160,6 +161,8 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
||||||
return '/saved';
|
return '/saved';
|
||||||
case 'account':
|
case 'account':
|
||||||
return '/account';
|
return '/account';
|
||||||
|
case 'reset-password':
|
||||||
|
return '/reset-password';
|
||||||
case 'invite':
|
case 'invite':
|
||||||
if (!inviteCode) {
|
if (!inviteCode) {
|
||||||
throw new Error('Cannot build invite path without an invite code');
|
throw new Error('Cannot build invite path without an invite code');
|
||||||
|
|
@ -183,6 +186,7 @@ function pathToPage(rawPathname: string): RouteMatch | null {
|
||||||
const seoContentPage = getSeoContentPage(pathname);
|
const seoContentPage = getSeoContentPage(pathname);
|
||||||
if (seoContentPage) return { page: seoContentPage };
|
if (seoContentPage) return { page: seoContentPage };
|
||||||
if (pathname === '/account') return { page: 'account' };
|
if (pathname === '/account') return { page: 'account' };
|
||||||
|
if (pathname === '/reset-password') return { page: 'reset-password' };
|
||||||
if (pathname === '/support') return { page: 'learn' };
|
if (pathname === '/support') return { page: 'learn' };
|
||||||
if (pathname === '/terms') return { page: 'terms' };
|
if (pathname === '/terms') return { page: 'terms' };
|
||||||
if (pathname === '/privacy') return { page: 'privacy' };
|
if (pathname === '/privacy') return { page: 'privacy' };
|
||||||
|
|
@ -234,15 +238,26 @@ export default function App() {
|
||||||
return params.get('og') === '1';
|
return params.get('og') === '1';
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Funnel fix: pre-seed high-intent filters on a cold (empty) map open so first-time visitors
|
// Funnel fix: pre-seed high-intent defaults on a cold map open so first-time visitors immediately
|
||||||
// immediately see value and are one filter from the demo cap. Deep links (OG screenshots, the
|
// see value and are one filter from the demo cap. "Cold" means the URL carries neither filters nor
|
||||||
// SEO landing-page CTAs) already carry filters, so they're left as-is. See DEFAULT_DEMO_FILTERS.
|
// a travel time; a deep link (OG screenshot, SEO landing-page CTA) sets at least one of those and
|
||||||
const initialMapFilters = useMemo(() => {
|
// is left as-is, as is the non-interactive screenshot/OG render. See DEFAULT_DEMO_FILTERS.
|
||||||
if (isScreenshotMode || isOgMode) return mapUrlState.filters;
|
const isColdMapOpen = useMemo(() => {
|
||||||
return Object.keys(mapUrlState.filters).length === 0
|
if (isScreenshotMode || isOgMode) return false;
|
||||||
? { ...DEFAULT_DEMO_FILTERS }
|
const hasFilters = Object.keys(mapUrlState.filters).length > 0;
|
||||||
: mapUrlState.filters;
|
const hasTravel = (mapUrlState.travelTime?.entries?.length ?? 0) > 0;
|
||||||
}, [mapUrlState.filters, isScreenshotMode, isOgMode]);
|
return !hasFilters && !hasTravel;
|
||||||
|
}, [mapUrlState.filters, mapUrlState.travelTime, isScreenshotMode, isOgMode]);
|
||||||
|
|
||||||
|
const initialMapFilters = useMemo(
|
||||||
|
() => (isColdMapOpen ? { ...DEFAULT_DEMO_FILTERS } : mapUrlState.filters),
|
||||||
|
[isColdMapOpen, mapUrlState.filters]
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialMapTravelTime = useMemo(
|
||||||
|
() => (isColdMapOpen ? DEFAULT_DEMO_TRAVEL : mapUrlState.travelTime),
|
||||||
|
[isColdMapOpen, mapUrlState.travelTime]
|
||||||
|
);
|
||||||
|
|
||||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||||
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
||||||
|
|
@ -283,6 +298,7 @@ export default function App() {
|
||||||
loginWithOAuth,
|
loginWithOAuth,
|
||||||
logout,
|
logout,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
|
confirmPasswordReset,
|
||||||
refreshAuth,
|
refreshAuth,
|
||||||
clearError,
|
clearError,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
|
|
@ -708,6 +724,17 @@ export default function App() {
|
||||||
<LearnPage />
|
<LearnPage />
|
||||||
) : activePage === 'terms' || activePage === 'privacy' ? (
|
) : activePage === 'terms' || activePage === 'privacy' ? (
|
||||||
<LegalPage kind={activePage} />
|
<LegalPage kind={activePage} />
|
||||||
|
) : activePage === 'reset-password' ? (
|
||||||
|
<ResetPasswordPage
|
||||||
|
onConfirm={confirmPasswordReset}
|
||||||
|
onLoginClick={() => {
|
||||||
|
navigateTo('home');
|
||||||
|
openAuthModal('login');
|
||||||
|
}}
|
||||||
|
loading={authLoading}
|
||||||
|
error={authError}
|
||||||
|
onClearError={clearError}
|
||||||
|
/>
|
||||||
) : isSeoLandingPage(activePage) ? (
|
) : isSeoLandingPage(activePage) ? (
|
||||||
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
||||||
) : isSeoContentPage(activePage) ? (
|
) : isSeoContentPage(activePage) ? (
|
||||||
|
|
@ -769,7 +796,7 @@ export default function App() {
|
||||||
}}
|
}}
|
||||||
onDashboardReadyChange={setDashboardReady}
|
onDashboardReadyChange={setDashboardReady}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
initialTravelTime={mapUrlState.travelTime}
|
initialTravelTime={initialMapTravelTime}
|
||||||
initialPostcode={mapUrlState.postcode}
|
initialPostcode={mapUrlState.postcode}
|
||||||
shareCode={mapUrlState.share}
|
shareCode={mapUrlState.share}
|
||||||
onSessionRejected={() => {
|
onSessionRejected={() => {
|
||||||
|
|
|
||||||
53
frontend/src/components/account/ResetPasswordPage.test.tsx
Normal file
53
frontend/src/components/account/ResetPasswordPage.test.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import('react-i18next')>()),
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import ResetPasswordPage from './ResetPasswordPage';
|
||||||
|
|
||||||
|
function renderPage(search: string, onConfirm = vi.fn(async () => {})) {
|
||||||
|
window.history.replaceState({}, '', `/reset-password${search}`);
|
||||||
|
const onLoginClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<ResetPasswordPage
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onLoginClick={onLoginClick}
|
||||||
|
loading={false}
|
||||||
|
error={null}
|
||||||
|
onClearError={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
return { onConfirm, onLoginClick };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('ResetPasswordPage', () => {
|
||||||
|
it('shows an incomplete-link message and no form when the token is missing', () => {
|
||||||
|
const { onLoginClick } = renderPage('');
|
||||||
|
expect(screen.getByText('auth.resetMissingToken')).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText('auth.newPassword')).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'auth.goToLogin' }));
|
||||||
|
expect(onLoginClick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits the URL token plus the new password, then shows success', async () => {
|
||||||
|
const { onConfirm } = renderPage('?token=reset-token-123');
|
||||||
|
const input = screen.getByLabelText('auth.newPassword') as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: 'sup3rs3cret' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'auth.updatePassword' }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledWith('reset-token-123', 'sup3rs3cret');
|
||||||
|
expect(await screen.findByText('auth.resetSuccessBody')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles password visibility', () => {
|
||||||
|
renderPage('?token=abc');
|
||||||
|
const input = screen.getByLabelText('auth.newPassword') as HTMLInputElement;
|
||||||
|
expect(input.type).toBe('password');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'auth.showPassword' }));
|
||||||
|
expect(input.type).toBe('text');
|
||||||
|
});
|
||||||
|
});
|
||||||
137
frontend/src/components/account/ResetPasswordPage.tsx
Normal file
137
frontend/src/components/account/ResetPasswordPage.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { useCallback, useId, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||||
|
import { EyeOffIcon } from '../ui/icons/EyeOffIcon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing page for the PocketBase password-reset email link
|
||||||
|
* (`/reset-password?token=…`). Reads the one-time token from the URL, takes a new
|
||||||
|
* password, and calls `confirmPasswordReset`. PocketBase does not issue a session
|
||||||
|
* on confirm, so on success we point the user at the log in screen rather than
|
||||||
|
* auto-authenticating them.
|
||||||
|
*/
|
||||||
|
export default function ResetPasswordPage({
|
||||||
|
onConfirm,
|
||||||
|
onLoginClick,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onClearError,
|
||||||
|
}: {
|
||||||
|
onConfirm: (token: string, password: string) => Promise<void>;
|
||||||
|
onLoginClick: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onClearError: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const token = useMemo(
|
||||||
|
() => new URLSearchParams(window.location.search).get('token') ?? '',
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const fieldId = useId();
|
||||||
|
const passwordInputId = `${fieldId}-new-password`;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClearError();
|
||||||
|
try {
|
||||||
|
await onConfirm(token, password);
|
||||||
|
setDone(true);
|
||||||
|
} catch {
|
||||||
|
// Error surfaces through the `error` prop (set by useAuth).
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onConfirm, token, password, onClearError]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loginButtonClass =
|
||||||
|
'w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 transition-colors text-center';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-start justify-center bg-warm-50 dark:bg-navy-950 px-4 py-16">
|
||||||
|
<div className="w-full max-w-sm bg-white dark:bg-warm-900 rounded-lg shadow-sm border border-warm-200 dark:border-warm-700 p-6">
|
||||||
|
<h1 className="text-lg font-semibold text-navy-950 dark:text-white mb-4">
|
||||||
|
{t('auth.resetPassword')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{!token ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-warm-600 dark:text-warm-300">{t('auth.resetMissingToken')}</p>
|
||||||
|
<button type="button" onClick={onLoginClick} className={loginButtonClass}>
|
||||||
|
{t('auth.goToLogin')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : done ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-teal-700 dark:text-teal-400">{t('auth.resetSuccessBody')}</p>
|
||||||
|
<button type="button" onClick={onLoginClick} className={loginButtonClass}>
|
||||||
|
{t('auth.goToLogin')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor={passwordInputId}
|
||||||
|
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
|
||||||
|
>
|
||||||
|
{t('auth.newPassword')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id={passwordInputId}
|
||||||
|
name="new-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
autoFocus
|
||||||
|
className="w-full pl-3 pr-10 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||||
|
placeholder={t('auth.newPasswordPlaceholder')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||||
|
aria-pressed={showPassword}
|
||||||
|
title={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 outline-none focus-visible:text-teal-600 dark:focus-visible:text-teal-400"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOffIcon className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon filled={false} className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? t('auth.pleaseWait') : t('auth.updatePassword')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLoginClick}
|
||||||
|
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||||
|
>
|
||||||
|
{t('auth.backToLogin')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -143,11 +143,11 @@ export default memo(function AiFilterInput({
|
||||||
|
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
|
<div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded(true)}
|
onClick={() => setExpanded(true)}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group"
|
className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group"
|
||||||
>
|
>
|
||||||
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
|
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||||
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
|
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
|
||||||
|
|
@ -159,8 +159,8 @@ export default memo(function AiFilterInput({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters">
|
<div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
|
||||||
<div className="flex items-center gap-1.5 mb-1.5">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
|
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
|
||||||
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">
|
<span className="text-xs font-medium text-teal-700 dark:text-teal-300">
|
||||||
{t('aiFilter.aiSearch')}
|
{t('aiFilter.aiSearch')}
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ export function ActiveFiltersPanel({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapsed}
|
onClick={onToggleCollapsed}
|
||||||
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
|
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-2 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
|
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
|
||||||
|
|
@ -202,7 +202,7 @@ export function ActiveFiltersPanel({
|
||||||
onLoginRequired={onLoginRequired}
|
onLoginRequired={onLoginRequired}
|
||||||
/>
|
/>
|
||||||
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
|
||||||
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
|
<p className="px-3 py-1 text-xs text-warm-400 dark:text-warm-500">
|
||||||
{t('filters.addFiltersHint')}
|
{t('filters.addFiltersHint')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ export function AddFilterPanel({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapsed}
|
onClick={onToggleCollapsed}
|
||||||
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
|
className="shrink-0 flex items-center justify-between border-b border-teal-300 bg-teal-100 px-3 py-2 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
|
||||||
>
|
>
|
||||||
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
|
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
|
||||||
{t('filters.addFilter')}
|
{t('filters.addFilter')}
|
||||||
|
|
@ -179,7 +179,7 @@ export function AddFilterPanel({
|
||||||
{(!collapsed || !isLicensed) && (
|
{(!collapsed || !isLicensed) && (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto md:min-h-[12rem]">
|
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<FeatureBrowser
|
<FeatureBrowser
|
||||||
availableFeatures={availableFeatures}
|
availableFeatures={availableFeatures}
|
||||||
allFeatures={allFeatures}
|
allFeatures={allFeatures}
|
||||||
|
|
@ -197,7 +197,7 @@ export function AddFilterPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLicensed && (
|
{!isLicensed && (
|
||||||
<div className="mt-auto shrink-0 flex flex-col items-center gap-2 px-3 pt-3 pb-2.5 border-t border-warm-200 dark:border-warm-700">
|
<div className="mt-auto shrink-0 flex flex-col items-center gap-1.5 px-3 pt-2 pb-2 border-t border-warm-200 dark:border-warm-700">
|
||||||
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-snug">
|
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-snug">
|
||||||
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '}
|
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '}
|
||||||
<span className="text-xs text-warm-400 dark:text-warm-500">
|
<span className="text-xs text-warm-400 dark:text-warm-500">
|
||||||
|
|
@ -206,7 +206,7 @@ export function AddFilterPanel({
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick}
|
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick}
|
||||||
className="w-full px-5 py-2 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
className="w-full px-5 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
|
||||||
>
|
>
|
||||||
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
|
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export function EnumFeatureFilterCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-filter-name={feature.name}
|
data-filter-name={feature.name}
|
||||||
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
className={`space-y-0.5 px-2 py-1 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-1">
|
<div className="flex items-start justify-between gap-1">
|
||||||
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap />
|
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap />
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ export function EthnicityFilterCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-filter-name={ETHNICITIES_FILTER_NAME}
|
data-filter-name={ETHNICITIES_FILTER_NAME}
|
||||||
className={`space-y-1.5 px-2 py-1.5 rounded ${
|
className={`space-y-1 px-2 py-1 rounded ${
|
||||||
isActive
|
isActive
|
||||||
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
|
||||||
: isPinned
|
: isPinned
|
||||||
|
|
@ -147,7 +147,7 @@ export function EthnicityFilterCard({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
<label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
|
||||||
{t('filters.ethnicity')}
|
{t('filters.ethnicity')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,16 @@ function authRefreshInvalidated(error: unknown): boolean {
|
||||||
return status === 401 || status === 403;
|
return status === 401 || status === 403;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an email so login and registration are case- and
|
||||||
|
* whitespace-insensitive: ` John@Example.com` and `john@example.com`
|
||||||
|
* resolve to the same account. Applied at the PocketBase boundary rather
|
||||||
|
* than on the input field, so the user still sees exactly what they typed.
|
||||||
|
*/
|
||||||
|
function normalizeEmail(email: string): string {
|
||||||
|
return email.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [user, setUser] = useState<AuthUser | null>(() => {
|
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||||
|
|
@ -62,7 +72,9 @@ export function useAuth() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setErrorAction(null);
|
setErrorAction(null);
|
||||||
try {
|
try {
|
||||||
const result = await pb.collection('users').authWithPassword(email, password);
|
const result = await pb
|
||||||
|
.collection('users')
|
||||||
|
.authWithPassword(normalizeEmail(email), password);
|
||||||
setUser(recordToUser(result.record));
|
setUser(recordToUser(result.record));
|
||||||
trackEvent('Login', { method: 'email' });
|
trackEvent('Login', { method: 'email' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -82,12 +94,13 @@ export function useAuth() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setErrorAction(null);
|
setErrorAction(null);
|
||||||
|
const normalizedEmail = normalizeEmail(email);
|
||||||
try {
|
try {
|
||||||
// Step 1: create the account. A failure here means nothing was created,
|
// Step 1: create the account. A failure here means nothing was created,
|
||||||
// so surface a friendly, field-aware message (e.g. "email already exists").
|
// so surface a friendly, field-aware message (e.g. "email already exists").
|
||||||
try {
|
try {
|
||||||
await pb.collection('users').create({
|
await pb.collection('users').create({
|
||||||
email,
|
email: normalizedEmail,
|
||||||
password,
|
password,
|
||||||
passwordConfirm: password,
|
passwordConfirm: password,
|
||||||
newsletter: true,
|
newsletter: true,
|
||||||
|
|
@ -103,7 +116,7 @@ export function useAuth() {
|
||||||
// orphaned: tell the user it was created and let them log in with the
|
// orphaned: tell the user it was created and let them log in with the
|
||||||
// same credentials, instead of a misleading "registration failed".
|
// same credentials, instead of a misleading "registration failed".
|
||||||
try {
|
try {
|
||||||
const result = await pb.collection('users').authWithPassword(email, password);
|
const result = await pb.collection('users').authWithPassword(normalizedEmail, password);
|
||||||
setUser(recordToUser(result.record));
|
setUser(recordToUser(result.record));
|
||||||
trackEvent('Register');
|
trackEvent('Register');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -159,7 +172,7 @@ export function useAuth() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setErrorAction(null);
|
setErrorAction(null);
|
||||||
try {
|
try {
|
||||||
await pb.collection('users').requestPasswordReset(email);
|
await pb.collection('users').requestPasswordReset(normalizeEmail(email));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const { message, action } = resolveAuthError(err, 'reset', t);
|
const { message, action } = resolveAuthError(err, 'reset', t);
|
||||||
setError(message);
|
setError(message);
|
||||||
|
|
@ -172,6 +185,27 @@ export function useAuth() {
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const confirmPasswordReset = useCallback(
|
||||||
|
async (token: string, password: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setErrorAction(null);
|
||||||
|
try {
|
||||||
|
// PocketBase requires the password twice; the reset form uses a single
|
||||||
|
// (show/hide) field, so both arguments are the same value.
|
||||||
|
await pb.collection('users').confirmPasswordReset(token, password, password);
|
||||||
|
} catch (err) {
|
||||||
|
const { message, action } = resolveAuthError(err, 'confirmReset', t);
|
||||||
|
setError(message);
|
||||||
|
setErrorAction(action);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
const refreshAuth = useCallback(async () => {
|
const refreshAuth = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -208,6 +242,7 @@ export function useAuth() {
|
||||||
loginWithOAuth,
|
loginWithOAuth,
|
||||||
logout,
|
logout,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
|
confirmPasswordReset,
|
||||||
refreshAuth,
|
refreshAuth,
|
||||||
clearError,
|
clearError,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,16 @@ const de: Translations = {
|
||||||
registrationFailed: 'Registrierung fehlgeschlagen',
|
registrationFailed: 'Registrierung fehlgeschlagen',
|
||||||
oauthFailed: 'OAuth-Anmeldung fehlgeschlagen',
|
oauthFailed: 'OAuth-Anmeldung fehlgeschlagen',
|
||||||
passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts fehlgeschlagen',
|
passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts fehlgeschlagen',
|
||||||
|
newPassword: 'Neues Passwort',
|
||||||
|
newPasswordPlaceholder: 'Mindestens 8 Zeichen',
|
||||||
|
updatePassword: 'Passwort aktualisieren',
|
||||||
|
resetSuccessBody:
|
||||||
|
'Dein Passwort wurde geändert. Du kannst dich jetzt mit deinem neuen Passwort anmelden.',
|
||||||
|
resetMissingToken:
|
||||||
|
'Dieser Link zum Zurücksetzen ist unvollständig. Fordere über die Anmeldeseite einen neuen an.',
|
||||||
|
resetInvalidLink:
|
||||||
|
'Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Fordere über die Anmeldeseite einen neuen an.',
|
||||||
|
goToLogin: 'Zur Anmeldung',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
|
|
@ -953,6 +963,10 @@ const de: Translations = {
|
||||||
viewProperties: '{{count}} Immobilien ansehen',
|
viewProperties: '{{count}} Immobilien ansehen',
|
||||||
viewPropertiesShort: 'Immobilien ansehen',
|
viewPropertiesShort: 'Immobilien ansehen',
|
||||||
priceHistory: 'Preisentwicklung',
|
priceHistory: 'Preisentwicklung',
|
||||||
|
priceHistoryThisArea: 'Dieses Gebiet',
|
||||||
|
priceMetric: 'Preisbasis',
|
||||||
|
priceMetricTotal: 'Gesamt',
|
||||||
|
priceMetricPerSqm: 'Pro m²',
|
||||||
journeysFrom: 'Fahrzeiten für {{label}}',
|
journeysFrom: 'Fahrzeiten für {{label}}',
|
||||||
to: 'Nach {{destination}}',
|
to: 'Nach {{destination}}',
|
||||||
noJourneyData: 'Keine Verbindungsdaten verfügbar',
|
noJourneyData: 'Keine Verbindungsdaten verfügbar',
|
||||||
|
|
@ -1578,6 +1592,10 @@ const de: Translations = {
|
||||||
listing: 'Inserat',
|
listing: 'Inserat',
|
||||||
showingOf: '{{visible}} von {{count}} werden angezeigt',
|
showingOf: '{{visible}} von {{count}} werden angezeigt',
|
||||||
groupedNear: 'Gruppiert an dieser Kartenposition',
|
groupedNear: 'Gruppiert an dieser Kartenposition',
|
||||||
|
priceHistory: 'Preisverlauf',
|
||||||
|
priceListed: 'Gelistet',
|
||||||
|
priceReduced: 'Reduziert',
|
||||||
|
priceIncreased: 'Erhöht',
|
||||||
},
|
},
|
||||||
|
|
||||||
overlays: {
|
overlays: {
|
||||||
|
|
@ -1898,6 +1916,7 @@ const de: Translations = {
|
||||||
'Tube station': 'U-Bahn-Station',
|
'Tube station': 'U-Bahn-Station',
|
||||||
'DLR station': 'DLR-Station',
|
'DLR station': 'DLR-Station',
|
||||||
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
|
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
|
||||||
|
'Any station': 'Beliebige Station',
|
||||||
Café: 'Café',
|
Café: 'Café',
|
||||||
Restaurant: 'Restaurant',
|
Restaurant: 'Restaurant',
|
||||||
Pub: 'Pub',
|
Pub: 'Pub',
|
||||||
|
|
|
||||||
|
|
@ -633,6 +633,15 @@ const en = {
|
||||||
registrationFailed: 'Registration failed',
|
registrationFailed: 'Registration failed',
|
||||||
oauthFailed: 'OAuth login failed',
|
oauthFailed: 'OAuth login failed',
|
||||||
passwordResetFailed: 'Password reset request failed',
|
passwordResetFailed: 'Password reset request failed',
|
||||||
|
newPassword: 'New password',
|
||||||
|
newPasswordPlaceholder: 'At least 8 characters',
|
||||||
|
updatePassword: 'Update password',
|
||||||
|
resetSuccessBody: 'Your password has been changed. You can now log in with your new password.',
|
||||||
|
resetMissingToken:
|
||||||
|
'This password reset link is incomplete. Request a new one from the log in screen.',
|
||||||
|
resetInvalidLink:
|
||||||
|
'This reset link is invalid or has expired. Request a new one from the log in screen.',
|
||||||
|
goToLogin: 'Go to log in',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
|
|
@ -941,6 +950,10 @@ const en = {
|
||||||
viewProperties: 'View {{count}} properties',
|
viewProperties: 'View {{count}} properties',
|
||||||
viewPropertiesShort: 'View properties',
|
viewPropertiesShort: 'View properties',
|
||||||
priceHistory: 'Price History',
|
priceHistory: 'Price History',
|
||||||
|
priceHistoryThisArea: 'This area',
|
||||||
|
priceMetric: 'Price basis',
|
||||||
|
priceMetricTotal: 'Total',
|
||||||
|
priceMetricPerSqm: 'Per m²',
|
||||||
journeysFrom: 'Journey times for {{label}}',
|
journeysFrom: 'Journey times for {{label}}',
|
||||||
to: 'To {{destination}}',
|
to: 'To {{destination}}',
|
||||||
noJourneyData: 'No journey data available',
|
noJourneyData: 'No journey data available',
|
||||||
|
|
@ -1558,6 +1571,10 @@ const en = {
|
||||||
listing: 'Listing',
|
listing: 'Listing',
|
||||||
showingOf: 'Showing {{visible}} of {{count}}',
|
showingOf: 'Showing {{visible}} of {{count}}',
|
||||||
groupedNear: 'Grouped near this map position',
|
groupedNear: 'Grouped near this map position',
|
||||||
|
priceHistory: 'Price history',
|
||||||
|
priceListed: 'Listed',
|
||||||
|
priceReduced: 'Reduced',
|
||||||
|
priceIncreased: 'Increased',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Map Overlays Pane ──────────────────────────────
|
// ── Map Overlays Pane ──────────────────────────────
|
||||||
|
|
@ -1880,6 +1897,7 @@ const en = {
|
||||||
'Tube station': 'Tube station',
|
'Tube station': 'Tube station',
|
||||||
'DLR station': 'DLR station',
|
'DLR station': 'DLR station',
|
||||||
'Tram & Metro stop': 'Tram & Metro stop',
|
'Tram & Metro stop': 'Tram & Metro stop',
|
||||||
|
'Any station': 'Any station',
|
||||||
Café: 'Café',
|
Café: 'Café',
|
||||||
Restaurant: 'Restaurant',
|
Restaurant: 'Restaurant',
|
||||||
Pub: 'Pub',
|
Pub: 'Pub',
|
||||||
|
|
|
||||||
|
|
@ -657,6 +657,16 @@ 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 ──────────────────────────────────
|
||||||
|
|
@ -963,6 +973,10 @@ 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',
|
||||||
|
|
@ -1595,6 +1609,10 @@ 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 ───────────────────────────────────────
|
||||||
|
|
@ -1918,6 +1936,7 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -630,6 +630,13 @@ const hi: Translations = {
|
||||||
registrationFailed: 'पंजीकरण विफल रहा',
|
registrationFailed: 'पंजीकरण विफल रहा',
|
||||||
oauthFailed: 'OAuth लॉग इन विफल रहा',
|
oauthFailed: 'OAuth लॉग इन विफल रहा',
|
||||||
passwordResetFailed: 'पासवर्ड रीसेट अनुरोध विफल रहा',
|
passwordResetFailed: 'पासवर्ड रीसेट अनुरोध विफल रहा',
|
||||||
|
newPassword: 'नया पासवर्ड',
|
||||||
|
newPasswordPlaceholder: 'कम से कम 8 अक्षर',
|
||||||
|
updatePassword: 'पासवर्ड अपडेट करें',
|
||||||
|
resetSuccessBody: 'आपका पासवर्ड बदल दिया गया है। अब आप अपने नए पासवर्ड से लॉग इन कर सकते हैं।',
|
||||||
|
resetMissingToken: 'यह रीसेट लिंक अधूरा है। लॉग इन स्क्रीन से नया लिंक प्राप्त करें।',
|
||||||
|
resetInvalidLink: 'यह रीसेट लिंक अमान्य है या समाप्त हो चुका है। लॉग इन स्क्रीन से नया लिंक प्राप्त करें।',
|
||||||
|
goToLogin: 'लॉग इन पर जाएँ',
|
||||||
},
|
},
|
||||||
|
|
||||||
upgrade: {
|
upgrade: {
|
||||||
|
|
@ -921,6 +928,10 @@ 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: 'कोई यात्रा डेटा उपलब्ध नहीं',
|
||||||
|
|
@ -1513,6 +1524,10 @@ const hi: Translations = {
|
||||||
listing: 'लिस्टिंग',
|
listing: 'लिस्टिंग',
|
||||||
showingOf: '{{count}} में से {{visible}} दिखाई जा रही हैं',
|
showingOf: '{{count}} में से {{visible}} दिखाई जा रही हैं',
|
||||||
groupedNear: 'इस मैप स्थिति के पास समूहीकृत',
|
groupedNear: 'इस मैप स्थिति के पास समूहीकृत',
|
||||||
|
priceHistory: 'मूल्य इतिहास',
|
||||||
|
priceListed: 'सूचीबद्ध',
|
||||||
|
priceReduced: 'घटाया गया',
|
||||||
|
priceIncreased: 'बढ़ाया गया',
|
||||||
},
|
},
|
||||||
|
|
||||||
overlays: {
|
overlays: {
|
||||||
|
|
@ -1802,6 +1817,7 @@ 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: 'पब',
|
||||||
|
|
|
||||||
|
|
@ -648,6 +648,16 @@ 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 ──────────────────────────────────
|
||||||
|
|
@ -951,6 +961,10 @@ 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',
|
||||||
|
|
@ -1578,6 +1592,10 @@ 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 ───────────────────────────────────────
|
||||||
|
|
@ -1900,6 +1918,7 @@ 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',
|
||||||
|
|
|
||||||
|
|
@ -594,6 +594,13 @@ const zh: Translations = {
|
||||||
registrationFailed: '注册失败',
|
registrationFailed: '注册失败',
|
||||||
oauthFailed: 'OAuth 登录失败',
|
oauthFailed: 'OAuth 登录失败',
|
||||||
passwordResetFailed: '密码重置请求失败',
|
passwordResetFailed: '密码重置请求失败',
|
||||||
|
newPassword: '新密码',
|
||||||
|
newPasswordPlaceholder: '至少 8 个字符',
|
||||||
|
updatePassword: '更新密码',
|
||||||
|
resetSuccessBody: '您的密码已更改。现在可以使用新密码登录。',
|
||||||
|
resetMissingToken: '此重置链接不完整。请从登录界面重新获取。',
|
||||||
|
resetInvalidLink: '此重置链接无效或已过期。请从登录界面重新获取。',
|
||||||
|
goToLogin: '前往登录',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Upgrade Modal ──────────────────────────────────
|
// ── Upgrade Modal ──────────────────────────────────
|
||||||
|
|
@ -889,6 +896,10 @@ 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: '暂无出行数据',
|
||||||
|
|
@ -1494,6 +1505,10 @@ const zh: Translations = {
|
||||||
listing: '房源',
|
listing: '房源',
|
||||||
showingOf: '显示 {{count}} 套中的 {{visible}} 套',
|
showingOf: '显示 {{count}} 套中的 {{visible}} 套',
|
||||||
groupedNear: '按此地图位置聚合',
|
groupedNear: '按此地图位置聚合',
|
||||||
|
priceHistory: '价格记录',
|
||||||
|
priceListed: '上架',
|
||||||
|
priceReduced: '降价',
|
||||||
|
priceIncreased: '涨价',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Overlays ───────────────────────────────────────
|
// ── Overlays ───────────────────────────────────────
|
||||||
|
|
@ -1813,6 +1828,7 @@ 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';
|
export type AuthContext = 'login' | 'register' | 'oauth' | 'reset' | 'confirmReset';
|
||||||
|
|
||||||
export interface ResolvedAuthError {
|
export interface ResolvedAuthError {
|
||||||
/** User-facing message (already resolved to a string). */
|
/** User-facing message (already resolved to a string). */
|
||||||
|
|
@ -75,5 +75,17 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
frontend/src/lib/postcode.ts
Normal file
22
frontend/src/lib/postcode.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Postcode-component helpers, mirroring the server's `postcode_outcode` /
|
||||||
|
* `postcode_sector` (server-rs/src/utils.rs) so labels match the aggregations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Outward code of a postcode: the part before the space. "E14 2DG" -> "E14". */
|
||||||
|
export function postcodeOutcode(postcode: string): string | null {
|
||||||
|
const trimmed = postcode.trim().toUpperCase();
|
||||||
|
const space = trimmed.indexOf(' ');
|
||||||
|
return space > 0 ? trimmed.slice(0, space) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postcode sector: the outward code, the space, and the first character of the
|
||||||
|
* inward code. "E14 2DG" -> "E14 2".
|
||||||
|
*/
|
||||||
|
export function postcodeSector(postcode: string): string | null {
|
||||||
|
const trimmed = postcode.trim().toUpperCase();
|
||||||
|
const space = trimmed.indexOf(' ');
|
||||||
|
if (space <= 0 || space + 1 >= trimmed.length) return null;
|
||||||
|
return trimmed.slice(0, space + 2);
|
||||||
|
}
|
||||||
|
|
@ -1018,6 +1018,14 @@ _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),
|
||||||
|
|
@ -1521,7 +1529,14 @@ def _load_listings_for_merge(listings_path: Path, arcgis_path: Path) -> pl.DataF
|
||||||
# treats NaN as distinct from null and the downstream `latest_price /
|
# 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)
|
||||||
|
|
@ -2196,6 +2211,7 @@ 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"),
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ pub const PLACES_LIMIT: usize = 20;
|
||||||
/// feature name in `features.rs`.
|
/// feature name in `features.rs`.
|
||||||
pub const FORMER_COUNCIL_HOUSE_FEATURE: &str = "Former council house";
|
pub const FORMER_COUNCIL_HOUSE_FEATURE: &str = "Former council house";
|
||||||
pub const PRICE_HISTORY_POINTS_LIMIT: usize = 5000;
|
pub const PRICE_HISTORY_POINTS_LIMIT: usize = 5000;
|
||||||
|
/// Downsample cap for the wider-area (sector / outcode) price-history charts.
|
||||||
|
/// Lower than the selection's own cap because these render below it: three full
|
||||||
|
/// scatters at 5000 points each would flood the DOM.
|
||||||
|
pub const AREA_PRICE_HISTORY_POINTS_LIMIT: usize = 1500;
|
||||||
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
|
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;
|
||||||
|
|
||||||
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;
|
pub const AI_FILTERS_MAX_TOKENS: usize = 2000;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,16 @@ use crate::utils::{normalize_postcode, GridIndex, InternedColumn};
|
||||||
|
|
||||||
const GRID_CELL_SIZE: f32 = 0.01;
|
const GRID_CELL_SIZE: f32 = 0.01;
|
||||||
|
|
||||||
|
/// One observation on a listing's accruing asking-price timeline, recovered from
|
||||||
|
/// the scraper's forward-only store (finder/price_history.py). `reason` is one of
|
||||||
|
/// "listed" | "reduced" | "increased"; `date` is `YYYY-MM-DD`.
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
pub struct PriceHistoryPoint {
|
||||||
|
pub date: String,
|
||||||
|
pub price: i64,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone)]
|
||||||
pub struct ActualListing {
|
pub struct ActualListing {
|
||||||
pub lat: f32,
|
pub lat: f32,
|
||||||
|
|
@ -32,6 +42,10 @@ pub struct ActualListing {
|
||||||
pub listing_status: Option<String>,
|
pub listing_status: Option<String>,
|
||||||
pub listing_date_iso: Option<String>,
|
pub listing_date_iso: Option<String>,
|
||||||
pub features: Vec<String>,
|
pub features: Vec<String>,
|
||||||
|
/// Accrued asking-price observations, oldest -> newest. Empty until the
|
||||||
|
/// scraper's forward-only store has recorded at least one run for this id.
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub price_history: Vec<PriceHistoryPoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ActualListingData {
|
pub struct ActualListingData {
|
||||||
|
|
@ -54,6 +68,8 @@ pub struct ActualListingData {
|
||||||
pub listing_status: InternedColumn,
|
pub listing_status: InternedColumn,
|
||||||
pub listing_date_iso: Vec<Option<String>>,
|
pub listing_date_iso: Vec<Option<String>>,
|
||||||
pub features: Vec<Vec<String>>,
|
pub features: Vec<Vec<String>>,
|
||||||
|
/// Per-row accrued asking-price series (empty when absent from the parquet).
|
||||||
|
pub price_history: Vec<Vec<PriceHistoryPoint>>,
|
||||||
/// Row-major feature matrix aligned with PropertyData::feature_names.
|
/// Row-major feature matrix aligned with PropertyData::feature_names.
|
||||||
///
|
///
|
||||||
/// Rows start from a best-effort address/postcode join to the historical property
|
/// Rows start from a best-effort address/postcode join to the historical property
|
||||||
|
|
@ -102,6 +118,7 @@ impl ActualListingData {
|
||||||
let listing_status_raw = extract_opt_str(&df, "Listing status")?;
|
let listing_status_raw = extract_opt_str(&df, "Listing status")?;
|
||||||
let listing_date_iso = extract_opt_datetime_iso(&df, "Listing date")?;
|
let listing_date_iso = extract_opt_datetime_iso(&df, "Listing date")?;
|
||||||
let features = extract_str_list(&df, "Listing features")?;
|
let features = extract_str_list(&df, "Listing features")?;
|
||||||
|
let price_history = extract_price_history(&df)?;
|
||||||
|
|
||||||
let postcode: Vec<String> = postcode_raw.iter().map(|s| normalize_postcode(s)).collect();
|
let postcode: Vec<String> = postcode_raw.iter().map(|s| normalize_postcode(s)).collect();
|
||||||
|
|
||||||
|
|
@ -146,6 +163,7 @@ impl ActualListingData {
|
||||||
listing_status,
|
listing_status,
|
||||||
listing_date_iso,
|
listing_date_iso,
|
||||||
features,
|
features,
|
||||||
|
price_history,
|
||||||
filter_feature_data,
|
filter_feature_data,
|
||||||
poi_filter_feature_data,
|
poi_filter_feature_data,
|
||||||
grid,
|
grid,
|
||||||
|
|
@ -172,6 +190,7 @@ impl ActualListingData {
|
||||||
listing_status: opt_from_interned(&self.listing_status, row),
|
listing_status: opt_from_interned(&self.listing_status, row),
|
||||||
listing_date_iso: self.listing_date_iso[row].clone(),
|
listing_date_iso: self.listing_date_iso[row].clone(),
|
||||||
features: self.features[row].clone(),
|
features: self.features[row].clone(),
|
||||||
|
price_history: self.price_history[row].clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -629,6 +648,64 @@ fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract `price_history: List<Struct{date: Utf8, price: Int64, reason: Utf8}>`.
|
||||||
|
///
|
||||||
|
/// Schema-gated: a parquet written before the column existed simply yields an
|
||||||
|
/// empty series per row (the column is dropped from serialized listings). A
|
||||||
|
/// null/empty list is likewise an empty series.
|
||||||
|
fn extract_price_history(df: &DataFrame) -> Result<Vec<Vec<PriceHistoryPoint>>> {
|
||||||
|
let row_count = df.height();
|
||||||
|
let Ok(column) = df.column("price_history") else {
|
||||||
|
return Ok(vec![Vec::new(); row_count]);
|
||||||
|
};
|
||||||
|
let list_ca = column
|
||||||
|
.list()
|
||||||
|
.context("price_history is not a list column")?;
|
||||||
|
|
||||||
|
let mut out: Vec<Vec<PriceHistoryPoint>> = Vec::with_capacity(row_count);
|
||||||
|
for row in 0..row_count {
|
||||||
|
let mut points = Vec::new();
|
||||||
|
if let Some(inner) = list_ca.get_as_series(row) {
|
||||||
|
if !inner.is_empty() {
|
||||||
|
let structs = inner
|
||||||
|
.struct_()
|
||||||
|
.context("price_history inner is not a struct")?;
|
||||||
|
let dates_s = structs
|
||||||
|
.field_by_name("date")
|
||||||
|
.context("Missing 'date' field in price_history struct")?;
|
||||||
|
let date_ca = dates_s.str().context("price_history.date is not a string")?;
|
||||||
|
let prices_s = structs
|
||||||
|
.field_by_name("price")
|
||||||
|
.context("Missing 'price' field in price_history struct")?;
|
||||||
|
let prices_i64 = prices_s
|
||||||
|
.cast(&DataType::Int64)
|
||||||
|
.context("price_history.price is not castable to Int64")?;
|
||||||
|
let price_ca = prices_i64.i64().context("price_history.price is not Int64")?;
|
||||||
|
let reasons_s = structs
|
||||||
|
.field_by_name("reason")
|
||||||
|
.context("Missing 'reason' field in price_history struct")?;
|
||||||
|
let reason_ca = reasons_s
|
||||||
|
.str()
|
||||||
|
.context("price_history.reason is not a string")?;
|
||||||
|
for idx in 0..inner.len() {
|
||||||
|
let (Some(date), Some(price), Some(reason)) =
|
||||||
|
(date_ca.get(idx), price_ca.get(idx), reason_ca.get(idx))
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
points.push(PriceHistoryPoint {
|
||||||
|
date: date.to_string(),
|
||||||
|
price,
|
||||||
|
reason: reason.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(points);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -663,4 +740,30 @@ mod tests {
|
||||||
assert!(any_listing.lon.is_finite());
|
assert!(any_listing.lon.is_finite());
|
||||||
assert!(!any_listing.listing_url.is_empty());
|
assert!(!any_listing.listing_url.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_price_history_from_parquet() {
|
||||||
|
let path = PathBuf::from("src/data/testdata/listings_price_history.parquet");
|
||||||
|
if !path.exists() {
|
||||||
|
eprintln!("price_history fixture not present; skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data = ActualListingData::load_inner(&path, None).expect("listings load");
|
||||||
|
assert_eq!(data.price_history.len(), data.lat.len());
|
||||||
|
|
||||||
|
// Row 0: two points, listed -> reduced, oldest first.
|
||||||
|
let r0 = data.listing_at(0);
|
||||||
|
assert_eq!(r0.price_history.len(), 2);
|
||||||
|
assert_eq!(r0.price_history[0].date, "2026-07-01");
|
||||||
|
assert_eq!(r0.price_history[0].price, 500_000);
|
||||||
|
assert_eq!(r0.price_history[0].reason, "listed");
|
||||||
|
assert_eq!(r0.price_history[1].price, 480_000);
|
||||||
|
assert_eq!(r0.price_history[1].reason, "reduced");
|
||||||
|
|
||||||
|
// Row 1: a single "listed" point.
|
||||||
|
assert_eq!(data.listing_at(1).price_history.len(), 1);
|
||||||
|
// Row 2 (empty list) and row 3 (null) carry no points.
|
||||||
|
assert!(data.listing_at(2).price_history.is_empty());
|
||||||
|
assert!(data.listing_at(3).price_history.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -903,6 +903,40 @@ impl PropertyData {
|
||||||
for rows in postcode_row_index.values_mut() {
|
for rows in postcode_row_index.values_mut() {
|
||||||
rows.shrink_to_fit();
|
rows.shrink_to_fit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group postcode keys by sector ("E14 2") and outward code ("E14") so the
|
||||||
|
// wider-area price-history charts can enumerate a sector's / outcode's
|
||||||
|
// properties. Storing keys (one per postcode) rather than rows keeps this
|
||||||
|
// an order of magnitude smaller than the row index.
|
||||||
|
let mut sector_postcode_index: FxHashMap<String, Vec<lasso::Spur>> = FxHashMap::default();
|
||||||
|
let mut outcode_postcode_index: FxHashMap<String, Vec<lasso::Spur>> = FxHashMap::default();
|
||||||
|
for &key in postcode_row_index.keys() {
|
||||||
|
let postcode = postcode_rodeo.resolve(&key);
|
||||||
|
if let Some(sector) = crate::utils::postcode_sector(postcode) {
|
||||||
|
sector_postcode_index
|
||||||
|
.entry(sector.to_string())
|
||||||
|
.or_default()
|
||||||
|
.push(key);
|
||||||
|
}
|
||||||
|
if let Some(outcode) = crate::utils::postcode_outcode(postcode) {
|
||||||
|
outcode_postcode_index
|
||||||
|
.entry(outcode.to_string())
|
||||||
|
.or_default()
|
||||||
|
.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for keys in sector_postcode_index.values_mut() {
|
||||||
|
keys.shrink_to_fit();
|
||||||
|
}
|
||||||
|
for keys in outcode_postcode_index.values_mut() {
|
||||||
|
keys.shrink_to_fit();
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
sectors = sector_postcode_index.len(),
|
||||||
|
outcodes = outcode_postcode_index.len(),
|
||||||
|
"Sector/outcode postcode indexes built"
|
||||||
|
);
|
||||||
|
|
||||||
let postcode_interner = postcode_rodeo.into_reader();
|
let postcode_interner = postcode_rodeo.into_reader();
|
||||||
|
|
||||||
let row_to_poi_metric_idx: Vec<u32> = if poi_metrics.is_empty() {
|
let row_to_poi_metric_idx: Vec<u32> = if poi_metrics.is_empty() {
|
||||||
|
|
@ -1122,6 +1156,8 @@ impl PropertyData {
|
||||||
postcode_interner,
|
postcode_interner,
|
||||||
postcode_keys,
|
postcode_keys,
|
||||||
postcode_row_index,
|
postcode_row_index,
|
||||||
|
sector_postcode_index,
|
||||||
|
outcode_postcode_index,
|
||||||
address_token_index,
|
address_token_index,
|
||||||
address_prefix_index,
|
address_prefix_index,
|
||||||
address_search_interner,
|
address_search_interner,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ mod quant;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
|
||||||
pub use h3::precompute_h3;
|
pub use h3::precompute_h3;
|
||||||
pub use poi_metrics::PostcodePoiMetrics;
|
pub use poi_metrics::{PostcodePoiMetrics, COMBINED_STATION_CATEGORY};
|
||||||
pub use quant::QuantRef;
|
pub use quant::QuantRef;
|
||||||
pub use stats::{FeatureStats, Histogram};
|
pub use stats::{FeatureStats, Histogram};
|
||||||
|
|
||||||
|
|
@ -90,6 +90,14 @@ pub struct PropertyData {
|
||||||
postcode_keys: SpillVec<lasso::Spur>,
|
postcode_keys: SpillVec<lasso::Spur>,
|
||||||
/// Rows for each postcode, keyed by the interned postcode key.
|
/// Rows for each postcode, keyed by the interned postcode key.
|
||||||
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
|
postcode_row_index: FxHashMap<lasso::Spur, Vec<u32>>,
|
||||||
|
/// Postcode keys for each postcode sector (e.g. "E14 2"). Stores interned
|
||||||
|
/// keys, not rows, so the wider-area price-history charts can enumerate a
|
||||||
|
/// sector's properties (via `postcode_row_index`) for ~1/13th the memory of
|
||||||
|
/// duplicating the row ids.
|
||||||
|
sector_postcode_index: FxHashMap<String, Vec<lasso::Spur>>,
|
||||||
|
/// Postcode keys for each outward code (e.g. "E14"). Same rationale as
|
||||||
|
/// `sector_postcode_index`.
|
||||||
|
outcode_postcode_index: FxHashMap<String, Vec<lasso::Spur>>,
|
||||||
/// Inverted index from address tokens to property rows.
|
/// Inverted index from address tokens to property rows.
|
||||||
address_token_index: FxHashMap<String, Vec<u32>>,
|
address_token_index: FxHashMap<String, Vec<u32>>,
|
||||||
/// Prefix lookup from typed address-token prefix to indexed full address tokens.
|
/// Prefix lookup from typed address-token prefix to indexed full address tokens.
|
||||||
|
|
@ -156,6 +164,31 @@ impl PropertyData {
|
||||||
.unwrap_or(&[])
|
.unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All property rows in a postcode sector (e.g. "E14 2"), gathered across the
|
||||||
|
/// sector's postcodes. Empty if the sector is unknown. Materializes a new Vec
|
||||||
|
/// since the rows live in per-postcode buckets; callers downsample it.
|
||||||
|
pub fn rows_for_sector(&self, sector: &str) -> Vec<u32> {
|
||||||
|
self.rows_for_area(self.sector_postcode_index.get(sector))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All property rows in an outward code (e.g. "E14"). See `rows_for_sector`.
|
||||||
|
pub fn rows_for_outcode(&self, outcode: &str) -> Vec<u32> {
|
||||||
|
self.rows_for_area(self.outcode_postcode_index.get(outcode))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rows_for_area(&self, postcode_keys: Option<&Vec<lasso::Spur>>) -> Vec<u32> {
|
||||||
|
let Some(keys) = postcode_keys else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for key in keys {
|
||||||
|
if let Some(bucket) = self.postcode_row_index.get(key) {
|
||||||
|
rows.extend_from_slice(bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the is_approx_build_date flag for a given row (bit-packed).
|
/// Get the is_approx_build_date flag for a given row (bit-packed).
|
||||||
pub fn is_approx_build_date(&self, row: usize) -> bool {
|
pub fn is_approx_build_date(&self, row: usize) -> bool {
|
||||||
let byte = self.approx_build_date_bits[row / 8];
|
let byte = self.approx_build_date_bits[row / 8];
|
||||||
|
|
@ -253,6 +286,8 @@ impl PropertyData {
|
||||||
postcode_interner: lasso::Rodeo::default().into_reader(),
|
postcode_interner: lasso::Rodeo::default().into_reader(),
|
||||||
postcode_keys: SpillVec::owned(Vec::new()),
|
postcode_keys: SpillVec::owned(Vec::new()),
|
||||||
postcode_row_index: FxHashMap::default(),
|
postcode_row_index: FxHashMap::default(),
|
||||||
|
sector_postcode_index: FxHashMap::default(),
|
||||||
|
outcode_postcode_index: FxHashMap::default(),
|
||||||
address_token_index: FxHashMap::default(),
|
address_token_index: FxHashMap::default(),
|
||||||
address_prefix_index: FxHashMap::default(),
|
address_prefix_index: FxHashMap::default(),
|
||||||
address_search_interner: lasso::Rodeo::default().into_resolver(),
|
address_search_interner: lasso::Rodeo::default().into_resolver(),
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ impl PostcodePoiMetrics {
|
||||||
|
|
||||||
pub(super) fn from_postcode_df(
|
pub(super) fn from_postcode_df(
|
||||||
df: &DataFrame,
|
df: &DataFrame,
|
||||||
feature_names: Vec<String>,
|
mut feature_names: Vec<String>,
|
||||||
) -> anyhow::Result<Self> {
|
) -> anyhow::Result<Self> {
|
||||||
if feature_names.is_empty() {
|
if feature_names.is_empty() {
|
||||||
return Ok(Self::empty(0));
|
return Ok(Self::empty(0));
|
||||||
|
|
@ -56,7 +56,7 @@ impl PostcodePoiMetrics {
|
||||||
"Building postcode POI metric side table"
|
"Building postcode POI metric side table"
|
||||||
);
|
);
|
||||||
|
|
||||||
let col_major: Vec<Vec<f32>> = feature_names
|
let mut col_major: Vec<Vec<f32>> = feature_names
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.map(|name| {
|
.map(|name| {
|
||||||
let column = df
|
let column = df
|
||||||
|
|
@ -66,6 +66,8 @@ impl PostcodePoiMetrics {
|
||||||
})
|
})
|
||||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
append_combined_station_distance(&mut feature_names, &mut col_major);
|
||||||
|
|
||||||
let feature_stats: Vec<FeatureStats> = col_major
|
let feature_stats: Vec<FeatureStats> = col_major
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
|
|
@ -198,3 +200,140 @@ impl PostcodePoiMetrics {
|
||||||
self.decode_raw(metric_idx, self.raw_for_property_row(row, metric_idx))
|
self.decode_raw(metric_idx, self.raw_for_property_row(row, metric_idx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Category name of the synthetic "nearest of any rail mode" distance feature.
|
||||||
|
pub const COMBINED_STATION_CATEGORY: &str = "Any station";
|
||||||
|
|
||||||
|
/// Per-mode transport categories folded into [`COMBINED_STATION_CATEGORY`].
|
||||||
|
const COMBINED_STATION_SOURCE_CATEGORIES: &[&str] = &[
|
||||||
|
"Rail station",
|
||||||
|
"Tube station",
|
||||||
|
"DLR station",
|
||||||
|
"Tram & Metro stop",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn poi_distance_feature_name(category: &str) -> String {
|
||||||
|
format!("Distance to nearest amenity ({category}) (km)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a synthetic combined-station distance column: the elementwise minimum
|
||||||
|
/// of the per-mode rail/tube/tram/DLR distance columns that are present. This
|
||||||
|
/// lets users filter on the nearest station of any rail mode without a data
|
||||||
|
/// rebuild, since the source columns are already loaded. NaN (no station) rows
|
||||||
|
/// stay NaN only when every source is missing.
|
||||||
|
fn append_combined_station_distance(
|
||||||
|
feature_names: &mut Vec<String>,
|
||||||
|
col_major: &mut Vec<Vec<f32>>,
|
||||||
|
) {
|
||||||
|
let combined_name = poi_distance_feature_name(COMBINED_STATION_CATEGORY);
|
||||||
|
// Guard against re-synthesis or a pipeline that already provides the column.
|
||||||
|
if feature_names.iter().any(|name| name == &combined_name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_indices: Vec<usize> = COMBINED_STATION_SOURCE_CATEGORIES
|
||||||
|
.iter()
|
||||||
|
.filter_map(|category| {
|
||||||
|
let name = poi_distance_feature_name(category);
|
||||||
|
feature_names.iter().position(|existing| existing == &name)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if source_indices.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let row_count = col_major.first().map_or(0, Vec::len);
|
||||||
|
let combined: Vec<f32> = (0..row_count)
|
||||||
|
.into_par_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let nearest = source_indices
|
||||||
|
.iter()
|
||||||
|
.map(|&idx| col_major[idx][row])
|
||||||
|
.filter(|value| value.is_finite())
|
||||||
|
.fold(f32::INFINITY, f32::min);
|
||||||
|
if nearest.is_finite() {
|
||||||
|
nearest
|
||||||
|
} else {
|
||||||
|
f32::NAN
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
sources = source_indices.len(),
|
||||||
|
"Synthesized combined-station POI distance column"
|
||||||
|
);
|
||||||
|
feature_names.push(combined_name);
|
||||||
|
col_major.push(combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn dist_name(category: &str) -> String {
|
||||||
|
format!("Distance to nearest amenity ({category}) (km)")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combined_station_is_elementwise_min_ignoring_nan() {
|
||||||
|
let mut names = vec![
|
||||||
|
dist_name("Rail station"),
|
||||||
|
dist_name("Tube station"),
|
||||||
|
dist_name("DLR station"),
|
||||||
|
dist_name("Tram & Metro stop"),
|
||||||
|
];
|
||||||
|
let nan = f32::NAN;
|
||||||
|
let mut cols = vec![
|
||||||
|
vec![2.0, nan, 5.0], // Rail
|
||||||
|
vec![1.0, nan, nan], // Tube
|
||||||
|
vec![3.0, 4.0, nan], // DLR
|
||||||
|
vec![nan, nan, nan], // Tram & Metro
|
||||||
|
];
|
||||||
|
|
||||||
|
append_combined_station_distance(&mut names, &mut cols);
|
||||||
|
|
||||||
|
assert_eq!(names.last().unwrap(), &dist_name(COMBINED_STATION_CATEGORY));
|
||||||
|
let combined = cols.last().unwrap();
|
||||||
|
assert_eq!(combined[0], 1.0); // min(2, 1, 3), tram NaN ignored
|
||||||
|
assert_eq!(combined[1], 4.0); // only DLR present
|
||||||
|
assert_eq!(combined[2], 5.0); // only Rail present
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combined_station_row_with_no_station_stays_nan() {
|
||||||
|
let mut names = vec![dist_name("Rail station")];
|
||||||
|
let mut cols = vec![vec![f32::NAN, 2.0]];
|
||||||
|
|
||||||
|
append_combined_station_distance(&mut names, &mut cols);
|
||||||
|
|
||||||
|
let combined = cols.last().unwrap();
|
||||||
|
assert!(combined[0].is_nan());
|
||||||
|
assert_eq!(combined[1], 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_source_categories_appends_nothing() {
|
||||||
|
let mut names = vec![dist_name("Café"), dist_name("Pub")];
|
||||||
|
let mut cols = vec![vec![1.0], vec![2.0]];
|
||||||
|
|
||||||
|
append_combined_station_distance(&mut names, &mut cols);
|
||||||
|
|
||||||
|
assert_eq!(names.len(), 2);
|
||||||
|
assert_eq!(cols.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combined_station_is_not_duplicated() {
|
||||||
|
let mut names = vec![
|
||||||
|
dist_name("Rail station"),
|
||||||
|
dist_name(COMBINED_STATION_CATEGORY),
|
||||||
|
];
|
||||||
|
let mut cols = vec![vec![1.0], vec![1.0]];
|
||||||
|
|
||||||
|
append_combined_station_distance(&mut names, &mut cols);
|
||||||
|
|
||||||
|
assert_eq!(names.len(), 2);
|
||||||
|
assert_eq!(cols.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,14 @@ async fn main() -> anyhow::Result<()> {
|
||||||
&cli.google_oauth_client_secret,
|
&cli.google_oauth_client_secret,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
pocketbase::ensure_password_reset_template(
|
||||||
|
&http_client,
|
||||||
|
&cli.pocketbase_url,
|
||||||
|
&cli.pocketbase_admin_email,
|
||||||
|
&cli.pocketbase_admin_password,
|
||||||
|
&cli.public_url,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
info!("Gemini configured (model: {})", cli.gemini_model);
|
info!("Gemini configured (model: {})", cli.gemini_model);
|
||||||
let tt_path = &cli.travel_times;
|
let tt_path = &cli.travel_times;
|
||||||
if !tt_path.exists() {
|
if !tt_path.exists() {
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,15 @@ fn seo_page_for_path(path: &str) -> Option<SeoPage> {
|
||||||
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
|
description: "Manage your Perfect Postcode account, saved searches, shared links and invitations.",
|
||||||
indexable: false,
|
indexable: false,
|
||||||
}),
|
}),
|
||||||
|
// The password-reset email links here (see pocketbase::ensure_password_reset_template).
|
||||||
|
// Without this entry should_return_404 would 404 the SPA route and the emailed link
|
||||||
|
// would dead-end, which is the whole bug this page exists to fix.
|
||||||
|
"/reset-password" => Some(SeoPage {
|
||||||
|
canonical_path: "/reset-password",
|
||||||
|
title: "Reset your password | Perfect Postcode",
|
||||||
|
description: "Choose a new password for your Perfect Postcode account.",
|
||||||
|
indexable: false,
|
||||||
|
}),
|
||||||
_ if path.starts_with("/invite/") => Some(SeoPage {
|
_ if path.starts_with("/invite/") => Some(SeoPage {
|
||||||
canonical_path: "/invite",
|
canonical_path: "/invite",
|
||||||
title: "You're invited to Perfect Postcode",
|
title: "You're invited to Perfect Postcode",
|
||||||
|
|
@ -508,6 +517,14 @@ mod tests {
|
||||||
assert!(should_return_404("/definitely-not-a-real-page"));
|
assert!(should_return_404("/definitely-not-a-real-page"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_password_page_resolves_and_is_not_404() {
|
||||||
|
// The PocketBase reset email links to this SPA route (the middleware sees only the
|
||||||
|
// path, not the ?token= query), so it must render rather than 404.
|
||||||
|
assert!(seo_page_for_path("/reset-password").is_some());
|
||||||
|
assert!(!should_return_404("/reset-password"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn indexable_pages_emit_hreflang() {
|
fn indexable_pages_emit_hreflang() {
|
||||||
let page = seo_page_for_path("/terms").expect("terms page");
|
let page = seo_page_for_path("/terms").expect("terms page");
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,11 @@ pub struct EnumFeatureStats {
|
||||||
pub struct PricePoint {
|
pub struct PricePoint {
|
||||||
pub year: f32,
|
pub year: f32,
|
||||||
pub price: f32,
|
pub price: f32,
|
||||||
|
/// Sale price divided by the property's EPC floor area (the "Price per sqm"
|
||||||
|
/// feature). Absent where no floor area is recorded, so the per-m² view drops
|
||||||
|
/// those points.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub price_per_sqm: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -143,6 +148,15 @@ pub struct HexagonStatsResponse {
|
||||||
pub enum_features: Vec<EnumFeatureStats>,
|
pub enum_features: Vec<EnumFeatureStats>,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub price_history: Vec<PricePoint>,
|
pub price_history: Vec<PricePoint>,
|
||||||
|
/// Price history for every sale in the selection's postcode sector (e.g.
|
||||||
|
/// "E14 2"), independent of the active filters: a wider-area reference for
|
||||||
|
/// the selection's own chart.
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub sector_price_history: Vec<PricePoint>,
|
||||||
|
/// Price history for every sale in the selection's outward code (e.g. "E14"),
|
||||||
|
/// independent of the active filters.
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub outcode_price_history: Vec<PricePoint>,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub crime_by_year: Vec<CrimeYearStats>,
|
pub crime_by_year: Vec<CrimeYearStats>,
|
||||||
/// Latest year in the crime dataset as a whole. When a selection's series
|
/// Latest year in the crime dataset as a whole. When a selection's series
|
||||||
|
|
@ -642,6 +656,12 @@ pub async fn get_hexagon_stats(
|
||||||
|
|
||||||
let price_history =
|
let price_history =
|
||||||
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
stats::extract_price_history(&matching_rows, &state.data, &state.feature_name_to_index);
|
||||||
|
// Sector/outcode price histories are a postcode-selection feature only: a
|
||||||
|
// hexagon straddles arbitrary postcodes, so its central postcode's
|
||||||
|
// sector/outcode is not a meaningful aggregation unit. The frontend hides
|
||||||
|
// them for hexagons, so skip the (potentially large) outcode scan here.
|
||||||
|
let sector_price_history: Vec<PricePoint> = Vec::new();
|
||||||
|
let outcode_price_history: Vec<PricePoint> = Vec::new();
|
||||||
|
|
||||||
let crime_by_year = stats::compute_crime_by_year(
|
let crime_by_year = stats::compute_crime_by_year(
|
||||||
&matching_rows,
|
&matching_rows,
|
||||||
|
|
@ -733,6 +753,8 @@ pub async fn get_hexagon_stats(
|
||||||
numeric_features,
|
numeric_features,
|
||||||
enum_features: enum_features_out,
|
enum_features: enum_features_out,
|
||||||
price_history,
|
price_history,
|
||||||
|
sector_price_history,
|
||||||
|
outcode_price_history,
|
||||||
crime_by_year,
|
crime_by_year,
|
||||||
crime_latest_year,
|
crime_latest_year,
|
||||||
crime_outcode,
|
crime_outcode,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ use metrics::counter;
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use crate::consts::PRICE_HISTORY_POINTS_LIMIT;
|
use crate::consts::{AREA_PRICE_HISTORY_POINTS_LIMIT, PRICE_HISTORY_POINTS_LIMIT};
|
||||||
use crate::data::area_crime_averages::AreaCrimeAverages;
|
use crate::data::area_crime_averages::AreaCrimeAverages;
|
||||||
use crate::data::crime_by_year::CrimeByYearData;
|
use crate::data::crime_by_year::CrimeByYearData;
|
||||||
use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
|
use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
|
||||||
|
|
@ -15,47 +15,122 @@ use super::hexagon_stats::{
|
||||||
NumericFeatureStats, PricePoint,
|
NumericFeatureStats, PricePoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Extract price history (year, price) pairs from matching rows, downsampled if needed.
|
/// Feature indexes needed to build price-history points, resolved once.
|
||||||
pub fn extract_price_history(
|
struct PriceHistoryIndexes {
|
||||||
matching_rows: &[usize],
|
year: usize,
|
||||||
data: &PropertyData,
|
/// "Price per sqm" (last sale price / EPC floor area), when the feature
|
||||||
feature_name_to_index: &FxHashMap<String, usize>,
|
/// exists. Populates each point's optional `price_per_sqm`.
|
||||||
) -> Vec<PricePoint> {
|
price_per_sqm: Option<usize>,
|
||||||
let year_idx = feature_name_to_index
|
}
|
||||||
|
|
||||||
|
impl PriceHistoryIndexes {
|
||||||
|
fn resolve(feature_name_to_index: &FxHashMap<String, usize>) -> Option<Self> {
|
||||||
|
Some(Self {
|
||||||
|
year: feature_name_to_index
|
||||||
.get("Date of last transaction")
|
.get("Date of last transaction")
|
||||||
.copied();
|
.copied()?,
|
||||||
match year_idx {
|
price_per_sqm: feature_name_to_index.get("Price per sqm").copied(),
|
||||||
Some(yi) => {
|
})
|
||||||
let mut points: Vec<PricePoint> = matching_rows
|
|
||||||
.iter()
|
|
||||||
.filter_map(|&row| {
|
|
||||||
let year = data.get_feature(row, yi);
|
|
||||||
let price = data.last_known_price_raw(row);
|
|
||||||
if year.is_finite() && price.is_finite() {
|
|
||||||
Some(PricePoint { year, price })
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build (year, price, price_per_sqm) points from an iterator of rows, dropping
|
||||||
|
/// rows without a finite year or price, then stride-downsampling to `limit`.
|
||||||
|
fn build_price_points(
|
||||||
|
rows: impl Iterator<Item = usize>,
|
||||||
|
data: &PropertyData,
|
||||||
|
idx: &PriceHistoryIndexes,
|
||||||
|
limit: usize,
|
||||||
|
) -> Vec<PricePoint> {
|
||||||
|
let mut points: Vec<PricePoint> = rows
|
||||||
|
.filter_map(|row| {
|
||||||
|
let year = data.get_feature(row, idx.year);
|
||||||
|
let price = data.last_known_price_raw(row);
|
||||||
|
if !(year.is_finite() && price.is_finite()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let price_per_sqm = idx
|
||||||
|
.price_per_sqm
|
||||||
|
.map(|pi| data.get_feature(row, pi))
|
||||||
|
.filter(|v| v.is_finite() && *v > 0.0);
|
||||||
|
Some(PricePoint {
|
||||||
|
year,
|
||||||
|
price,
|
||||||
|
price_per_sqm,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if points.len() > PRICE_HISTORY_POINTS_LIMIT {
|
if points.len() > limit {
|
||||||
let step = points.len() as f64 / PRICE_HISTORY_POINTS_LIMIT as f64;
|
let step = points.len() as f64 / limit as f64;
|
||||||
points = (0..PRICE_HISTORY_POINTS_LIMIT)
|
points = (0..limit)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let idx = (i as f64 * step) as usize;
|
let src = &points[(i as f64 * step) as usize];
|
||||||
PricePoint {
|
PricePoint {
|
||||||
year: points[idx].year,
|
year: src.year,
|
||||||
price: points[idx].price,
|
price: src.price,
|
||||||
|
price_per_sqm: src.price_per_sqm,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
points
|
points
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract price history (year, price, price_per_sqm) pairs from matching rows,
|
||||||
|
/// downsampled if needed.
|
||||||
|
pub fn extract_price_history(
|
||||||
|
matching_rows: &[usize],
|
||||||
|
data: &PropertyData,
|
||||||
|
feature_name_to_index: &FxHashMap<String, usize>,
|
||||||
|
) -> Vec<PricePoint> {
|
||||||
|
match PriceHistoryIndexes::resolve(feature_name_to_index) {
|
||||||
|
Some(idx) => build_price_points(
|
||||||
|
matching_rows.iter().copied(),
|
||||||
|
data,
|
||||||
|
&idx,
|
||||||
|
PRICE_HISTORY_POINTS_LIMIT,
|
||||||
|
),
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Price histories for the selection's postcode sector and outward code, taken
|
||||||
|
/// over *every* sale in those areas (filter-independent), as wider-area context
|
||||||
|
/// for the selection's own chart. Returns `(sector_history, outcode_history)`.
|
||||||
|
pub fn extract_area_price_histories(
|
||||||
|
central_postcode: Option<&str>,
|
||||||
|
data: &PropertyData,
|
||||||
|
feature_name_to_index: &FxHashMap<String, usize>,
|
||||||
|
) -> (Vec<PricePoint>, Vec<PricePoint>) {
|
||||||
|
let (Some(postcode), Some(idx)) = (
|
||||||
|
central_postcode,
|
||||||
|
PriceHistoryIndexes::resolve(feature_name_to_index),
|
||||||
|
) else {
|
||||||
|
return (Vec::new(), Vec::new());
|
||||||
|
};
|
||||||
|
let sector = postcode_sector(postcode)
|
||||||
|
.map(|s| {
|
||||||
|
build_price_points(
|
||||||
|
data.rows_for_sector(s).into_iter().map(|r| r as usize),
|
||||||
|
data,
|
||||||
|
&idx,
|
||||||
|
AREA_PRICE_HISTORY_POINTS_LIMIT,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let outcode = postcode_outcode(postcode)
|
||||||
|
.map(|o| {
|
||||||
|
build_price_points(
|
||||||
|
data.rows_for_outcode(o).into_iter().map(|r| r as usize),
|
||||||
|
data,
|
||||||
|
&idx,
|
||||||
|
AREA_PRICE_HISTORY_POINTS_LIMIT,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
(sector, outcode)
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-feature accumulator kind, determined once before the row loop.
|
/// Per-feature accumulator kind, determined once before the row loop.
|
||||||
enum FeatureAccum {
|
enum FeatureAccum {
|
||||||
/// Numeric: track count, min, max, sum, histogram bins.
|
/// Numeric: track count, min, max, sum, histogram bins.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue