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
property-data
property-data-snapshot
property-data-snapshot2
property-data-snapshot*
video/.audit*

View file

@ -30,6 +30,13 @@ services:
- cargo-home:/usr/local/cargo
- cargo-target:/app/server-rs/target
environment:
# Disable incremental compilation. cargo-watch does incremental
# rebuilds over the mounted source; editing a struct with a generic
# serde impl mid-flight can corrupt the incremental cache and produce
# `rust-lld: undefined hidden symbol ...::serialize` link failures
# that never resolve until a clean rebuild. Off = slightly slower
# warm rebuilds, but no such corruption.
CARGO_INCREMENTAL: "0"
# Fallback only: the binary uses jemalloc as its global allocator
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
MALLOC_ARENA_MAX: "2"

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
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from datetime import datetime, timezone
from functools import partial
from pathlib import Path
from typing import Callable, Iterable
@ -34,6 +35,7 @@ from postcode_cache import load_cache, save_cache
from rightmove import resolve_outcode_id
from rightmove import search_outcode as rightmove_search_outcode
from spatial import PostcodeSpatialIndex
from price_history import load_history, save_history, update_history
from storage import write_parquet
from zoopla import TurnstileError
from zoopla import launch_browser as launch_zoopla_browser
@ -839,7 +841,18 @@ def run_scrape(
merged, source_counts, deduped = _merge_properties(results)
output_path = output_base / "online_listings_buy.parquet"
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:
if output_path.exists():
output_path.unlink()

View file

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

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(
scraper,
"write_parquet",
lambda props, path: written.update(count=len(props), path=path),
lambda props, path, price_history=None: written.update(
count=len(props), path=path, price_history=price_history
),
)
result = scraper.run_scrape(

View file

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

View file

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

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

View file

@ -127,7 +127,7 @@ export function ActiveFiltersPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-2 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
@ -202,7 +202,7 @@ export function ActiveFiltersPanel({
onLoginRequired={onLoginRequired}
/>
{enabledFeatureList.length === 0 && activeEntryCount === 0 && (
<p className="px-3 py-1.5 text-xs text-warm-400 dark:text-warm-500">
<p className="px-3 py-1 text-xs text-warm-400 dark:text-warm-500">
{t('filters.addFiltersHint')}
</p>
)}

View file

@ -166,7 +166,7 @@ export function AddFilterPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-y border-teal-300 bg-teal-100 px-3 py-3 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
className="shrink-0 flex items-center justify-between border-b border-teal-300 bg-teal-100 px-3 py-2 cursor-pointer hover:bg-teal-200 dark:border-teal-800 dark:bg-teal-900/50 dark:hover:bg-teal-900/70"
>
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')}
@ -179,7 +179,7 @@ export function AddFilterPanel({
{(!collapsed || !isLicensed) && (
<div className="flex min-h-0 flex-1 flex-col">
{!collapsed && (
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto md:min-h-[12rem]">
<div ref={setContainer} className="min-h-0 flex-1 overflow-y-auto">
<FeatureBrowser
availableFeatures={availableFeatures}
allFeatures={allFeatures}
@ -197,7 +197,7 @@ export function AddFilterPanel({
</div>
)}
{!isLicensed && (
<div className="mt-auto shrink-0 flex flex-col items-center gap-2 px-3 pt-3 pb-2.5 border-t border-warm-200 dark:border-warm-700">
<div className="mt-auto shrink-0 flex flex-col items-center gap-1.5 px-3 pt-2 pb-2 border-t border-warm-200 dark:border-warm-700">
<p className="text-sm text-warm-600 dark:text-warm-400 text-center leading-snug">
{isLoggedIn ? t('filters.upgradePrompt') : t('filters.registerPrompt')}{' '}
<span className="text-xs text-warm-400 dark:text-warm-500">
@ -206,7 +206,7 @@ export function AddFilterPanel({
</p>
<button
onClick={isLoggedIn ? onUpgradeClick : onRegisterClick}
className="w-full px-5 py-2 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
className="w-full px-5 py-1.5 rounded-lg bg-gradient-to-r from-teal-500 to-teal-600 hover:from-teal-600 hover:to-teal-700 text-white font-medium text-sm shadow-sm hover:shadow-md"
>
{isLoggedIn ? t('filters.upgradeToFullMap') : t('filters.registerCta')}
</button>

View file

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

View file

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

View file

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

View file

@ -646,6 +646,16 @@ const de: Translations = {
registrationFailed: 'Registrierung fehlgeschlagen',
oauthFailed: 'OAuth-Anmeldung fehlgeschlagen',
passwordResetFailed: 'Anfrage zum Zurücksetzen des Passworts fehlgeschlagen',
newPassword: 'Neues Passwort',
newPasswordPlaceholder: 'Mindestens 8 Zeichen',
updatePassword: 'Passwort aktualisieren',
resetSuccessBody:
'Dein Passwort wurde geändert. Du kannst dich jetzt mit deinem neuen Passwort anmelden.',
resetMissingToken:
'Dieser Link zum Zurücksetzen ist unvollständig. Fordere über die Anmeldeseite einen neuen an.',
resetInvalidLink:
'Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen. Fordere über die Anmeldeseite einen neuen an.',
goToLogin: 'Zur Anmeldung',
},
// ── Upgrade Modal ──────────────────────────────────
@ -953,6 +963,10 @@ const de: Translations = {
viewProperties: '{{count}} Immobilien ansehen',
viewPropertiesShort: 'Immobilien ansehen',
priceHistory: 'Preisentwicklung',
priceHistoryThisArea: 'Dieses Gebiet',
priceMetric: 'Preisbasis',
priceMetricTotal: 'Gesamt',
priceMetricPerSqm: 'Pro m²',
journeysFrom: 'Fahrzeiten für {{label}}',
to: 'Nach {{destination}}',
noJourneyData: 'Keine Verbindungsdaten verfügbar',
@ -1578,6 +1592,10 @@ const de: Translations = {
listing: 'Inserat',
showingOf: '{{visible}} von {{count}} werden angezeigt',
groupedNear: 'Gruppiert an dieser Kartenposition',
priceHistory: 'Preisverlauf',
priceListed: 'Gelistet',
priceReduced: 'Reduziert',
priceIncreased: 'Erhöht',
},
overlays: {
@ -1898,6 +1916,7 @@ const de: Translations = {
'Tube station': 'U-Bahn-Station',
'DLR station': 'DLR-Station',
'Tram & Metro stop': 'Tram- & Metro-Haltestelle',
'Any station': 'Beliebige Station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',

View file

@ -633,6 +633,15 @@ const en = {
registrationFailed: 'Registration failed',
oauthFailed: 'OAuth login failed',
passwordResetFailed: 'Password reset request failed',
newPassword: 'New password',
newPasswordPlaceholder: 'At least 8 characters',
updatePassword: 'Update password',
resetSuccessBody: 'Your password has been changed. You can now log in with your new password.',
resetMissingToken:
'This password reset link is incomplete. Request a new one from the log in screen.',
resetInvalidLink:
'This reset link is invalid or has expired. Request a new one from the log in screen.',
goToLogin: 'Go to log in',
},
// ── Upgrade Modal ──────────────────────────────────
@ -941,6 +950,10 @@ const en = {
viewProperties: 'View {{count}} properties',
viewPropertiesShort: 'View properties',
priceHistory: 'Price History',
priceHistoryThisArea: 'This area',
priceMetric: 'Price basis',
priceMetricTotal: 'Total',
priceMetricPerSqm: 'Per m²',
journeysFrom: 'Journey times for {{label}}',
to: 'To {{destination}}',
noJourneyData: 'No journey data available',
@ -1558,6 +1571,10 @@ const en = {
listing: 'Listing',
showingOf: 'Showing {{visible}} of {{count}}',
groupedNear: 'Grouped near this map position',
priceHistory: 'Price history',
priceListed: 'Listed',
priceReduced: 'Reduced',
priceIncreased: 'Increased',
},
// ── Map Overlays Pane ──────────────────────────────
@ -1880,6 +1897,7 @@ const en = {
'Tube station': 'Tube station',
'DLR station': 'DLR station',
'Tram & Metro stop': 'Tram & Metro stop',
'Any station': 'Any station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',

View file

@ -657,6 +657,16 @@ const fr: Translations = {
registrationFailed: 'Échec de linscription',
oauthFailed: 'Échec de la connexion OAuth',
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 ──────────────────────────────────
@ -963,6 +973,10 @@ const fr: Translations = {
viewProperties: 'Voir {{count}} biens',
viewPropertiesShort: 'Voir les biens',
priceHistory: 'Historique des prix',
priceHistoryThisArea: 'Cette zone',
priceMetric: 'Base de prix',
priceMetricTotal: 'Total',
priceMetricPerSqm: 'Au m²',
journeysFrom: 'Temps de trajet pour {{label}}',
to: 'Vers {{destination}}',
noJourneyData: 'Aucune donnée de trajet disponible',
@ -1595,6 +1609,10 @@ const fr: Translations = {
listing: 'Annonce',
showingOf: 'Affichage de {{visible}} sur {{count}}',
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 ───────────────────────────────────────
@ -1918,6 +1936,7 @@ const fr: Translations = {
'Tube station': 'Station de métro londonien',
'DLR station': 'Station DLR',
'Tram & Metro stop': 'Arrêt de tramway et métro',
'Any station': 'Toute station',
Café: 'Café',
Restaurant: 'Restaurant',
Pub: 'Pub',

View file

@ -630,6 +630,13 @@ const hi: Translations = {
registrationFailed: 'पंजीकरण विफल रहा',
oauthFailed: 'OAuth लॉग इन विफल रहा',
passwordResetFailed: 'पासवर्ड रीसेट अनुरोध विफल रहा',
newPassword: 'नया पासवर्ड',
newPasswordPlaceholder: 'कम से कम 8 अक्षर',
updatePassword: 'पासवर्ड अपडेट करें',
resetSuccessBody: 'आपका पासवर्ड बदल दिया गया है। अब आप अपने नए पासवर्ड से लॉग इन कर सकते हैं।',
resetMissingToken: 'यह रीसेट लिंक अधूरा है। लॉग इन स्क्रीन से नया लिंक प्राप्त करें।',
resetInvalidLink: 'यह रीसेट लिंक अमान्य है या समाप्त हो चुका है। लॉग इन स्क्रीन से नया लिंक प्राप्त करें।',
goToLogin: 'लॉग इन पर जाएँ',
},
upgrade: {
@ -921,6 +928,10 @@ const hi: Translations = {
viewProperties: '{{count}} संपत्तियां देखें',
viewPropertiesShort: 'संपत्तियां देखें',
priceHistory: 'कीमत इतिहास',
priceHistoryThisArea: 'यह क्षेत्र',
priceMetric: 'मूल्य आधार',
priceMetricTotal: 'कुल',
priceMetricPerSqm: 'प्रति मी²',
journeysFrom: '{{label}} के लिए यात्रा समय',
to: '{{destination}} तक',
noJourneyData: 'कोई यात्रा डेटा उपलब्ध नहीं',
@ -1513,6 +1524,10 @@ const hi: Translations = {
listing: 'लिस्टिंग',
showingOf: '{{count}} में से {{visible}} दिखाई जा रही हैं',
groupedNear: 'इस मैप स्थिति के पास समूहीकृत',
priceHistory: 'मूल्य इतिहास',
priceListed: 'सूचीबद्ध',
priceReduced: 'घटाया गया',
priceIncreased: 'बढ़ाया गया',
},
overlays: {
@ -1802,6 +1817,7 @@ const hi: Translations = {
'Tube station': 'ट्यूब स्टेशन',
'DLR station': 'DLR स्टेशन',
'Tram & Metro stop': 'ट्राम और मेट्रो स्टॉप',
'Any station': 'कोई भी स्टेशन',
Café: 'कैफे',
Restaurant: 'रेस्तरां',
Pub: 'पब',

View file

@ -648,6 +648,16 @@ const hu: Translations = {
registrationFailed: 'A regisztráció sikertelen',
oauthFailed: 'Az OAuth-bejelentkezé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 ──────────────────────────────────
@ -951,6 +961,10 @@ const hu: Translations = {
viewProperties: '{{count}} ingatlan megtekintése',
viewPropertiesShort: 'Ingatlanok megtekintése',
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}}',
to: 'Ide: {{destination}}',
noJourneyData: 'Nincs elérhető utazási adat',
@ -1578,6 +1592,10 @@ const hu: Translations = {
listing: 'Hirdetés',
showingOf: '{{visible}} / {{count}} megjelenítve',
groupedNear: 'Ennél a térképponton csoportosítva',
priceHistory: 'Ártörténet',
priceListed: 'Meghirdetve',
priceReduced: 'Csökkentve',
priceIncreased: 'Növelve',
},
// ── Overlays ───────────────────────────────────────
@ -1900,6 +1918,7 @@ const hu: Translations = {
'Tube station': 'Londoni metróállomás',
'DLR station': 'DLR-állomás',
'Tram & Metro stop': 'Villamos- és metrómegálló',
'Any station': 'Bármely állomás',
Café: 'Kávézó',
Restaurant: 'Étterem',
Pub: 'Kocsma',

View file

@ -594,6 +594,13 @@ const zh: Translations = {
registrationFailed: '注册失败',
oauthFailed: 'OAuth 登录失败',
passwordResetFailed: '密码重置请求失败',
newPassword: '新密码',
newPasswordPlaceholder: '至少 8 个字符',
updatePassword: '更新密码',
resetSuccessBody: '您的密码已更改。现在可以使用新密码登录。',
resetMissingToken: '此重置链接不完整。请从登录界面重新获取。',
resetInvalidLink: '此重置链接无效或已过期。请从登录界面重新获取。',
goToLogin: '前往登录',
},
// ── Upgrade Modal ──────────────────────────────────
@ -889,6 +896,10 @@ const zh: Translations = {
viewProperties: '查看 {{count}} 套房产',
viewPropertiesShort: '查看房产',
priceHistory: '价格历史',
priceHistoryThisArea: '此区域',
priceMetric: '价格基准',
priceMetricTotal: '总价',
priceMetricPerSqm: '每平方米',
journeysFrom: '{{label}} 的出行时间',
to: '前往 {{destination}}',
noJourneyData: '暂无出行数据',
@ -1494,6 +1505,10 @@ const zh: Translations = {
listing: '房源',
showingOf: '显示 {{count}} 套中的 {{visible}} 套',
groupedNear: '按此地图位置聚合',
priceHistory: '价格记录',
priceListed: '上架',
priceReduced: '降价',
priceIncreased: '涨价',
},
// ── Overlays ───────────────────────────────────────
@ -1813,6 +1828,7 @@ const zh: Translations = {
'Tube station': '伦敦地铁站',
'DLR station': 'DLR 轻轨站',
'Tram & Metro stop': '有轨电车与城市轨道站',
'Any station': '任意车站',
Café: '咖啡馆',
Restaurant: '餐厅',
Pub: '酒吧',

View file

@ -8,7 +8,7 @@ import type { TFunction } from 'i18next';
*/
export type AuthErrorAction = 'switchToLogin' | null;
export type AuthContext = 'login' | 'register' | 'oauth' | 'reset';
export type AuthContext = 'login' | 'register' | 'oauth' | 'reset' | 'confirmReset';
export interface ResolvedAuthError {
/** User-facing message (already resolved to a string). */
@ -75,5 +75,17 @@ export function resolveAuthError(
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 };
}

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 status", "_actual_listing_status", 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),
("Bathrooms", "_actual_bathrooms", pl.Int32),
("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 /
# total_floor_area` cast to Int32 explodes on a NaN, so we normalise floats
# to null at load time.
raw_columns = set(raw.collect_schema().names())
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)
if dtype in (pl.Float32, pl.Float64):
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_status").alias("Listing status"),
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_per_sqm").alias("Asking price per sqm"),
pl.col("_actual_bedrooms").alias("Bedrooms"),

View file

@ -22,6 +22,10 @@ pub const PLACES_LIMIT: usize = 20;
/// feature name in `features.rs`.
pub const FORMER_COUNCIL_HOUSE_FEATURE: &str = "Former council house";
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 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;
/// 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)]
pub struct ActualListing {
pub lat: f32,
@ -32,6 +42,10 @@ pub struct ActualListing {
pub listing_status: Option<String>,
pub listing_date_iso: Option<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 {
@ -54,6 +68,8 @@ pub struct ActualListingData {
pub listing_status: InternedColumn,
pub listing_date_iso: Vec<Option<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.
///
/// 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_date_iso = extract_opt_datetime_iso(&df, "Listing date")?;
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();
@ -146,6 +163,7 @@ impl ActualListingData {
listing_status,
listing_date_iso,
features,
price_history,
filter_feature_data,
poi_filter_feature_data,
grid,
@ -172,6 +190,7 @@ impl ActualListingData {
listing_status: opt_from_interned(&self.listing_status, row),
listing_date_iso: self.listing_date_iso[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)
}
/// 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)]
mod tests {
use super::*;
@ -663,4 +740,30 @@ mod tests {
assert!(any_listing.lon.is_finite());
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() {
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 row_to_poi_metric_idx: Vec<u32> = if poi_metrics.is_empty() {
@ -1122,6 +1156,8 @@ impl PropertyData {
postcode_interner,
postcode_keys,
postcode_row_index,
sector_postcode_index,
outcode_postcode_index,
address_token_index,
address_prefix_index,
address_search_interner,

View file

@ -18,7 +18,7 @@ mod quant;
mod stats;
pub use h3::precompute_h3;
pub use poi_metrics::PostcodePoiMetrics;
pub use poi_metrics::{PostcodePoiMetrics, COMBINED_STATION_CATEGORY};
pub use quant::QuantRef;
pub use stats::{FeatureStats, Histogram};
@ -90,6 +90,14 @@ pub struct PropertyData {
postcode_keys: SpillVec<lasso::Spur>,
/// Rows for each postcode, keyed by the interned postcode key.
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.
address_token_index: FxHashMap<String, Vec<u32>>,
/// Prefix lookup from typed address-token prefix to indexed full address tokens.
@ -156,6 +164,31 @@ impl PropertyData {
.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).
pub fn is_approx_build_date(&self, row: usize) -> bool {
let byte = self.approx_build_date_bits[row / 8];
@ -253,6 +286,8 @@ impl PropertyData {
postcode_interner: lasso::Rodeo::default().into_reader(),
postcode_keys: SpillVec::owned(Vec::new()),
postcode_row_index: FxHashMap::default(),
sector_postcode_index: FxHashMap::default(),
outcode_postcode_index: FxHashMap::default(),
address_token_index: FxHashMap::default(),
address_prefix_index: FxHashMap::default(),
address_search_interner: lasso::Rodeo::default().into_resolver(),

View file

@ -44,7 +44,7 @@ impl PostcodePoiMetrics {
pub(super) fn from_postcode_df(
df: &DataFrame,
feature_names: Vec<String>,
mut feature_names: Vec<String>,
) -> anyhow::Result<Self> {
if feature_names.is_empty() {
return Ok(Self::empty(0));
@ -56,7 +56,7 @@ impl PostcodePoiMetrics {
"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()
.map(|name| {
let column = df
@ -66,6 +66,8 @@ impl PostcodePoiMetrics {
})
.collect::<anyhow::Result<Vec<_>>>()?;
append_combined_station_distance(&mut feature_names, &mut col_major);
let feature_stats: Vec<FeatureStats> = col_major
.par_iter()
.enumerate()
@ -198,3 +200,140 @@ impl PostcodePoiMetrics {
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,
)
.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);
let tt_path = &cli.travel_times;
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.",
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 {
canonical_path: "/invite",
title: "You're invited to Perfect Postcode",
@ -508,6 +517,14 @@ mod tests {
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]
fn indexable_pages_emit_hreflang() {
let page = seo_page_for_path("/terms").expect("terms page");

View file

@ -61,6 +61,11 @@ pub struct EnumFeatureStats {
pub struct PricePoint {
pub year: 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)]
@ -143,6 +148,15 @@ pub struct HexagonStatsResponse {
pub enum_features: Vec<EnumFeatureStats>,
#[serde(skip_serializing_if = "Vec::is_empty")]
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")]
pub crime_by_year: Vec<CrimeYearStats>,
/// 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 =
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(
&matching_rows,
@ -733,6 +753,8 @@ pub async fn get_hexagon_stats(
numeric_features,
enum_features: enum_features_out,
price_history,
sector_price_history,
outcode_price_history,
crime_by_year,
crime_latest_year,
crime_outcode,

View file

@ -4,7 +4,7 @@ use metrics::counter;
use rustc_hash::FxHashMap;
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::crime_by_year::CrimeByYearData;
use crate::data::{FeatureStats, PostcodePoiMetrics, PropertyData};
@ -15,47 +15,122 @@ use super::hexagon_stats::{
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(
matching_rows: &[usize],
data: &PropertyData,
feature_name_to_index: &FxHashMap<String, usize>,
) -> Vec<PricePoint> {
let year_idx = feature_name_to_index
.get("Date of last transaction")
.copied();
match year_idx {
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
}
})
.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
}
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(),
}
}
/// 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.
enum FeatureAccum {
/// Numeric: track count, min, max, sum, histogram bins.