This commit is contained in:
Andras Schmelczer 2026-07-12 20:30:19 +01:00
parent 6df2812a4e
commit 9e4e65fa2a
35 changed files with 1172 additions and 70 deletions

2
.gitignore vendored
View file

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

View file

@ -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
View file

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

View file

@ -5,6 +5,7 @@ import signal
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Callable, Iterable from typing import Callable, Iterable
@ -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()

View file

@ -10,8 +10,16 @@ from transform import map_property_type, normalize_postcode
log = logging.getLogger("rightmove") log = logging.getLogger("rightmove")
def write_parquet(properties: list[dict], path: Path) -> None: def write_parquet(
"""Write sale properties list to parquet with server-ready column names.""" properties: list[dict], path: Path, price_history: dict | None = None
) -> None:
"""Write sale properties list to parquet with server-ready column names.
``price_history`` is the persistent listing-id -> [{date, price, reason}]
store (see finder/price_history.py); each listing's accrued asking-price
series is embedded as a ``price_history`` list column. Absent id -> empty
list, so pre-instrumentation runs and tests simply write empty series.
"""
if not properties: if not properties:
log.warning("No properties to write to %s", path) log.warning("No properties to write to %s", path)
return return
@ -95,6 +103,14 @@ def write_parquet(properties: list[dict], path: Path) -> None:
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties] asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
listing_statuses = ["For sale"] * len(properties) listing_statuses = ["For sale"] * len(properties)
# Accrued asking-price history per listing (oldest -> newest). Look up with
# the SAME normalisation the store keys on (str(id).strip(), matching
# price_history.update_history and scraper dedup); an unseen id -> empty.
history_lookup = price_history or {}
price_history_col = [
history_lookup.get(str(p.get("id")).strip(), []) for p in properties
]
df = pl.DataFrame( df = pl.DataFrame(
{ {
"Bedrooms": [p["Bedrooms"] for p in properties], "Bedrooms": [p["Bedrooms"] for p in properties],
@ -144,6 +160,7 @@ def write_parquet(properties: list[dict], path: Path) -> None:
"Listing date": listing_dates, "Listing date": listing_dates,
"Listing status": listing_statuses, "Listing status": listing_statuses,
"Asking price": asking_prices, "Asking price": asking_prices,
"price_history": price_history_col,
}, },
schema={ schema={
"Bedrooms": pl.Int32, "Bedrooms": pl.Int32,
@ -169,6 +186,11 @@ def write_parquet(properties: list[dict], path: Path) -> None:
"Listing date": pl.Datetime("us"), "Listing date": pl.Datetime("us"),
"Listing status": pl.Utf8, "Listing status": pl.Utf8,
"Asking price": pl.Int64, "Asking price": pl.Int64,
"price_history": pl.List(
pl.Struct(
{"date": pl.Utf8, "price": pl.Int64, "reason": pl.Utf8}
)
),
}, },
) )

View file

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

View file

@ -125,7 +125,9 @@ def test_run_scrape_runs_all_sources_merges_and_persists_caches(tmp_path, monkey
monkeypatch.setattr( monkeypatch.setattr(
scraper, scraper,
"write_parquet", "write_parquet",
lambda props, path: written.update(count=len(props), path=path), lambda props, path, price_history=None: written.update(
count=len(props), path=path, price_history=price_history
),
) )
result = scraper.run_scrape( result = scraper.run_scrape(

View file

@ -385,6 +385,16 @@ def transform_property(
if not listing_id: if not listing_id:
return None return None
# The search API already carries the single most-recent price-change event
# (reason + ISO date) with no extra request. Rightmove never exposes the
# previous price, so this only seeds the accruing asking-price history (see
# finder/price_history.py); it is not written to the output parquet itself.
listing_update = prop.get("listingUpdate")
if not isinstance(listing_update, dict):
listing_update = {}
listing_update_reason = listing_update.get("listingUpdateReason")
listing_update_date = listing_update.get("listingUpdateDate")
return { return {
"id": listing_id, "id": listing_id,
"Bedrooms": bedrooms, "Bedrooms": bedrooms,
@ -411,4 +421,7 @@ def transform_property(
"Listing URL": build_listing_url(property_url, bool(prop.get("development"))), "Listing URL": build_listing_url(property_url, bool(prop.get("development"))),
"Listing features": key_features, "Listing features": key_features,
"first_visible_date": prop.get("firstVisibleDate", ""), "first_visible_date": prop.get("firstVisibleDate", ""),
# Fed into the asking-price history store, not the parquet directly.
"listing_update_reason": listing_update_reason,
"listing_update_date": listing_update_date,
} }

View file

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

View file

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

View file

@ -0,0 +1,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>
);
}

View file

@ -143,11 +143,11 @@ export default memo(function AiFilterInput({
if (!expanded) { if (!expanded) {
return ( return (
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters"> <div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
<button <button
type="button" type="button"
onClick={() => setExpanded(true)} onClick={() => setExpanded(true)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group" className="w-full flex items-center gap-2 px-3 py-1.5 rounded-lg border border-dashed border-teal-300 dark:border-teal-700 bg-teal-50/50 dark:bg-teal-900/20 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer group"
> >
<SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" /> <SparklesIcon className="w-4 h-4 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200"> <span className="text-sm text-teal-700 dark:text-teal-300 group-hover:text-teal-800 dark:group-hover:text-teal-200">
@ -159,8 +159,8 @@ export default memo(function AiFilterInput({
} }
return ( return (
<div ref={containerRef} className="px-3 py-2" data-tutorial="ai-filters"> <div ref={containerRef} className="px-3 py-1.5" data-tutorial="ai-filters">
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1">
<SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" /> <SparklesIcon className="w-3.5 h-3.5 text-teal-500 dark:text-teal-400 shrink-0" />
<span className="text-xs font-medium text-teal-700 dark:text-teal-300"> <span className="text-xs font-medium text-teal-700 dark:text-teal-300">
{t('aiFilter.aiSearch')} {t('aiFilter.aiSearch')}

View file

@ -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>
)} )}

View file

@ -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>

View file

@ -35,7 +35,7 @@ export function EnumFeatureFilterCard({
return ( return (
<div <div
data-filter-name={feature.name} data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`} className={`space-y-0.5 px-2 py-1 rounded ${pinnedFeature === feature.name ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
> >
<div className="flex items-start justify-between gap-1"> <div className="flex items-start justify-between gap-1">
<FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap /> <FeatureLabel feature={feature} size="sm" className="min-w-0 shrink" wrap />

View file

@ -119,7 +119,7 @@ export function EthnicityFilterCard({
return ( return (
<div <div
data-filter-name={ETHNICITIES_FILTER_NAME} data-filter-name={ETHNICITIES_FILTER_NAME}
className={`space-y-1.5 px-2 py-1.5 rounded ${ className={`space-y-1 px-2 py-1 rounded ${
isActive isActive
? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30'
: isPinned : isPinned
@ -147,7 +147,7 @@ export function EthnicityFilterCard({
</div> </div>
<div> <div>
<label className="mb-1 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500"> <label className="mb-0.5 block text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.ethnicity')} {t('filters.ethnicity')}
</label> </label>
<div className="relative"> <div className="relative">

View file

@ -32,6 +32,16 @@ function authRefreshInvalidated(error: unknown): boolean {
return status === 401 || status === 403; return status === 401 || status === 403;
} }
/**
* Normalize an email so login and registration are case- and
* whitespace-insensitive: ` John@Example.com` and `john@example.com`
* resolve to the same account. Applied at the PocketBase boundary rather
* than on the input field, so the user still sees exactly what they typed.
*/
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function useAuth() { export function useAuth() {
const { t } = useTranslation(); const { t } = useTranslation();
const [user, setUser] = useState<AuthUser | null>(() => { const [user, setUser] = useState<AuthUser | null>(() => {
@ -62,7 +72,9 @@ export function useAuth() {
setError(null); setError(null);
setErrorAction(null); setErrorAction(null);
try { try {
const result = await pb.collection('users').authWithPassword(email, password); const result = await pb
.collection('users')
.authWithPassword(normalizeEmail(email), password);
setUser(recordToUser(result.record)); setUser(recordToUser(result.record));
trackEvent('Login', { method: 'email' }); trackEvent('Login', { method: 'email' });
} catch (err) { } catch (err) {
@ -82,12 +94,13 @@ export function useAuth() {
setLoading(true); setLoading(true);
setError(null); setError(null);
setErrorAction(null); setErrorAction(null);
const normalizedEmail = normalizeEmail(email);
try { try {
// Step 1: create the account. A failure here means nothing was created, // Step 1: create the account. A failure here means nothing was created,
// so surface a friendly, field-aware message (e.g. "email already exists"). // so surface a friendly, field-aware message (e.g. "email already exists").
try { try {
await pb.collection('users').create({ await pb.collection('users').create({
email, email: normalizedEmail,
password, password,
passwordConfirm: password, passwordConfirm: password,
newsletter: true, newsletter: true,
@ -103,7 +116,7 @@ export function useAuth() {
// orphaned: tell the user it was created and let them log in with the // orphaned: tell the user it was created and let them log in with the
// same credentials, instead of a misleading "registration failed". // same credentials, instead of a misleading "registration failed".
try { try {
const result = await pb.collection('users').authWithPassword(email, password); const result = await pb.collection('users').authWithPassword(normalizedEmail, password);
setUser(recordToUser(result.record)); setUser(recordToUser(result.record));
trackEvent('Register'); trackEvent('Register');
} catch (err) { } catch (err) {
@ -159,7 +172,7 @@ export function useAuth() {
setError(null); setError(null);
setErrorAction(null); setErrorAction(null);
try { try {
await pb.collection('users').requestPasswordReset(email); await pb.collection('users').requestPasswordReset(normalizeEmail(email));
} catch (err) { } catch (err) {
const { message, action } = resolveAuthError(err, 'reset', t); const { message, action } = resolveAuthError(err, 'reset', t);
setError(message); setError(message);
@ -172,6 +185,27 @@ export function useAuth() {
[t] [t]
); );
const confirmPasswordReset = useCallback(
async (token: string, password: string) => {
setLoading(true);
setError(null);
setErrorAction(null);
try {
// PocketBase requires the password twice; the reset form uses a single
// (show/hide) field, so both arguments are the same value.
await pb.collection('users').confirmPasswordReset(token, password, password);
} catch (err) {
const { message, action } = resolveAuthError(err, 'confirmReset', t);
setError(message);
setErrorAction(action);
throw err;
} finally {
setLoading(false);
}
},
[t]
);
const refreshAuth = useCallback(async () => { const refreshAuth = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -208,6 +242,7 @@ export function useAuth() {
loginWithOAuth, loginWithOAuth,
logout, logout,
requestPasswordReset, requestPasswordReset,
confirmPasswordReset,
refreshAuth, refreshAuth,
clearError, clearError,
}; };

View file

@ -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',

View file

@ -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',

View file

@ -657,6 +657,16 @@ const fr: Translations = {
registrationFailed: 'Échec de linscription', registrationFailed: 'Échec de linscription',
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',

View file

@ -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: 'पब',

View file

@ -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',

View file

@ -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: '酒吧',

View file

@ -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 };
} }

View 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);
}

View file

@ -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"),

View file

@ -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;

View file

@ -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());
}
} }

View file

@ -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,

View file

@ -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(),

View file

@ -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);
}
}

View file

@ -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() {

View file

@ -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");

View file

@ -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,

View file

@ -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.
struct PriceHistoryIndexes {
year: usize,
/// "Price per sqm" (last sale price / EPC floor area), when the feature
/// exists. Populates each point's optional `price_per_sqm`.
price_per_sqm: Option<usize>,
}
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")
.copied()?,
price_per_sqm: feature_name_to_index.get("Price per sqm").copied(),
})
}
}
/// 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();
if points.len() > limit {
let step = points.len() as f64 / limit as f64;
points = (0..limit)
.map(|i| {
let src = &points[(i as f64 * step) as usize];
PricePoint {
year: src.year,
price: src.price,
price_per_sqm: src.price_per_sqm,
}
})
.collect();
}
points
}
/// Extract price history (year, price, price_per_sqm) pairs from matching rows,
/// downsampled if needed.
pub fn extract_price_history( pub fn extract_price_history(
matching_rows: &[usize], matching_rows: &[usize],
data: &PropertyData, data: &PropertyData,
feature_name_to_index: &FxHashMap<String, usize>, feature_name_to_index: &FxHashMap<String, usize>,
) -> Vec<PricePoint> { ) -> Vec<PricePoint> {
let year_idx = feature_name_to_index match PriceHistoryIndexes::resolve(feature_name_to_index) {
.get("Date of last transaction") Some(idx) => build_price_points(
.copied(); matching_rows.iter().copied(),
match year_idx { data,
Some(yi) => { &idx,
let mut points: Vec<PricePoint> = matching_rows PRICE_HISTORY_POINTS_LIMIT,
.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
}
})
.collect();
if points.len() > PRICE_HISTORY_POINTS_LIMIT {
let step = points.len() as f64 / PRICE_HISTORY_POINTS_LIMIT as f64;
points = (0..PRICE_HISTORY_POINTS_LIMIT)
.map(|i| {
let idx = (i as f64 * step) as usize;
PricePoint {
year: points[idx].year,
price: points[idx].price,
}
})
.collect();
}
points
}
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.