fine
This commit is contained in:
parent
6df2812a4e
commit
9e4e65fa2a
35 changed files with 1172 additions and 70 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -27,5 +27,5 @@ video/auth.*
|
|||
r5-java/tmp
|
||||
property-data
|
||||
property-data-snapshot
|
||||
property-data-snapshot2
|
||||
property-data-snapshot*
|
||||
video/.audit*
|
||||
|
|
|
|||
|
|
@ -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
120
finder/price_history.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Forward-only asking-price history, accrued across recurring scrape runs.
|
||||
|
||||
Rightmove (and the other portals) never expose a listing's full asking-price
|
||||
timeline: a detail page carries only the current price plus one most-recent
|
||||
"Reduced on <date>" event, and the previous price is never published. The only
|
||||
way to obtain a real "listed at X, reduced to Y" series is therefore to record
|
||||
each listing's price ourselves every run and diff it over time.
|
||||
|
||||
This module keeps a persistent, listing-id-keyed store of price observations:
|
||||
|
||||
{"<listing id>": [{"date": "YYYY-MM-DD", "price": 425000, "reason": "listed"},
|
||||
{"date": "YYYY-MM-DD", "price": 410000, "reason": "reduced"}]}
|
||||
|
||||
Entries are oldest -> newest. A new point is appended only when the price
|
||||
actually changes (or on first sight), so an unchanged listing costs nothing. The
|
||||
store is seeded from / dumped to disk with the same atomic JSON helpers as the
|
||||
detail-postcode caches (see postcode_cache.py), so a recurring scrape extends the
|
||||
history rather than rebuilding it.
|
||||
|
||||
Two hard limitations, both inherent to the data source:
|
||||
* There is NO backfill. History accrues only from the first instrumented run;
|
||||
prior asking prices cannot be reconstructed (portals don't publish them and
|
||||
we kept no snapshots).
|
||||
* On a listing's first sight we know exactly one price, so we record one point.
|
||||
If the portal's own most-recent event says that price was itself a reduction/
|
||||
increase, we date and label that single point accordingly; we still cannot
|
||||
invent the pre-change price.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from postcode_cache import load_cache, save_cache
|
||||
|
||||
log = logging.getLogger("rightmove")
|
||||
|
||||
# Portal `listingUpdateReason` codes -> our canonical reasons. Anything not a
|
||||
# recognised price move (e.g. "new", "under_offer", "auction", or absent) leaves
|
||||
# the first-sight point labelled "listed" and never fabricates a change.
|
||||
_REASON_MAP = {
|
||||
"price_reduced": "reduced",
|
||||
"price_increased": "increased",
|
||||
}
|
||||
|
||||
_ISO_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})")
|
||||
|
||||
|
||||
def normalize_reason(raw: object) -> str | None:
|
||||
"""Map a portal update-reason code to 'reduced'/'increased', else None."""
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
return _REASON_MAP.get(raw.strip().lower())
|
||||
|
||||
|
||||
def _iso_to_date(value: object) -> str | None:
|
||||
"""Return the YYYY-MM-DD prefix of an ISO timestamp, or None."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
match = _ISO_DATE_RE.match(value.strip())
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def load_history(path: str | Path) -> dict:
|
||||
"""Load the persisted asking-price history. Returns {} when absent/unreadable."""
|
||||
return load_cache(path)
|
||||
|
||||
|
||||
def save_history(path: str | Path, history: dict) -> None:
|
||||
"""Atomically persist the asking-price history to disk."""
|
||||
save_cache(path, history)
|
||||
|
||||
|
||||
def update_history(history: dict, listings: list[dict], run_date: str) -> None:
|
||||
"""Fold one run's listings into ``history`` in place.
|
||||
|
||||
``run_date`` is the ISO date (YYYY-MM-DD) of the scrape. For each listing with
|
||||
a stable id and a positive price:
|
||||
|
||||
* first sight -> append one point. Its date/reason come from the portal's
|
||||
most-recent change event when that event is a price move (so a listing we
|
||||
first meet already-reduced reads "Reduced on <event date>"); otherwise the
|
||||
point is the "listed" price dated to ``first_visible_date`` (falling back
|
||||
to ``run_date``).
|
||||
* later runs -> append a point ONLY when the price differs from the last
|
||||
recorded one, labelled "reduced"/"increased" by direction and dated to
|
||||
``run_date`` (we only know the change happened by this run).
|
||||
|
||||
An unchanged price is a no-op, so the series stays a list of genuine moves.
|
||||
"""
|
||||
for listing in listings:
|
||||
listing_id_raw = listing.get("id")
|
||||
if listing_id_raw is None:
|
||||
continue
|
||||
listing_id = str(listing_id_raw).strip()
|
||||
if not listing_id:
|
||||
continue
|
||||
try:
|
||||
price = int(listing.get("price") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if price <= 0:
|
||||
continue
|
||||
|
||||
entries = history.get(listing_id)
|
||||
if not isinstance(entries, list) or not entries:
|
||||
reason = normalize_reason(listing.get("listing_update_reason"))
|
||||
if reason is not None:
|
||||
date = _iso_to_date(listing.get("listing_update_date")) or run_date
|
||||
else:
|
||||
reason = "listed"
|
||||
date = _iso_to_date(listing.get("first_visible_date")) or run_date
|
||||
history[listing_id] = [{"date": date, "price": price, "reason": reason}]
|
||||
continue
|
||||
|
||||
last_price = entries[-1].get("price")
|
||||
if last_price == price:
|
||||
continue
|
||||
reason = "reduced" if last_price is not None and price < last_price else "increased"
|
||||
entries.append({"date": run_date, "price": price, "reason": reason})
|
||||
|
|
@ -5,6 +5,7 @@ import signal
|
|||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
77
finder/test_price_history.py
Normal file
77
finder/test_price_history.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Tests for the forward-only asking-price history store (price_history.py)."""
|
||||
|
||||
from price_history import normalize_reason, update_history
|
||||
|
||||
|
||||
def test_first_sight_new_listing_records_listed_at_first_visible_date() -> None:
|
||||
history: dict = {}
|
||||
update_history(
|
||||
history,
|
||||
[{"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}],
|
||||
"2026-07-12",
|
||||
)
|
||||
assert history["A"] == [{"date": "2026-07-01", "price": 500000, "reason": "listed"}]
|
||||
|
||||
|
||||
def test_first_sight_already_reduced_uses_event_date_and_reason() -> None:
|
||||
history: dict = {}
|
||||
update_history(
|
||||
history,
|
||||
[
|
||||
{
|
||||
"id": "B",
|
||||
"price": 425000,
|
||||
"first_visible_date": "2026-06-01T09:00:00Z",
|
||||
"listing_update_reason": "price_reduced",
|
||||
"listing_update_date": "2026-07-10T14:00:00Z",
|
||||
}
|
||||
],
|
||||
"2026-07-12",
|
||||
)
|
||||
assert history["B"] == [{"date": "2026-07-10", "price": 425000, "reason": "reduced"}]
|
||||
|
||||
|
||||
def test_later_run_appends_only_on_price_change_with_direction() -> None:
|
||||
history: dict = {}
|
||||
listing = {"id": "A", "price": 500000, "first_visible_date": "2026-07-01T09:00:00Z"}
|
||||
update_history(history, [listing], "2026-07-12")
|
||||
# Unchanged price -> no new point.
|
||||
update_history(history, [{"id": "A", "price": 500000}], "2026-07-19")
|
||||
assert len(history["A"]) == 1
|
||||
# Reduced -> appended, dated to the run.
|
||||
update_history(history, [{"id": "A", "price": 480000}], "2026-07-26")
|
||||
# Increased -> appended.
|
||||
update_history(history, [{"id": "A", "price": 490000}], "2026-08-02")
|
||||
assert history["A"] == [
|
||||
{"date": "2026-07-01", "price": 500000, "reason": "listed"},
|
||||
{"date": "2026-07-26", "price": 480000, "reason": "reduced"},
|
||||
{"date": "2026-08-02", "price": 490000, "reason": "increased"},
|
||||
]
|
||||
|
||||
|
||||
def test_zero_or_missing_price_and_id_are_skipped() -> None:
|
||||
history: dict = {}
|
||||
update_history(
|
||||
history,
|
||||
[
|
||||
{"id": "POA", "price": 0},
|
||||
{"id": "", "price": 100000},
|
||||
{"price": 100000}, # no id
|
||||
],
|
||||
"2026-07-12",
|
||||
)
|
||||
assert history == {}
|
||||
|
||||
|
||||
def test_run_date_fallback_when_dates_absent() -> None:
|
||||
history: dict = {}
|
||||
update_history(history, [{"id": "A", "price": 300000}], "2026-07-12")
|
||||
assert history["A"] == [{"date": "2026-07-12", "price": 300000, "reason": "listed"}]
|
||||
|
||||
|
||||
def test_normalize_reason_maps_only_price_moves() -> None:
|
||||
assert normalize_reason("price_reduced") == "reduced"
|
||||
assert normalize_reason("price_increased") == "increased"
|
||||
assert normalize_reason("new") is None
|
||||
assert normalize_reason("under_offer") is None
|
||||
assert normalize_reason(None) is None
|
||||
|
|
@ -125,7 +125,9 @@ def test_run_scrape_runs_all_sources_merges_and_persists_caches(tmp_path, monkey
|
|||
monkeypatch.setattr(
|
||||
scraper,
|
||||
"write_parquet",
|
||||
lambda props, path: written.update(count=len(props), path=path),
|
||||
lambda props, path, price_history=None: written.update(
|
||||
count=len(props), path=path, price_history=price_history
|
||||
),
|
||||
)
|
||||
|
||||
result = scraper.run_scrape(
|
||||
|
|
|
|||
|
|
@ -385,6 +385,16 @@ def transform_property(
|
|||
if not listing_id:
|
||||
return None
|
||||
|
||||
# The search API already carries the single most-recent price-change event
|
||||
# (reason + ISO date) with no extra request. Rightmove never exposes the
|
||||
# previous price, so this only seeds the accruing asking-price history (see
|
||||
# finder/price_history.py); it is not written to the output parquet itself.
|
||||
listing_update = prop.get("listingUpdate")
|
||||
if not isinstance(listing_update, dict):
|
||||
listing_update = {}
|
||||
listing_update_reason = listing_update.get("listingUpdateReason")
|
||||
listing_update_date = listing_update.get("listingUpdateDate")
|
||||
|
||||
return {
|
||||
"id": listing_id,
|
||||
"Bedrooms": bedrooms,
|
||||
|
|
@ -411,4 +421,7 @@ def transform_property(
|
|||
"Listing URL": build_listing_url(property_url, bool(prop.get("development"))),
|
||||
"Listing features": key_features,
|
||||
"first_visible_date": prop.get("firstVisibleDate", ""),
|
||||
# Fed into the asking-price history store, not the parquet directly.
|
||||
"listing_update_reason": listing_update_reason,
|
||||
"listing_update_date": listing_update_date,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { fetchWithRetry, apiUrl, logNonAbortError } from './lib/api';
|
|||
import { trackEvent } from './lib/analytics';
|
||||
import { parseUrlState } from './lib/url-state';
|
||||
import pb from './lib/pocketbase';
|
||||
import { DEFAULT_DEMO_FILTERS, INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { DEFAULT_DEMO_FILTERS, DEFAULT_DEMO_TRAVEL, INITIAL_VIEW_STATE } from './lib/consts';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { useIsMobile } from './hooks/useIsMobile';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
|
|
@ -38,6 +38,7 @@ const LearnPage = lazy(() => import('./components/learn/LearnPage'));
|
|||
const SeoLandingPage = lazy(() => import('./components/landing/SeoLandingPage'));
|
||||
const SeoContentPage = lazy(() => import('./components/landing/SeoContentPage'));
|
||||
const AccountPage = lazy(() => import('./components/account/AccountPage'));
|
||||
const ResetPasswordPage = lazy(() => import('./components/account/ResetPasswordPage'));
|
||||
const SavedPage = lazy(() =>
|
||||
import('./components/account/AccountPage').then((module) => ({ default: module.SavedPage }))
|
||||
);
|
||||
|
|
@ -160,6 +161,8 @@ function pageToPath(page: Page, inviteCode?: string): string {
|
|||
return '/saved';
|
||||
case 'account':
|
||||
return '/account';
|
||||
case 'reset-password':
|
||||
return '/reset-password';
|
||||
case 'invite':
|
||||
if (!inviteCode) {
|
||||
throw new Error('Cannot build invite path without an invite code');
|
||||
|
|
@ -183,6 +186,7 @@ function pathToPage(rawPathname: string): RouteMatch | null {
|
|||
const seoContentPage = getSeoContentPage(pathname);
|
||||
if (seoContentPage) return { page: seoContentPage };
|
||||
if (pathname === '/account') return { page: 'account' };
|
||||
if (pathname === '/reset-password') return { page: 'reset-password' };
|
||||
if (pathname === '/support') return { page: 'learn' };
|
||||
if (pathname === '/terms') return { page: 'terms' };
|
||||
if (pathname === '/privacy') return { page: 'privacy' };
|
||||
|
|
@ -234,15 +238,26 @@ export default function App() {
|
|||
return params.get('og') === '1';
|
||||
}, []);
|
||||
|
||||
// Funnel fix: pre-seed high-intent filters on a cold (empty) map open so first-time visitors
|
||||
// immediately see value and are one filter from the demo cap. Deep links (OG screenshots, the
|
||||
// SEO landing-page CTAs) already carry filters, so they're left as-is. See DEFAULT_DEMO_FILTERS.
|
||||
const initialMapFilters = useMemo(() => {
|
||||
if (isScreenshotMode || isOgMode) return mapUrlState.filters;
|
||||
return Object.keys(mapUrlState.filters).length === 0
|
||||
? { ...DEFAULT_DEMO_FILTERS }
|
||||
: mapUrlState.filters;
|
||||
}, [mapUrlState.filters, isScreenshotMode, isOgMode]);
|
||||
// Funnel fix: pre-seed high-intent defaults on a cold map open so first-time visitors immediately
|
||||
// see value and are one filter from the demo cap. "Cold" means the URL carries neither filters nor
|
||||
// a travel time; a deep link (OG screenshot, SEO landing-page CTA) sets at least one of those and
|
||||
// is left as-is, as is the non-interactive screenshot/OG render. See DEFAULT_DEMO_FILTERS.
|
||||
const isColdMapOpen = useMemo(() => {
|
||||
if (isScreenshotMode || isOgMode) return false;
|
||||
const hasFilters = Object.keys(mapUrlState.filters).length > 0;
|
||||
const hasTravel = (mapUrlState.travelTime?.entries?.length ?? 0) > 0;
|
||||
return !hasFilters && !hasTravel;
|
||||
}, [mapUrlState.filters, mapUrlState.travelTime, isScreenshotMode, isOgMode]);
|
||||
|
||||
const initialMapFilters = useMemo(
|
||||
() => (isColdMapOpen ? { ...DEFAULT_DEMO_FILTERS } : mapUrlState.filters),
|
||||
[isColdMapOpen, mapUrlState.filters]
|
||||
);
|
||||
|
||||
const initialMapTravelTime = useMemo(
|
||||
() => (isColdMapOpen ? DEFAULT_DEMO_TRAVEL : mapUrlState.travelTime),
|
||||
[isColdMapOpen, mapUrlState.travelTime]
|
||||
);
|
||||
|
||||
const [features, setFeatures] = useState<FeatureMeta[]>([]);
|
||||
const [poiCategoryGroups, setPOICategoryGroups] = useState<POICategoryGroup[]>([]);
|
||||
|
|
@ -283,6 +298,7 @@ export default function App() {
|
|||
loginWithOAuth,
|
||||
logout,
|
||||
requestPasswordReset,
|
||||
confirmPasswordReset,
|
||||
refreshAuth,
|
||||
clearError,
|
||||
} = useAuth();
|
||||
|
|
@ -708,6 +724,17 @@ export default function App() {
|
|||
<LearnPage />
|
||||
) : activePage === 'terms' || activePage === 'privacy' ? (
|
||||
<LegalPage kind={activePage} />
|
||||
) : activePage === 'reset-password' ? (
|
||||
<ResetPasswordPage
|
||||
onConfirm={confirmPasswordReset}
|
||||
onLoginClick={() => {
|
||||
navigateTo('home');
|
||||
openAuthModal('login');
|
||||
}}
|
||||
loading={authLoading}
|
||||
error={authError}
|
||||
onClearError={clearError}
|
||||
/>
|
||||
) : isSeoLandingPage(activePage) ? (
|
||||
<SeoLandingPage pageKey={activePage} onOpenDashboard={() => navigateTo('dashboard')} />
|
||||
) : isSeoContentPage(activePage) ? (
|
||||
|
|
@ -769,7 +796,7 @@ export default function App() {
|
|||
}}
|
||||
onDashboardReadyChange={setDashboardReady}
|
||||
isMobile={isMobile}
|
||||
initialTravelTime={mapUrlState.travelTime}
|
||||
initialTravelTime={initialMapTravelTime}
|
||||
initialPostcode={mapUrlState.postcode}
|
||||
shareCode={mapUrlState.share}
|
||||
onSessionRejected={() => {
|
||||
|
|
|
|||
53
frontend/src/components/account/ResetPasswordPage.test.tsx
Normal file
53
frontend/src/components/account/ResetPasswordPage.test.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('react-i18next', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-i18next')>()),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import ResetPasswordPage from './ResetPasswordPage';
|
||||
|
||||
function renderPage(search: string, onConfirm = vi.fn(async () => {})) {
|
||||
window.history.replaceState({}, '', `/reset-password${search}`);
|
||||
const onLoginClick = vi.fn();
|
||||
render(
|
||||
<ResetPasswordPage
|
||||
onConfirm={onConfirm}
|
||||
onLoginClick={onLoginClick}
|
||||
loading={false}
|
||||
error={null}
|
||||
onClearError={() => {}}
|
||||
/>
|
||||
);
|
||||
return { onConfirm, onLoginClick };
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('ResetPasswordPage', () => {
|
||||
it('shows an incomplete-link message and no form when the token is missing', () => {
|
||||
const { onLoginClick } = renderPage('');
|
||||
expect(screen.getByText('auth.resetMissingToken')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('auth.newPassword')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'auth.goToLogin' }));
|
||||
expect(onLoginClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('submits the URL token plus the new password, then shows success', async () => {
|
||||
const { onConfirm } = renderPage('?token=reset-token-123');
|
||||
const input = screen.getByLabelText('auth.newPassword') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'sup3rs3cret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'auth.updatePassword' }));
|
||||
expect(onConfirm).toHaveBeenCalledWith('reset-token-123', 'sup3rs3cret');
|
||||
expect(await screen.findByText('auth.resetSuccessBody')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('toggles password visibility', () => {
|
||||
renderPage('?token=abc');
|
||||
const input = screen.getByLabelText('auth.newPassword') as HTMLInputElement;
|
||||
expect(input.type).toBe('password');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'auth.showPassword' }));
|
||||
expect(input.type).toBe('text');
|
||||
});
|
||||
});
|
||||
137
frontend/src/components/account/ResetPasswordPage.tsx
Normal file
137
frontend/src/components/account/ResetPasswordPage.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EyeIcon } from '../ui/icons/EyeIcon';
|
||||
import { EyeOffIcon } from '../ui/icons/EyeOffIcon';
|
||||
|
||||
/**
|
||||
* Landing page for the PocketBase password-reset email link
|
||||
* (`/reset-password?token=…`). Reads the one-time token from the URL, takes a new
|
||||
* password, and calls `confirmPasswordReset`. PocketBase does not issue a session
|
||||
* on confirm, so on success we point the user at the log in screen rather than
|
||||
* auto-authenticating them.
|
||||
*/
|
||||
export default function ResetPasswordPage({
|
||||
onConfirm,
|
||||
onLoginClick,
|
||||
loading,
|
||||
error,
|
||||
onClearError,
|
||||
}: {
|
||||
onConfirm: (token: string, password: string) => Promise<void>;
|
||||
onLoginClick: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onClearError: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const token = useMemo(
|
||||
() => new URLSearchParams(window.location.search).get('token') ?? '',
|
||||
[]
|
||||
);
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const fieldId = useId();
|
||||
const passwordInputId = `${fieldId}-new-password`;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onClearError();
|
||||
try {
|
||||
await onConfirm(token, password);
|
||||
setDone(true);
|
||||
} catch {
|
||||
// Error surfaces through the `error` prop (set by useAuth).
|
||||
}
|
||||
},
|
||||
[onConfirm, token, password, onClearError]
|
||||
);
|
||||
|
||||
const loginButtonClass =
|
||||
'w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 transition-colors text-center';
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-start justify-center bg-warm-50 dark:bg-navy-950 px-4 py-16">
|
||||
<div className="w-full max-w-sm bg-white dark:bg-warm-900 rounded-lg shadow-sm border border-warm-200 dark:border-warm-700 p-6">
|
||||
<h1 className="text-lg font-semibold text-navy-950 dark:text-white mb-4">
|
||||
{t('auth.resetPassword')}
|
||||
</h1>
|
||||
|
||||
{!token ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-warm-600 dark:text-warm-300">{t('auth.resetMissingToken')}</p>
|
||||
<button type="button" onClick={onLoginClick} className={loginButtonClass}>
|
||||
{t('auth.goToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
) : done ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-teal-700 dark:text-teal-400">{t('auth.resetSuccessBody')}</p>
|
||||
<button type="button" onClick={onLoginClick} className={loginButtonClass}>
|
||||
{t('auth.goToLogin')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={passwordInputId}
|
||||
className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"
|
||||
>
|
||||
{t('auth.newPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id={passwordInputId}
|
||||
name="new-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
className="w-full pl-3 pr-10 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
|
||||
placeholder={t('auth.newPasswordPlaceholder')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||
aria-pressed={showPassword}
|
||||
title={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200 outline-none focus-visible:text-teal-600 dark:focus-visible:text-teal-400"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeIcon filled={false} className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-300">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 rounded bg-teal-600 text-white text-sm font-medium hover:bg-teal-700 dark:hover:bg-teal-600 disabled:opacity-50 disabled:cursor-wait transition-colors"
|
||||
>
|
||||
{loading ? t('auth.pleaseWait') : t('auth.updatePassword')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoginClick}
|
||||
className="w-full text-center text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
|
||||
>
|
||||
{t('auth.backToLogin')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -143,11 +143,11 @@ export default memo(function AiFilterInput({
|
|||
|
||||
if (!expanded) {
|
||||
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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -657,6 +657,16 @@ const fr: Translations = {
|
|||
registrationFailed: 'Échec de l’inscription',
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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: 'पब',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '酒吧',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
22
frontend/src/lib/postcode.ts
Normal file
22
frontend/src/lib/postcode.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Postcode-component helpers, mirroring the server's `postcode_outcode` /
|
||||
* `postcode_sector` (server-rs/src/utils.rs) so labels match the aggregations.
|
||||
*/
|
||||
|
||||
/** Outward code of a postcode: the part before the space. "E14 2DG" -> "E14". */
|
||||
export function postcodeOutcode(postcode: string): string | null {
|
||||
const trimmed = postcode.trim().toUpperCase();
|
||||
const space = trimmed.indexOf(' ');
|
||||
return space > 0 ? trimmed.slice(0, space) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Postcode sector: the outward code, the space, and the first character of the
|
||||
* inward code. "E14 2DG" -> "E14 2".
|
||||
*/
|
||||
export function postcodeSector(postcode: string): string | null {
|
||||
const trimmed = postcode.trim().toUpperCase();
|
||||
const space = trimmed.indexOf(' ');
|
||||
if (space <= 0 || space + 1 >= trimmed.length) return null;
|
||||
return trimmed.slice(0, space + 2);
|
||||
}
|
||||
|
|
@ -1018,6 +1018,14 @@ _LISTING_OVERLAY_SOURCES: tuple[tuple[str, str, pl.DataType], ...] = (
|
|||
("Listing date", "_actual_listing_date", pl.Datetime("us")),
|
||||
("Listing 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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue