This commit is contained in:
Andras Schmelczer 2026-07-12 20:37:39 +01:00
parent 9e4e65fa2a
commit ca771a7edf
32 changed files with 1467 additions and 109 deletions

View file

@ -8,7 +8,10 @@ import type {
FeatureMeta,
FilterExclusion,
HexagonStatsResponse,
PriceMetric,
PricePoint,
} from '../../types';
import { postcodeOutcode, postcodeSector } from '../../lib/postcode';
import { travelFieldKey, type TravelTimeEntry } from '../../hooks/useTravelTime';
import type { HexagonLocation } from '../../lib/external-search';
import {
@ -289,6 +292,7 @@ export default function AreaPane({
return [{ name: STATION_GROUP_NAME, features: [] }, ...paneFeatureGroups];
}, [paneFeatureGroups, hexagonLocation]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
const [priceMetric, setPriceMetric] = useState<PriceMetric>('price');
const { scrollRef, onScroll } = useRetainedScrollTop<HTMLDivElement>({
restoreKey: scrollRestoreKey ?? hexagonId,
scrollTopRef,
@ -548,14 +552,105 @@ export default function AreaPane({
{stats.price_history &&
(() => {
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
return uniqueYears.size > 1 ? (
if (uniqueYears.size <= 1) return null;
const charts: {
key: string;
label: string;
dot: string;
points: PricePoint[];
}[] = [
{
key: 'area',
label: t('areaPane.priceHistoryThisArea'),
dot: 'bg-teal-500 dark:bg-teal-400',
points: stats.price_history,
},
];
// Sector/outcode context is postcode-only: a hexagon cell
// straddles arbitrary postcodes, so its sector/outcode is not a
// meaningful aggregation unit. The Total/Per-m² toggle still
// applies to the area chart in both cases.
if (isPostcode && hexagonId) {
const sector = postcodeSector(hexagonId);
const outcode = postcodeOutcode(hexagonId);
if (sector && stats.sector_price_history?.length) {
charts.push({
key: 'sector',
label: sector,
dot: 'bg-indigo-500 dark:bg-indigo-400',
points: stats.sector_price_history,
});
}
if (outcode && stats.outcode_price_history?.length) {
charts.push({
key: 'outcode',
label: outcode,
dot: 'bg-amber-500 dark:bg-amber-400',
points: stats.outcode_price_history,
});
}
}
const hasPerSqm = charts.some((c) =>
c.points.some((p) => p.price_per_sqm != null)
);
const metric: PriceMetric = hasPerSqm ? priceMetric : 'price';
const optionClass = (active: boolean) =>
`px-2 py-0.5 text-[11px] font-medium border-r last:border-r-0 border-warm-200 dark:border-warm-700 transition-colors ${
active
? 'bg-teal-600 text-white dark:bg-teal-500'
: 'text-warm-600 hover:bg-warm-100 dark:text-warm-300 dark:hover:bg-warm-700'
}`;
return (
<div className="mx-3 mt-2 bg-warm-50 dark:bg-warm-800 rounded p-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
{t('areaPane.priceHistory')}
</span>
<PriceHistoryChart points={stats.price_history} />
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-xs text-warm-700 dark:text-warm-300">
{t('areaPane.priceHistory')}
</span>
{hasPerSqm && (
<div
className="grid grid-cols-2 overflow-hidden rounded-md border border-warm-200 dark:border-warm-700"
role="radiogroup"
aria-label={t('areaPane.priceMetric')}
>
<button
type="button"
role="radio"
aria-checked={metric === 'price'}
onClick={() => setPriceMetric('price')}
className={optionClass(metric === 'price')}
>
{t('areaPane.priceMetricTotal')}
</button>
<button
type="button"
role="radio"
aria-checked={metric === 'price_per_sqm'}
onClick={() => setPriceMetric('price_per_sqm')}
className={optionClass(metric === 'price_per_sqm')}
>
{t('areaPane.priceMetricPerSqm')}
</button>
</div>
)}
</div>
{charts.map((chart) => (
<div key={chart.key} className={chart.key === 'area' ? '' : 'mt-1.5'}>
{charts.length > 1 && (
<span className="flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-warm-500 dark:text-warm-400">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${chart.dot}`}
/>
{chart.label}
</span>
)}
<PriceHistoryChart points={chart.points} metric={metric} />
</div>
))}
</div>
) : null;
);
})()}
{displayFeatureGroups.map((group) => {
const showNearbyStations =

View file

@ -105,7 +105,7 @@ export default function FeatureBrowser({
return (
<>
<div className="shrink-0 px-2 py-1.5 border-b border-warm-200 dark:border-navy-700">
<div className="shrink-0 px-2 py-1 border-b border-warm-200 dark:border-navy-700">
<SearchInput
value={search}
onChange={setSearch}
@ -125,7 +125,7 @@ export default function FeatureBrowser({
toggleGroup(group.name);
onGroupToggle?.(group.name, willExpand);
}}
className="px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
className="px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 sticky top-0 z-30 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length +
@ -141,7 +141,7 @@ export default function FeatureBrowser({
return (
<div
key={mode}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 cursor-pointer"
>
<div
className="flex items-center gap-2 min-w-0"
@ -184,7 +184,7 @@ export default function FeatureBrowser({
return (
<div
key={f.name}
className="flex items-center justify-between px-3 py-1.5 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
className="flex items-center justify-between px-3 py-1.5 md:py-1 hover:bg-teal-50 dark:hover:bg-teal-900/30 dark:text-warm-300"
>
<div className="min-w-0 mr-2">
<FeatureLabel feature={f} size="sm" description={f.description} />

View file

@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from '@testing-library/react';
import { ListingPopupSingleContent } from './ListingPopups';
import type { ActualListing } from '../../types';
// No global RTL setup file registers auto-cleanup, so unmount between cases to
// keep each render isolated in the shared jsdom document.
afterEach(cleanup);
// Minimal react-i18next stub: t() echoes a readable label per key; i18n.language
// is fixed so date formatting is deterministic.
vi.mock('react-i18next', () => {
const labels: Record<string, string> = {
'listing.priceHistory': 'Price history',
'listing.priceListed': 'Listed',
'listing.priceReduced': 'Reduced',
'listing.priceIncreased': 'Increased',
'listing.openListing': 'Open listing',
'listing.viewed': 'Viewed',
};
return {
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
labels[key] ?? (opts ? `${key} ${JSON.stringify(opts)}` : key),
i18n: { language: 'en-GB' },
}),
};
});
function baseListing(overrides: Partial<ActualListing> = {}): ActualListing {
return {
lat: 51.5,
lon: -0.1,
postcode: 'SW9 0HD',
listing_url: 'https://www.rightmove.co.uk/properties/1',
asking_price: 490000,
features: [],
...overrides,
};
}
describe('ListingPopupSingleContent price history', () => {
it('renders points newest-first with signed deltas and reason labels', () => {
const listing = baseListing({
price_history: [
{ date: '2026-07-01', price: 500000, reason: 'listed' },
{ date: '2026-07-19', price: 480000, reason: 'reduced' },
{ date: '2026-07-26', price: 490000, reason: 'increased' },
],
});
const { getByText, container } = render(
<ListingPopupSingleContent listing={listing} clickedUrls={new Set()} onOpen={() => {}} />
);
getByText('Price history');
// Newest first: increased row shows +£10,000 vs the £480k point before it.
const items = Array.from(container.querySelectorAll('ol li'));
expect(items).toHaveLength(3);
expect(items[0].textContent).toContain('£490,000');
expect(items[0].textContent).toContain('+£10,000');
expect(items[0].textContent).toContain('Increased');
// Middle: the reduction from £500k -> £480k.
expect(items[1].textContent).toContain('£480,000');
expect(items[1].textContent).toContain('£20,000');
expect(items[1].textContent).toContain('Reduced');
// Oldest: the initial listing, no delta.
expect(items[2].textContent).toContain('£500,000');
expect(items[2].textContent).toContain('Listed');
expect(items[2].textContent).not.toContain('£0');
// UTC-stable date: "2026-07-01" must read as 1 Jul regardless of runner TZ.
expect(items[2].textContent).toContain('1 Jul 2026');
});
it('renders a single listed point with no delta', () => {
const listing = baseListing({
price_history: [{ date: '2026-06-15', price: 325000, reason: 'listed' }],
});
const { getByText, container } = render(
<ListingPopupSingleContent listing={listing} clickedUrls={new Set()} onOpen={() => {}} />
);
getByText('Price history');
expect(container.querySelectorAll('ol li')).toHaveLength(1);
});
it('omits the section entirely when there is no history', () => {
const { queryByText } = render(
<ListingPopupSingleContent
listing={baseListing({ price_history: [] })}
clickedUrls={new Set()}
onOpen={() => {}}
/>
);
expect(queryByText('Price history')).toBeNull();
});
});

View file

@ -2,12 +2,89 @@ import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import type { ActualListing } from '../../types';
import type { ActualListing, PriceHistoryPoint } from '../../types';
function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`;
}
function formatHistoryDate(iso: string, locale: string): string {
const parsed = new Date(iso);
if (Number.isNaN(parsed.getTime())) return iso;
// `iso` is a UTC calendar date ("YYYY-MM-DD"), parsed as UTC midnight. Format
// in UTC too, or a viewer west of UTC would see every date shifted a day back.
return parsed.toLocaleDateString(locale, {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
}
function reasonLabel(reason: string, t: TFunction): string {
switch (reason) {
case 'reduced':
return t('listing.priceReduced');
case 'increased':
return t('listing.priceIncreased');
default:
return t('listing.priceListed');
}
}
/** Compact asking-price timeline for the hover card. Renders most-recent first,
* with the £ change from the prior point on each reduction/increase. Accrues from
* the scraper's forward-only store, so a fresh listing shows a single point. */
function ListingPriceHistory({ history }: { history: PriceHistoryPoint[] }) {
const { t, i18n } = useTranslation();
if (history.length === 0) return null;
// history is oldest -> newest; show up to the 5 most recent, newest first.
const shown = history.slice(-5);
const baseIndex = history.length - shown.length;
const rows = shown
.map((point, idx) => {
const globalIdx = baseIndex + idx;
const prev = globalIdx > 0 ? history[globalIdx - 1] : null;
const delta = prev ? point.price - prev.price : 0;
return { point, delta };
})
.reverse();
return (
<div className="mt-2 border-t border-warm-100 pt-1.5 dark:border-warm-700/60">
<div className="text-[11px] font-medium text-warm-500 dark:text-warm-400">
{t('listing.priceHistory')}
</div>
<ol className="mt-1 space-y-0.5">
{rows.map(({ point, delta }, idx) => {
const isReduction = point.reason === 'reduced';
const isIncrease = point.reason === 'increased';
const accent = isReduction
? 'text-emerald-600 dark:text-emerald-400'
: isIncrease
? 'text-red-600 dark:text-red-400'
: 'text-warm-600 dark:text-warm-300';
return (
<li key={idx} className="flex items-baseline justify-between gap-2 text-[11px]">
<span className="flex items-baseline gap-1.5">
<span className={`font-semibold ${accent}`}>{formatListingPrice(point.price)}</span>
{delta !== 0 && (
<span className={accent}>
{delta < 0 ? '' : '+'}£{Math.abs(delta).toLocaleString()}
</span>
)}
</span>
<span className="shrink-0 text-warm-400 dark:text-warm-500">
{reasonLabel(point.reason, t)} · {formatHistoryDate(point.date, i18n.language)}
</span>
</li>
);
})}
</ol>
</div>
);
}
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
const parts: string[] = [];
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
@ -78,6 +155,9 @@ export const ListingPopupSingleContent = memo(function ListingPopupSingleContent
))}
</ul>
)}
{listing.price_history && listing.price_history.length > 0 && (
<ListingPriceHistory history={listing.price_history} />
)}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] font-medium">
{visited && (
<span className="text-violet-600 dark:text-violet-400"> {t('listing.viewed')}</span>

View file

@ -710,6 +710,11 @@ export default function MapPage({
const shareAndSaveView = isMobile
? (mapData.currentVisibleView ?? mapData.currentView)
: mapData.currentView;
// Params for share/save/checkout-return/last-session, deliberately WITHOUT the
// selected postcode: the lat/lon/zoom already convey the location, so focusing
// a postcode adds nothing to a shared link and shouldn't be baked into a saved
// search. The live URL (useUrlSync above) still carries `pc` so a reload
// re-opens the selection.
const dashboardParams = useMemo(
() =>
stateToParams(
@ -723,7 +728,7 @@ export default function MapPage({
activeOverlays,
basemap,
crimeTypes,
selectedPostcodeParam,
undefined,
colorOpacity,
listingsMode
).toString(),
@ -738,7 +743,6 @@ export default function MapPage({
listingsMode,
rightPaneTab,
selectedPOICategories,
selectedPostcodeParam,
shareCode,
shareAndSaveView,
]
@ -894,6 +898,9 @@ export default function MapPage({
total={propertiesTotal}
loading={loadingProperties}
hexagonId={selectedHexagon?.id || null}
statsUseFilters={areaStatsUseFilters}
onStatsUseFiltersChange={setAreaStatsUseFilters}
filtersActive={Object.keys(filters).length + activeEntries.length > 0}
onLoadMore={handleLoadMoreProperties}
scrollTopRef={propertiesPaneScrollTopRef}
scrollRestoreKey={
@ -903,7 +910,17 @@ export default function MapPage({
/>
</Suspense>
),
[handleLoadMoreProperties, loadingProperties, properties, propertiesTotal, selectedHexagon]
[
activeEntries,
areaStatsUseFilters,
filters,
handleLoadMoreProperties,
loadingProperties,
properties,
propertiesTotal,
selectedHexagon,
setAreaStatsUseFilters,
]
);
const poiPane = useMemo(

View file

@ -1,9 +1,11 @@
import { useMemo, useRef, useState, useEffect } from 'react';
import type { PricePoint } from '../../types';
import type { PriceMetric, PricePoint } from '../../types';
import { formatValue } from '../../lib/format';
interface PriceHistoryChartProps {
points: PricePoint[];
/** Which value to plot. Defaults to the absolute sale price. */
metric?: PriceMetric;
}
const PADDING = { top: 8, right: 24, bottom: 20, left: 48 };
@ -22,7 +24,7 @@ interface PriceScale {
ticks: number[];
}
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
export default function PriceHistoryChart({ points, metric = 'price' }: PriceHistoryChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
@ -37,7 +39,18 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
return () => observer.disconnect();
}, []);
// Project each point onto the chosen metric as a plain {year, price} so the
// rest of the chart is metric-agnostic. In per-m² mode, points without a
// recorded floor area drop out.
const plotPoints = useMemo<PricePoint[]>(() => {
if (metric === 'price') return points;
return points
.filter((p) => Number.isFinite(p.price_per_sqm))
.map((p) => ({ year: p.year, price: p.price_per_sqm as number }));
}, [points, metric]);
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
const points = plotPoints;
let yMin = Infinity,
yMax = -Infinity;
for (const p of points) {
@ -83,7 +96,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
priceScale: getPriceScale(points),
medians: meds,
};
}, [points]);
}, [plotPoints]);
const plotW = width - PADDING.left - PADDING.right;
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
@ -104,7 +117,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
return (
<div ref={containerRef} style={{ height: HEIGHT }}>
{width > 0 && (
{width > 0 && plotPoints.length > 0 && (
<svg width={width} height={HEIGHT}>
{/* Grid lines */}
{priceScale.ticks.map((tick) => (
@ -120,7 +133,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
))}
{/* Dots */}
{points.map((p, i) => (
{plotPoints.map((p, i) => (
<circle
key={i}
cx={scaleX(p.year)}

View file

@ -23,6 +23,9 @@ interface PropertiesPaneProps {
total: number;
loading: boolean;
hexagonId: string | null;
statsUseFilters: boolean;
onStatsUseFiltersChange: (useFilters: boolean) => void;
filtersActive: boolean;
onLoadMore: () => void;
onNavigateToSource?: (slug: string) => void;
scrollTopRef?: MutableRefObject<number>;
@ -35,6 +38,9 @@ export function PropertiesPane({
total,
loading,
hexagonId,
statsUseFilters,
onStatsUseFiltersChange,
filtersActive,
onLoadMore,
onNavigateToSource,
scrollTopRef,
@ -106,13 +112,41 @@ export function PropertiesPane({
</InfoPopup>
)}
<div className="p-2">
<div className="p-2 space-y-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('propertyCard.searchPlaceholder')}
className="p-2"
/>
{filtersActive && (
<div className="grid grid-cols-2 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
<button
type="button"
aria-pressed={statsUseFilters}
onClick={() => onStatsUseFiltersChange(true)}
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
statsUseFilters
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.matchingFiltersOption')}
</button>
<button
type="button"
aria-pressed={!statsUseFilters}
onClick={() => onStatsUseFiltersChange(false)}
className={`min-w-0 rounded px-2 py-1 text-center text-xs font-medium leading-tight break-words ${
!statsUseFilters
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.allPropertiesOption')}
</button>
</div>
)}
</div>
<div>

View file

@ -448,12 +448,12 @@ export function ActiveFilterList({
onToggleGroup(group.name);
onGroupToggle?.(group.name, !expanded);
}}
className="sticky top-0 z-30 px-3 py-2.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
className="sticky top-0 z-30 px-3 py-1.5 text-sm font-bold text-navy-950 bg-warm-200 dark:bg-navy-900 dark:text-warm-100 hover:bg-warm-200 dark:hover:bg-warm-800"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
</CollapsibleGroupHeader>
{expanded && (
<div className="px-2 py-1.5 space-y-3.5">
<div className="px-2 py-1 space-y-2">
{group.name === TRANSPORT_GROUP && travelCards}
{group.features.map((feature) => renderFeatureCard(feature))}
</div>

View file

@ -123,7 +123,7 @@ export function ElectionVoteShareFilterCard({
return (
<div
data-filter-name={ELECTION_VOTE_SHARE_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
@ -151,7 +151,7 @@ export function ElectionVoteShareFilterCard({
</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.party')}
</label>
<div className="relative">

View file

@ -77,7 +77,7 @@ export function NumericFeatureFilterCard({
return (
<div
data-filter-name={feature.name}
className={`space-y-0.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`space-y-0.5 px-2 py-1 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel

View file

@ -128,7 +128,7 @@ export function PoiDistanceFilterCard({
return (
<div
data-filter-name={filterName}
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
@ -156,7 +156,7 @@ export function PoiDistanceFilterCard({
</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.poiType')}
</label>
<PoiTypeDropdown

View file

@ -107,7 +107,7 @@ export function SchoolFilterCard({
return (
<div
data-filter-name={SCHOOL_FILTER_NAME}
className={`space-y-1.5 px-2 py-1.5 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
className={`space-y-1 px-2 py-1 rounded ${isActive ? 'ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/30' : isPinned ? 'ring-2 ring-teal-400 bg-teal-50/50 dark:bg-teal-900/20' : ''}`}
>
<div className="relative z-10 flex items-center justify-between gap-1">
<FeatureLabel
@ -127,7 +127,7 @@ export function SchoolFilterCard({
/>
</div>
<div className="space-y-1.5">
<div className="space-y-1">
<div>
<div className="mb-0.5 text-[10px] font-medium uppercase text-warm-400 dark:text-warm-500">
{t('filters.schoolType')}

View file

@ -118,7 +118,7 @@ export function SliderLabels({
if (feature && onValueChange) {
return (
<div className="relative h-4 mt-2 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<div className="relative h-4 mt-1.5 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<EditableLabel
value={labels[0]}
formatted={minLabel}

View file

@ -159,7 +159,7 @@ export function VariantFilterCard({
return (
<div
data-filter-name={config.filterName}
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
@ -190,7 +190,7 @@ export function VariantFilterCard({
so the dropdown is hidden and only the window toggle + slider remain. */}
{variantOptions.length > 1 && (
<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(config.dropdownLabelKey)}
</label>
<div className="relative">
@ -216,7 +216,7 @@ export function VariantFilterCard({
{windowConfig && currentWindow && windowOptions.length > 1 && (
<div>
{windowConfig.labelKey && (
<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(windowConfig.labelKey)}
</label>
)}

View file

@ -179,7 +179,7 @@ export function DesktopMapPage({
>
<div className="flex-1 flex flex-col overflow-hidden">{filtersPane}</div>
<div
className="w-3 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
className="w-2 cursor-col-resize flex items-center justify-center group bg-warm-100 dark:bg-navy-800 hover:bg-warm-200 dark:hover:bg-navy-700 border-x border-warm-200 dark:border-navy-700"
{...leftPaneHandlers}
>
<div className="flex flex-col gap-1.5">

View file

@ -37,7 +37,8 @@ export type Page =
| 'privacy'
| 'account'
| 'saved'
| 'invite';
| 'invite'
| 'reset-password';
export interface HeaderExportState {
onExport: (options?: { postcodes?: string[] }) => void;
@ -65,6 +66,7 @@ export const PAGE_PATHS: Record<Page, string> = {
saved: '/saved',
account: '/account',
invite: '/invite',
'reset-password': '/reset-password',
};
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
@ -206,33 +208,6 @@ export default function Header({
return (
<>
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{showEditingBar && (
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-0 flex items-center justify-center px-4">
<div className="pointer-events-auto flex items-center gap-3 max-w-[60%]">
<span className="text-sm text-warm-300 truncate" title={editingSearch.name}>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{ strong: <strong className="font-semibold text-white" /> }}
/>
</span>
<button
onClick={onCancelEdit}
className="cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{t('common.cancel')}
</button>
<button
onClick={onUpdateEdit}
disabled={savingSearch || dashboardActionsBlocked}
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
</div>
)}
{/* Left: Logo + nav */}
<div className="flex min-w-0 items-center gap-4">
<a
@ -246,9 +221,9 @@ export default function Header({
</span>
</a>
{/* Desktop nav: hidden while the saved-search "is being updated" banner
is shown so the centered pointer-events-auto banner can't overlap (and
block clicks on) the Invite Friends / Learn / Pricing links at ~1366px. */}
{/* Desktop nav: hidden while the saved-search "is being updated" bar is
shown so the centered edit bar has room and the header can't get
cramped (the bar would otherwise crowd the Learn / Pricing links). */}
{!useSidebarNav && !showEditingBar && (
<nav className="top-menu flex items-center">
<a
@ -287,6 +262,36 @@ export default function Header({
)}
</div>
{/* Saved-search "is being updated" bar. In normal flow (not an absolute
overlay) between the logo and the right-side actions so flexbox
reserves its space: the Cancel / Update buttons can never overlap the
Share / Export buttons. Text truncates; the buttons stay put. */}
{showEditingBar && (
<div className="flex min-w-0 flex-1 items-center justify-center gap-3 px-3">
<span className="min-w-0 truncate text-sm text-warm-300" title={editingSearch.name}>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{ strong: <strong className="font-semibold text-white" /> }}
/>
</span>
<button
onClick={onCancelEdit}
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{t('common.cancel')}
</button>
<button
onClick={onUpdateEdit}
disabled={savingSearch || dashboardActionsBlocked}
className="shrink-0 cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
)}
{/* Right side */}
<div className="ml-auto flex shrink-0 items-center gap-2">
{/* Desktop-only dashboard actions: shown to everyone; a logged-out click

View file

@ -0,0 +1,22 @@
interface IconProps {
className?: string;
}
export function EyeOffIcon({ className = 'w-7 h-7' }: IconProps) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 1 12s4 8 11 8a9.74 9.74 0 0 0 5.39-1.61" />
<line x1="2" y1="2" x2="22" y2="22" />
</svg>
);
}

View file

@ -9,6 +9,7 @@ export { CloseIcon } from './CloseIcon';
export { DownloadIcon } from './DownloadIcon';
export { ExpandIcon } from './ExpandIcon';
export { EyeIcon } from './EyeIcon';
export { EyeOffIcon } from './EyeOffIcon';
export { FilterIcon } from './FilterIcon';
export { GoogleIcon } from './GoogleIcon';
export { GraduationCapIcon } from './GraduationCapIcon';

View file

@ -93,23 +93,23 @@ interface UseFiltersOptions {
onFilterLimitReached?: () => void;
}
// Applied in order: each normalizer folds its own raw feature names into a single
// folded filter. Council folds AFTER tenure so a bare "% Social rent" is claimed by
// tenure first.
const FILTER_NORMALIZERS: Array<(filters: FeatureFilters) => FeatureFilters> = [
normalizeSchoolFilters,
normalizeSpecificCrimeFilters,
normalizeCrimeSeverityFilters,
normalizeElectionVoteShareFilters,
normalizeEthnicityFilters,
normalizeQualificationFilters,
normalizeTenureFilters,
normalizeCouncilFilters,
normalizePoiDistanceFilters,
];
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
return normalizePoiDistanceFilters(
// Council folds AFTER tenure so a bare "% Social rent" is claimed by tenure.
normalizeCouncilFilters(
normalizeTenureFilters(
normalizeQualificationFilters(
normalizeEthnicityFilters(
normalizeElectionVoteShareFilters(
normalizeCrimeSeverityFilters(
normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters))
)
)
)
)
)
)
);
return FILTER_NORMALIZERS.reduce((acc, normalize) => normalize(acc), filters);
}
function getBackendFeatureName(name: string): string {

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import pb from '../lib/pocketbase';
import { apiUrl, authHeaders } from '../lib/api';
import { trackEvent } from '../lib/analytics';
import { stripSelectedPostcodeParam } from '../lib/url-state';
export interface SavedSearch {
id: string;
@ -141,7 +142,11 @@ export function useSavedSearches(userId: string | null) {
setSaving(true);
setError(null);
try {
const params = paramsOverride ?? window.location.search.replace(/^\?/, '');
// A saved search stores filter criteria, so drop the transient
// selected-postcode param (a search/map-click leaves it in the URL).
const params = stripSelectedPostcodeParam(
paramsOverride ?? window.location.search.replace(/^\?/, '')
);
// Create record immediately without screenshot
const formData = new FormData();
@ -220,11 +225,14 @@ export function useSavedSearches(userId: string | null) {
);
const updateSearchParams = useCallback(
async (id: string, params: string) => {
async (id: string, rawParams: string) => {
if (!userId) return;
setSaving(true);
setError(null);
try {
// Match saveSearch: a saved search never carries the transient
// selected-postcode param.
const params = stripSelectedPostcodeParam(rawParams);
const record = await pb.collection('saved_searches').update(id, { params });
trackEvent('Search Update');
setSearches((prev) =>

View file

@ -1,4 +1,5 @@
import type { ViewState } from '../types';
import type { TravelTimeInitial } from '../hooks/useTravelTime';
export const INITIAL_RETRY_MS = 1000;
export const MAX_RETRY_MS = 10000;
@ -45,14 +46,32 @@ export function filterCapFor(isLoggedIn: boolean, filtersUnlimited: boolean): nu
}
/** Funnel fix (growth): a cold map open lands empty, so first-time visitors never feel the value
* or the 3-filter cap. These two high-intent filters (value for money + good secondary schools)
* are pre-seeded when the map opens with no filters in the URL, so the map is immediately useful
* and one more filter hits the cap. Deep links (OG screenshots, the SEO landing-page CTAs) carry
* their own filters and are left untouched. Unknown feature names are dropped safely by useFilters.
* Tune or empty this object to change/disable the behaviour. */
* or the 3-filter cap. On a cold open (no filters AND no travel time in the URL) we pre-seed the
* price filter plus a public-transport commute card, so the map is immediately useful and framed
* around the two highest-intent decisions: budget and commute. Deep links (OG screenshots, the
* SEO landing-page CTAs) carry their own filters/travel and are left untouched. Unknown feature
* names are dropped safely by useFilters. Tune or empty these to change/disable the behaviour. */
export const DEFAULT_DEMO_FILTERS: Record<string, [number, number]> = {
// 'Est. price per sqm': [0, 7000],
// 'Good+ secondary school catchments': [1, 11],
'Estimated current price': [0, 600000],
};
/** The public-transport commute half of the cold-open defaults. A single transit entry with no
* destination selected: it renders the "Public Transport" card (prompting the visitor to pick a
* destination) without filtering the map or touching the URL until they choose one. Mirrors what
* clicking "add public transport" produces, so it flows through useTravelTime unchanged. */
export const DEFAULT_DEMO_TRAVEL: TravelTimeInitial = {
entries: [
{
mode: 'transit',
slug: '',
label: '',
timeRange: null,
useBest: false,
noChange: false,
oneChange: false,
noBuses: false,
},
],
};
/**

View file

@ -19,6 +19,7 @@ export const POI_DISTANCE_FILTER_KEY_PREFIX = `${POI_DISTANCE_FILTER_NAME}:`;
const TRANSPORT_POI_CATEGORIES = new Set([
'Airport',
'Any station',
'Bus station',
'Bus stop',
'DLR station',

View file

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { parseUrlState, stateToParams } from './url-state';
import { parseUrlState, stateToParams, stripSelectedPostcodeParam } from './url-state';
import { DEFAULT_OVERLAY_IDS } from './overlays';
import { INITIAL_VIEW_STATE } from './consts';
import { createSchoolFilterKey } from './school-filter';
@ -60,6 +60,24 @@ describe('url-state', () => {
]);
});
it('strips the selected-postcode param for saved searches, keeping filters intact', () => {
const params =
'lat=51.5074&lon=-0.1278&zoom=12.5&filter=Last%20known%20price:100000:500000&pc=SW1A%201AA&poi=supermarket';
const stripped = stripSelectedPostcodeParam(params);
const result = new URLSearchParams(stripped);
expect(result.has('pc')).toBe(false);
expect(result.get('filter')).toBe('Last known price:100000:500000');
expect(result.get('poi')).toBe('supermarket');
expect(result.get('lat')).toBe('51.5074');
});
it('returns the query string unchanged when no selected-postcode param is present', () => {
const params = 'filter=Last%20known%20price:100000:500000&poi=supermarket';
expect(stripSelectedPostcodeParam(params)).toBe(params);
});
it('leaves POIs unselected when URL params are omitted', () => {
const state = parseUrlState();

View file

@ -92,6 +92,23 @@ const CRIME_TYPES_NONE_PARAM = '__none';
const OVERLAY_NONE_PARAM = '__none';
const CRIME_OVERLAY_ID: OverlayId = 'crime-hotspots';
/** URL param holding the currently focused postcode (from a search or a map
* click). It reflects a transient selection, not search criteria, so a live or
* shared link keeps it but a saved search must not bake it in. */
export const SELECTED_POSTCODE_PARAM = 'pc';
/**
* Drop the transient selected-postcode param from a serialized query string so a
* saved search captures the filter criteria, not whichever postcode the user
* last clicked. Takes and returns a query string without the leading '?'.
*/
export function stripSelectedPostcodeParam(params: string): string {
const parsed = new URLSearchParams(params);
if (!parsed.has(SELECTED_POSTCODE_PARAM)) return params;
parsed.delete(SELECTED_POSTCODE_PARAM);
return parsed.toString();
}
export interface UrlState {
viewState: ViewState;
/** True only when the URL carried explicit lat/lon/zoom (shared/dashboard link).
@ -406,7 +423,7 @@ export function parseUrlState(): UrlState {
// Selected postcode. This is also accepted as the historical one-time
// navigate-to-postcode param used by saved-property links.
const pc = params.get('pc');
const pc = params.get(SELECTED_POSTCODE_PARAM);
if (pc) {
result.postcode = pc;
}

View file

@ -158,6 +158,15 @@ export interface ActualListing {
listing_status?: string;
listing_date_iso?: string;
features: string[];
price_history?: PriceHistoryPoint[];
}
/** One observed point on a listing's asking-price timeline, accrued across
* scrapes. `reason` is 'listed' | 'reduced' | 'increased'; `date` is YYYY-MM-DD. */
export interface PriceHistoryPoint {
date: string;
price: number;
reason: string;
}
export interface ActualListingsResponse {
@ -303,8 +312,14 @@ export interface EnumFeatureStats {
export interface PricePoint {
year: number;
price: number;
/** Sale price per square metre (sale price / EPC floor area). Absent where no
* floor area is recorded; the per-m² view drops those points. */
price_per_sqm?: number;
}
/** Which value a price-history chart plots. */
export type PriceMetric = 'price' | 'price_per_sqm';
export interface CrimeYearPoint {
year: number;
count: number;
@ -369,6 +384,12 @@ export interface HexagonStatsResponse {
numeric_features: NumericFeatureStats[];
enum_features: EnumFeatureStats[];
price_history?: PricePoint[];
/** Price history for every sale in the selection's postcode sector (e.g.
* "E14 2"), filter-independent: wider-area context for the selection's chart. */
sector_price_history?: PricePoint[];
/** Price history for every sale in the selection's outward code (e.g. "E14"),
* filter-independent. */
outcode_price_history?: PricePoint[];
/** Per-crime-type per-year counts averaged across the selection. */
crime_by_year?: CrimeYearStats[];
/**

View file

@ -7,9 +7,13 @@ from pathlib import Path
import pytest
from pipeline.download.transit_network import (
STATION_COORD_OVERRIDES,
_repair_stop_coordinate,
clean_national_rail_gtfs,
convert_high_freq_to_frequency_based,
validate_gtfs_feed,
validate_london_coverage,
validate_stop_geometry,
)
@ -231,9 +235,7 @@ def test_validate_gtfs_feed_zero_and_empty_coords(tmp_path: Path) -> None:
feed = _make_gtfs(
tmp_path / "feed.zip",
stops=(
"stop_id,stop_name,stop_lat,stop_lon\n"
"STOP_A,Nowhere,0,0\n"
"STOP_B,Blank,,\n"
"stop_id,stop_name,stop_lat,stop_lon\nSTOP_A,Nowhere,0,0\nSTOP_B,Blank,,\n"
),
)
with pytest.raises(RuntimeError, match=r"plausible UK coordinates"):
@ -281,3 +283,254 @@ def test_validate_gtfs_feed_not_a_zip(tmp_path: Path) -> None:
bogus.write_text("not a zip")
with pytest.raises(RuntimeError, match="not a valid zip"):
validate_gtfs_feed(bogus, "bogus feed", today=TODAY)
# ── _repair_stop_coordinate ───────────────────────────────────────────────────
@pytest.mark.parametrize(
"stop_id,lat,lon,expected",
[
# Known-bad stations get an authoritative override (TCR ships transposed,
# BDS ships a wrong-signed longitude; both are in STATION_COORD_OVERRIDES).
("TCR", -0.1306, 51.5163, (*STATION_COORD_OVERRIDES["TCR"], "override")),
("BDS", 51.514, 0.15, (*STATION_COORD_OVERRIDES["BDS"], "override")),
# A plausible UK coordinate is left untouched.
("ZFD", 51.5205, -0.1050, (51.5205, -0.1050, "keep")),
# An unknown station with lat/lon transposed is swapped back generically.
("ZZZ", -0.13, 51.51, (51.51, -0.13, "transpose")),
# Genuinely out-of-area garbage (Irish CIE South Atlantic) is neutralised.
("IEP", -4.172, -14.5154, (54.0, -2.0, "dump")),
# Missing coordinates are neutralised too.
("NUL", None, None, (54.0, -2.0, "dump")),
],
)
def test_repair_stop_coordinate(stop_id, lat, lon, expected) -> None:
assert _repair_stop_coordinate(stop_id, lat, lon) == expected
def test_clean_national_rail_repairs_broken_station_coords(tmp_path: Path) -> None:
"""End-to-end: the cleaner repairs the exact TCR/BDS failure modes.
TCR (transposed) and BDS (wrong-signed lon) are corrected to their override
coordinates; an unknown transposed stop is swapped back; genuine out-of-area
garbage is dumped; a good coordinate is preserved.
"""
src = tmp_path / "in.zip"
dst = tmp_path / "out.zip"
stops = (
"stop_id,stop_name,stop_lat,stop_lon\n"
"TCR,Tottenham Court Road (Elizabeth line),-0.1306,51.5163\n"
"BDS,Bond Street (Elizabeth line),51.514,0.15\n"
"ZZZ,Transposed Halt,-0.20,51.40\n"
"IEP,Cork (CIE),-4.172,-14.5154\n"
"GUD,Good Station,51.50,-0.10\n"
)
with zipfile.ZipFile(src, "w") as z:
z.writestr("stops.txt", stops)
z.writestr("routes.txt", "route_id,route_type\nR1,2\n")
z.writestr("trips.txt", "trip_id,route_id,service_id\nT1,R1,S1\n")
z.writestr(
"stop_times.txt",
"trip_id,stop_id,stop_sequence,departure_time\n"
"T1,TCR,1,06:00:00\n"
"T1,BDS,2,06:03:00\n"
"T1,GUD,3,06:06:00\n",
)
clean_national_rail_gtfs(src, dst)
with zipfile.ZipFile(dst, "r") as z:
rows = z.read("stops.txt").decode("utf-8").splitlines()
coords = {r.split(",")[0]: r.split(",")[-2:] for r in rows[1:]}
assert (
float(coords["TCR"][0]),
float(coords["TCR"][1]),
) == STATION_COORD_OVERRIDES["TCR"]
assert (
float(coords["BDS"][0]),
float(coords["BDS"][1]),
) == STATION_COORD_OVERRIDES["BDS"]
assert (float(coords["ZZZ"][0]), float(coords["ZZZ"][1])) == (51.40, -0.20)
assert (float(coords["IEP"][0]), float(coords["IEP"][1])) == (54.0, -2.0)
assert (float(coords["GUD"][0]), float(coords["GUD"][1])) == (51.50, -0.10)
# ── validate_stop_geometry ────────────────────────────────────────────────────
def _geometry_gtfs(path: Path, *, n_trips: int, b_lat: float, b_lon: float) -> Path:
"""A metro line ABC (5-minute hops) repeated across n_trips.
Displacing B far from A and C makes it a displacement outlier; n_trips sets
its service level (the hard-fail vs warn tier).
"""
routes = "route_id,route_type\nR1,1\n"
trips = "trip_id,route_id,service_id\n" + "".join(
f"T{i},R1,S1\n" for i in range(n_trips)
)
stops = (
"stop_id,stop_name,stop_lat,stop_lon\n"
"A,Aaa,51.50,-0.10\n"
f"B,Bbb,{b_lat},{b_lon}\n"
"C,Ccc,51.52,-0.10\n"
)
header = "trip_id,stop_id,stop_sequence,arrival_time,departure_time\n"
body = "".join(
f"T{i},A,0,06:00:00,06:00:00\n"
f"T{i},B,1,06:05:00,06:05:00\n"
f"T{i},C,2,06:10:00,06:10:00\n"
for i in range(n_trips)
)
with zipfile.ZipFile(path, "w") as z:
z.writestr("routes.txt", routes)
z.writestr("trips.txt", trips)
z.writestr("stops.txt", stops)
z.writestr("stop_times.txt", header + body)
return path
def test_validate_stop_geometry_fails_on_high_service_displacement(
tmp_path: Path,
) -> None:
"""A busy stop whose trains imply teleportation fails the build (TCR mode)."""
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=120, b_lat=58.0, b_lon=-2.0)
with pytest.raises(RuntimeError, match="stop-geometry validation failed"):
validate_stop_geometry(feed, "displaced feed")
def test_validate_stop_geometry_passes_when_stops_are_coherent(
tmp_path: Path,
) -> None:
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=120, b_lat=51.51, b_lon=-0.10)
validate_stop_geometry(feed, "coherent feed") # must not raise
def test_validate_stop_geometry_only_warns_on_low_service_displacement(
tmp_path: Path,
) -> None:
"""Heritage-line quirks (few trips) warn but do not block a build."""
feed = _geometry_gtfs(tmp_path / "feed.zip", n_trips=6, b_lat=58.0, b_lon=-2.0)
validate_stop_geometry(feed, "heritage feed") # must not raise
# ── validate_london_coverage ──────────────────────────────────────────────────
_ALL_LU = (
"Bakerloo",
"Central",
"Circle",
"District",
"Hammersmith & City",
"Jubilee",
"Metropolitan",
"Northern",
"Piccadilly",
"Victoria",
"Waterloo & City",
)
_COVERAGE_CALENDAR = (
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
"start_date,end_date\n"
"S1,1,1,1,1,1,1,1,20260101,20271231\n"
)
def _bods_coverage(
path: Path,
*,
lu_lines: tuple[str, ...] = _ALL_LU,
include_dlr: bool = True,
include_tramlink: bool = True,
) -> Path:
routes = ["route_id,agency_id,route_short_name,route_long_name,route_type"]
trips = ["trip_id,route_id,service_id"]
n = 0
for line in lu_lines:
routes.append(f"LU{n},LU,{line},,1")
trips.append(f"T{n},LU{n},S1")
n += 1
if include_dlr:
routes.append(f"DLR{n},DLRA,DLR,Docklands Light Railway,2")
trips.append(f"T{n},DLR{n},S1")
n += 1
if include_tramlink:
routes.append(f"TR{n},TRAM,Tram,London Tramlink,0")
trips.append(f"T{n},TR{n},S1")
n += 1
with zipfile.ZipFile(path, "w") as z:
z.writestr("calendar.txt", _COVERAGE_CALENDAR)
z.writestr("routes.txt", "\n".join(routes) + "\n")
z.writestr("trips.txt", "\n".join(trips) + "\n")
return path
def _nr_coverage(
path: Path, *, include_elizabeth: bool = True, include_overground: bool = True
) -> Path:
routes = ["route_id,agency_id,route_short_name,route_long_name,route_type"]
trips = ["trip_id,route_id,service_id"]
if include_elizabeth:
routes.append("XR1,XR,XR:PAD->ABW,Elizabeth line,2")
trips.append("TX,XR1,S1")
if include_overground:
routes.append("LO1,LO,LO,London Overground,2")
trips.append("TL,LO1,S1")
with zipfile.ZipFile(path, "w") as z:
z.writestr("calendar.txt", _COVERAGE_CALENDAR)
z.writestr("routes.txt", "\n".join(routes) + "\n")
z.writestr("trips.txt", "\n".join(trips) + "\n")
return path
def test_validate_london_coverage_happy_path(tmp_path: Path) -> None:
bods = _bods_coverage(tmp_path / "bods.zip")
nr = _nr_coverage(tmp_path / "nr.zip")
validate_london_coverage(bods, nr, today=TODAY) # must not raise
def test_validate_london_coverage_missing_tube_line_fails(tmp_path: Path) -> None:
bods = _bods_coverage(
tmp_path / "bods.zip",
lu_lines=tuple(name for name in _ALL_LU if name != "Victoria"),
)
nr = _nr_coverage(tmp_path / "nr.zip")
with pytest.raises(RuntimeError, match="Victoria"):
validate_london_coverage(bods, nr, today=TODAY)
def test_validate_london_coverage_missing_dlr_fails(tmp_path: Path) -> None:
bods = _bods_coverage(tmp_path / "bods.zip", include_dlr=False)
nr = _nr_coverage(tmp_path / "nr.zip")
with pytest.raises(RuntimeError, match="DLR"):
validate_london_coverage(bods, nr, today=TODAY)
def test_validate_london_coverage_missing_elizabeth_fails(tmp_path: Path) -> None:
bods = _bods_coverage(tmp_path / "bods.zip")
nr = _nr_coverage(tmp_path / "nr.zip", include_elizabeth=False)
with pytest.raises(RuntimeError, match="Elizabeth line"):
validate_london_coverage(bods, nr, today=TODAY)
def test_validate_london_coverage_expired_service_fails(tmp_path: Path) -> None:
"""A line whose only calendar expired years ago counts as missing (present in
routes.txt but with no active service the retired-TfL-feed failure mode)."""
bods = _bods_coverage(tmp_path / "bods.zip")
nr = tmp_path / "nr.zip"
expired = (
"service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,"
"start_date,end_date\n"
"S1,1,1,1,1,1,1,1,20091201,20101224\n"
)
with zipfile.ZipFile(nr, "w") as z:
z.writestr("calendar.txt", expired)
z.writestr(
"routes.txt",
"route_id,agency_id,route_short_name,route_long_name,route_type\n"
"XR1,XR,XR:PAD->ABW,Elizabeth line,2\nLO1,LO,LO,London Overground,2\n",
)
z.writestr("trips.txt", "trip_id,route_id,service_id\nTX,XR1,S1\nTL,LO1,S1\n")
with pytest.raises(RuntimeError, match="Elizabeth line|Overground"):
validate_london_coverage(bods, nr, today=TODAY)

View file

@ -42,6 +42,7 @@ import zipfile
from collections import defaultdict
from pathlib import Path
import polars as pl
from tqdm import tqdm
from pipeline.local_temp import local_tmp_dir
@ -66,6 +67,56 @@ GTFS_MIN_VALID_STOP_FRACTION = 0.95
UK_LAT_RANGE = (49.0, 61.0)
UK_LON_RANGE = (-9.0, 2.5)
# Authoritative (lat, lon) for stops that upstream feeds ship mislocated, keyed
# by GTFS stop_id (National Rail CRS code). The National Rail CIF → GTFS export
# ships the Elizabeth-line-only core stations broken: Tottenham Court Road (TCR)
# has its lat/lon transposed and Bond Street (BDS) has a wrong-signed longitude,
# leaving them ~300km and ~20km from reality. Both then fail to link to the
# street network, so nobody can board/alight the Elizabeth line there and every
# journey through them silently reroutes (e.g. TCR→Heathrow via the Central line
# + Heathrow Express instead of one seat on the Elizabeth line). Coordinates are
# the TfL/BODS station nodes for the same physical stations. New breakages that
# these overrides don't cover are caught by validate_stop_geometry() below, which
# fails the build rather than shipping a phantom stop.
STATION_COORD_OVERRIDES = {
"TCR": (51.51643, -0.13041), # Tottenham Court Road (Elizabeth line)
"BDS": (51.51430, -0.14972), # Bond Street (Elizabeth line)
}
# validate_stop_geometry(): a served tram/metro/rail stop is a "displacement
# outlier" when, over real timetabled hops (>= GEOMETRY_MIN_HOP_SECONDS, so that
# coarse timetables where adjacent stops share a minute don't count), the implied
# in-vehicle speed to a MAJORITY of its distinct trip-neighbours exceeds
# GEOMETRY_MAX_KMH. Such a stop sits nowhere near where its trains actually run
# (the TCR failure mode). Outliers with >= GEOMETRY_HARDFAIL_MIN_TRIPS timetabled
# trips FAIL the build; rarer ones (heritage lines) only warn. Scoped to
# rail/metro/tram route_types: buses carry demand-responsive "area" stops and
# same-minute urban hops that are noisy without being real geometry errors, and
# the R5-side unlinked-stop check covers gross bus displacement anyway.
GEOMETRY_RAIL_ROUTE_TYPES = frozenset({"0", "1", "2"})
GEOMETRY_MIN_HOP_SECONDS = 120
GEOMETRY_MAX_KMH = 300.0
GEOMETRY_HARDFAIL_MIN_TRIPS = 100
# London modes/lines that must be present with active service in the combined
# network. A feed regression that silently drops one (as the retired TfL
# TransXChange feed did — see module docstring) FAILS the build instead of
# quietly degrading every affected journey. Underground lines and Tramlink/DLR
# come from BODS; the Elizabeth line and Overground come from National Rail.
LONDON_UNDERGROUND_LINES = (
"Bakerloo",
"Central",
"Circle",
"District",
"Hammersmith & City",
"Jubilee",
"Metropolitan",
"Northern",
"Piccadilly",
"Victoria",
"Waterloo & City",
)
def _download_http(
url: str, dest: Path, *, desc: str, headers: dict | None = None
@ -804,6 +855,8 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
bad_trips_removed = 0
seqs_renumbered = 0
coords_fixed = 0
coords_overridden = 0
coords_transposed = 0
route_types_fixed = 0
with (
@ -890,6 +943,7 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
with zin.open(info) as f:
header = f.readline()
cols = _parse_csv_line(header)
stop_id_idx = cols.index("stop_id")
lat_idx = cols.index("stop_lat")
lon_idx = cols.index("stop_lon")
@ -905,16 +959,24 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
parts = _parse_csv_line(line)
if not parts:
continue
stop_id = parts[stop_id_idx].strip('"')
try:
lat = float(parts[lat_idx])
# Fix bogus Irish CIE coordinates (South Atlantic)
if lat < 0:
# Set to a neutral UK coordinate that won't be routed to
parts[lat_idx] = "54.0"
parts[lon_idx] = "-2.0"
lon = float(parts[lon_idx])
except (ValueError, IndexError):
lat = lon = None
new_lat, new_lon, action = _repair_stop_coordinate(
stop_id, lat, lon
)
if action != "keep":
parts[lat_idx] = repr(new_lat)
parts[lon_idx] = repr(new_lon)
if action == "override":
coords_overridden += 1
elif action == "transpose":
coords_transposed += 1
else: # "dump"
coords_fixed += 1
except ValueError:
pass
tmp.write(_format_csv_row(parts))
tmp.close()
@ -1014,7 +1076,9 @@ def clean_national_rail_gtfs(src: Path, dst: Path) -> None:
print(f" Orphan stop references removed: {orphan_stops_removed}")
print(f" Bad trip stop_times removed: {bad_trips_removed}")
print(f" Stop sequences renumbered: {seqs_renumbered}")
print(f" Bogus coordinates fixed: {coords_fixed}")
print(f" Coordinates overridden (known-bad stations): {coords_overridden}")
print(f" Coordinates de-transposed (lat/lon swapped): {coords_transposed}")
print(f" Bogus coordinates dumped (out-of-area): {coords_fixed}")
print(f" Route types 714→3 fixed: {route_types_fixed}")
print(f" Saved to {dst}")
@ -1139,6 +1203,410 @@ def convert_national_rail_to_gtfs(raw_dir: Path, output_dir: Path) -> Path:
return dest
def _in_uk(lat: float, lon: float) -> bool:
"""True if (lat, lon) falls inside the coarse UK routing bounding box."""
return (
UK_LAT_RANGE[0] <= lat <= UK_LAT_RANGE[1]
and UK_LON_RANGE[0] <= lon <= UK_LON_RANGE[1]
)
def _repair_stop_coordinate(
stop_id: str, lat: float | None, lon: float | None
) -> tuple[float, float, str]:
"""Repair an obviously-broken stop coordinate. Returns (lat, lon, action).
action is one of:
"override" - an authoritative coordinate was substituted for a known-bad
station (see STATION_COORD_OVERRIDES).
"transpose" - the feed shipped lat/lon swapped (the coordinate is outside
the UK but swapping lands inside it), so they are swapped
back. This is the Tottenham Court Road failure mode and is
handled generically, not just for the hard-coded stations.
"dump" - the coordinate is genuinely out of area (Irish CIE stations
at 0,0-ish South Atlantic garbage, missing coordinates) and
is moved to a neutral inland point that will not be routed
to, preserving the historical behaviour for those stops.
"keep" - already a plausible UK coordinate; left unchanged.
"""
if stop_id in STATION_COORD_OVERRIDES:
return (*STATION_COORD_OVERRIDES[stop_id], "override")
if lat is None or lon is None:
return 54.0, -2.0, "dump"
if _in_uk(lat, lon):
return lat, lon, "keep"
if _in_uk(lon, lat):
return lon, lat, "transpose"
return 54.0, -2.0, "dump"
def _secs_expr(col: str) -> pl.Expr:
"""Polars expression parsing an HH:MM:SS GTFS time to seconds since midnight."""
parts = pl.col(col).str.split(":")
return (
parts.list.get(0).cast(pl.Int64, strict=False) * 3600
+ parts.list.get(1).cast(pl.Int64, strict=False) * 60
+ parts.list.get(2).cast(pl.Int64, strict=False)
)
def _extract_member(path: Path, member: str, dest_dir: str) -> Path:
"""Stream one file out of a GTFS zip to dest_dir (avoids holding it in RAM)."""
out = Path(dest_dir) / member
with zipfile.ZipFile(path) as z, z.open(member) as src, open(out, "wb") as dst:
shutil.copyfileobj(src, dst)
return out
def validate_stop_geometry(path: Path, feed_name: str) -> None:
"""Fail if a served rail/metro/tram stop is a coordinate displacement outlier.
Guards against the Tottenham Court Road failure mode: a stop whose timetabled
trains imply teleportation (>{GEOMETRY_MAX_KMH:.0f} km/h over a real hop) to a
MAJORITY of its distinct trip-neighbours is not where the feed places it, so
it cannot link to the street network and every journey through it silently
reroutes. High-service outliers (>= {GEOMETRY_HARDFAIL_MIN_TRIPS} trips) raise;
negligible-service ones (heritage lines) only warn. Scoped to tram/metro/rail
route types; see GEOMETRY_* constants.
"""
print(f"Validating stop geometry for feed '{feed_name}'...")
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as td:
routes = pl.read_csv(
_extract_member(path, "routes.txt", td), infer_schema_length=0
).select(
pl.col("route_id").cast(pl.Utf8),
pl.col("route_type").cast(pl.Utf8),
)
rail_route_ids = routes.filter(
pl.col("route_type").is_in(list(GEOMETRY_RAIL_ROUTE_TYPES))
).select("route_id")
trips = pl.read_csv(
_extract_member(path, "trips.txt", td), infer_schema_length=0
).select(
pl.col("trip_id").cast(pl.Utf8),
pl.col("route_id").cast(pl.Utf8),
)
rail_trips = trips.join(rail_route_ids, on="route_id", how="inner").select(
"trip_id"
)
if rail_trips.height == 0:
print(" no rail/metro/tram trips in feed; nothing to check")
return
stops = pl.read_csv(
_extract_member(path, "stops.txt", td), infer_schema_length=0
).select(
pl.col("stop_id").cast(pl.Utf8),
pl.col("stop_name").cast(pl.Utf8).alias("name"),
pl.col("stop_lat").cast(pl.Float64, strict=False).alias("lat"),
pl.col("stop_lon").cast(pl.Float64, strict=False).alias("lon"),
)
# Only rail/metro/tram stop_times, ordered within each trip.
st = (
pl.scan_csv(
_extract_member(path, "stop_times.txt", td), infer_schema_length=0
)
.select(
pl.col("trip_id").cast(pl.Utf8),
pl.col("stop_id").cast(pl.Utf8),
pl.col("stop_sequence").cast(pl.Int64, strict=False).alias("seq"),
_secs_expr("departure_time").alias("dep"),
_secs_expr("arrival_time").alias("arr"),
)
.join(rail_trips.lazy(), on="trip_id", how="inner")
.join(
stops.lazy().select(["stop_id", "lat", "lon"]), on="stop_id", how="left"
)
.collect()
.sort(["trip_id", "seq"])
)
# Service level: distinct trips serving each stop (the hard-fail tier).
svc = st.group_by("stop_id").agg(pl.col("trip_id").n_unique().alias("trips"))
# Consecutive-stop hops within a trip.
st = st.with_columns(
pl.col("lat").shift(1).over("trip_id").alias("plat"),
pl.col("lon").shift(1).over("trip_id").alias("plon"),
pl.col("stop_id").shift(1).over("trip_id").alias("pid"),
pl.col("dep").shift(1).over("trip_id").alias("pdep"),
)
earth_km = 6371.0
dlat = (pl.col("lat") - pl.col("plat")).radians()
dlon = (pl.col("lon") - pl.col("plon")).radians()
hav = (dlat / 2).sin() ** 2 + pl.col("plat").radians().cos() * pl.col(
"lat"
).radians().cos() * (dlon / 2).sin() ** 2
hops = (
st.with_columns(
(2 * earth_km * hav.sqrt().arcsin()).alias("dist_km"),
(pl.col("arr") - pl.col("pdep")).alias("dt_s"),
)
.filter(
pl.col("plat").is_not_null()
& pl.col("dist_km").is_not_null()
& (pl.col("dt_s") >= GEOMETRY_MIN_HOP_SECONDS)
)
.with_columns((pl.col("dist_km") / (pl.col("dt_s") / 3600.0)).alias("kmh"))
)
if hops.height == 0:
print(" no timetabled hops long enough to assess; skipping")
return
# Undirected stop-neighbour edges, flagged if any hop teleports.
fwd = hops.select(
pl.col("stop_id").alias("a"),
pl.col("pid").alias("b"),
(pl.col("kmh") > GEOMETRY_MAX_KMH).alias("tp"),
)
rev = fwd.select(pl.col("b").alias("a"), pl.col("a").alias("b"), "tp")
edges = (
pl.concat([fwd, rev])
.group_by(["a", "b"])
.agg(pl.col("tp").max().alias("tp"))
)
per_stop = edges.group_by("a").agg(
pl.len().alias("nbrs"), pl.col("tp").sum().alias("tp_nbrs")
)
outliers = (
per_stop.filter(
(pl.col("nbrs") >= 2) & (pl.col("tp_nbrs") / pl.col("nbrs") >= 0.5)
)
.join(stops, left_on="a", right_on="stop_id", how="left")
.join(svc, left_on="a", right_on="stop_id", how="left")
.with_columns(pl.col("trips").fill_null(0))
.sort("trips", descending=True)
)
hard = outliers.filter(pl.col("trips") >= GEOMETRY_HARDFAIL_MIN_TRIPS)
soft = outliers.filter(pl.col("trips") < GEOMETRY_HARDFAIL_MIN_TRIPS)
for r in soft.iter_rows(named=True):
print(
f" WARN low-service displacement outlier: {r['a']} "
f"'{r['name']}' ({r['trips']} trips) at ({r['lat']}, {r['lon']})"
)
if hard.height > 0:
lines = [
f" {r['a']} '{r['name']}' ({r['trips']} trips) "
f"at ({r['lat']}, {r['lon']})"
for r in hard.iter_rows(named=True)
]
raise RuntimeError(
f"stop-geometry validation failed for feed '{feed_name}': "
f"{hard.height} high-service rail/metro/tram stop(s) sit nowhere "
f"near where their trains run (implied speed > {GEOMETRY_MAX_KMH:.0f} "
f"km/h to most neighbours). These cannot link to the street network "
f"and every journey through them silently reroutes. Add an entry to "
f"STATION_COORD_OVERRIDES or fix the upstream feed:\n"
+ "\n".join(lines)
)
print(
f" OK: {outliers.height} displacement outlier(s), none at or above "
f"{GEOMETRY_HARDFAIL_MIN_TRIPS} trips"
)
def _active_service_ids(path: Path, window_start: int, window_end: int) -> set[str]:
"""Service ids with at least one running day in [window_start, window_end]."""
active: set[str] = set()
weekdays = (
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
)
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
if "calendar.txt" in names:
with z.open("calendar.txt") as f:
cols = _parse_csv_line(f.readline())
sid_i = cols.index("service_id")
start_i = cols.index("start_date")
end_i = cols.index("end_date")
day_i = [cols.index(d) for d in weekdays if d in cols]
for line in f:
parts = _parse_csv_line(line)
if not parts:
continue
try:
start = int(parts[start_i].strip('"'))
end = int(parts[end_i].strip('"'))
except (ValueError, IndexError):
continue
if start > window_end or end < window_start:
continue
if day_i and not any(
parts[i].strip('"') == "1" for i in day_i if i < len(parts)
):
continue
active.add(parts[sid_i].strip('"'))
if "calendar_dates.txt" in names:
with z.open("calendar_dates.txt") as f:
cols = _parse_csv_line(f.readline())
sid_i = cols.index("service_id")
date_i = cols.index("date")
exc_i = cols.index("exception_type")
for line in f:
parts = _parse_csv_line(line)
if not parts:
continue
try:
date = int(parts[date_i].strip('"'))
except (ValueError, IndexError):
continue
if exc_i < len(parts) and parts[exc_i].strip('"') != "1":
continue
if window_start <= date <= window_end:
active.add(parts[sid_i].strip('"'))
return active
def _lines_with_active_service(
path: Path,
active_services: set[str],
*,
route_predicate,
) -> set[str]:
"""Return the set of route labels (agency_id::short_name matched by
route_predicate) that have at least one trip on an active service.
route_predicate((agency_id, route_type, short_name, long_name)) -> label|None.
A returned label marks the route as one we care about; None ignores it.
"""
with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as td:
routes = pl.read_csv(
_extract_member(path, "routes.txt", td), infer_schema_length=0
)
route_label: dict[str, str] = {}
for r in routes.iter_rows(named=True):
label = route_predicate(
(
str(r.get("agency_id", "")),
str(r.get("route_type", "")),
str(r.get("route_short_name", "")),
str(r.get("route_long_name", "")),
)
)
if label is not None:
route_label[str(r["route_id"])] = label
if not route_label:
return set()
trips = pl.read_csv(
_extract_member(path, "trips.txt", td), infer_schema_length=0
).select(
pl.col("route_id").cast(pl.Utf8),
pl.col("service_id").cast(pl.Utf8),
)
present: set[str] = set()
for row in trips.iter_rows():
route_id, service_id = str(row[0]), str(row[1])
label = route_label.get(route_id)
if label is not None and service_id in active_services:
present.add(label)
return present
def validate_london_coverage(
bods_path: Path, nr_path: Path, *, today: dt.date | None = None
) -> None:
"""Fail if any must-have London line/mode lacks active service in the window.
A silent regression that drops the Underground, DLR, Tramlink, the Elizabeth
line or the Overground (as the retired TfL TransXChange feed did) would leave
the map quietly under-serving huge swaths of journeys. This turns that into a
hard build failure. See LONDON_UNDERGROUND_LINES.
"""
if today is None:
today = dt.date.today()
window_start = int(today.strftime("%Y%m%d"))
window_end = int(
(today + dt.timedelta(days=GTFS_CALENDAR_LOOKAHEAD_DAYS)).strftime("%Y%m%d")
)
print("Validating London mode/line coverage...")
bods_active = _active_service_ids(bods_path, window_start, window_end)
nr_active = _active_service_ids(nr_path, window_start, window_end)
# BODS underground lines: agency 'London Underground (TfL)', route_type=1.
def lu_pred(row):
agency_id, route_type, short, _long = row
return (
short if route_type == "1" and short in LONDON_UNDERGROUND_LINES else None
)
# DLR: metro/light-rail named DLR (agency 'London Docklands Light Railway').
def dlr_pred(row):
_agency_id, _route_type, short, long = row
text = f"{short} {long}".lower()
return "DLR" if ("dlr" in text or "docklands light" in text) else None
# London Tramlink tram service.
def tramlink_pred(row):
_agency_id, route_type, short, long = row
text = f"{short} {long}".lower()
return "Tramlink" if (route_type == "0" and "tram" in text) else None
lu_present = _lines_with_active_service(
bods_path, bods_active, route_predicate=lu_pred
)
dlr_present = _lines_with_active_service(
bods_path, bods_active, route_predicate=dlr_pred
)
tramlink_present = _lines_with_active_service(
bods_path, bods_active, route_predicate=tramlink_pred
)
# National Rail: Elizabeth line (agency_id XR), Overground (agency_id LO).
def nr_agency_pred(target_id, label):
def pred(row):
agency_id, _route_type, _short, _long = row
return label if agency_id == target_id else None
return pred
elizabeth_present = _lines_with_active_service(
nr_path, nr_active, route_predicate=nr_agency_pred("XR", "Elizabeth line")
)
overground_present = _lines_with_active_service(
nr_path, nr_active, route_predicate=nr_agency_pred("LO", "London Overground")
)
problems: list[str] = []
missing_lu = [line for line in LONDON_UNDERGROUND_LINES if line not in lu_present]
if missing_lu:
problems.append(
"London Underground lines missing/without active service: "
+ ", ".join(missing_lu)
)
if not dlr_present:
problems.append("DLR missing or without active service (BODS)")
if not tramlink_present:
problems.append("London Tramlink missing or without active service (BODS)")
if not elizabeth_present:
problems.append(
"Elizabeth line missing or without active service (National Rail, XR)"
)
if not overground_present:
problems.append(
"London Overground missing or without active service (National Rail, LO)"
)
if problems:
raise RuntimeError(
"London coverage validation failed (window "
f"{window_start}-{window_end}):\n " + "\n ".join(problems)
)
print(
f" OK: {len(lu_present)}/{len(LONDON_UNDERGROUND_LINES)} Underground lines, "
"DLR, Tramlink, Elizabeth line and Overground all present with active service"
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Download and prepare transit network data for R5 routing engine"
@ -1167,6 +1635,7 @@ def main() -> None:
bods_final = output_dir / "bods_gtfs.zip"
convert_high_freq_to_frequency_based(bods_cleaned, bods_final)
validate_gtfs_feed(bods_final, "BODS GTFS")
validate_stop_geometry(bods_final, "BODS GTFS")
# 2. National Rail CIF → GTFS. Heavy rail is mandatory: trains are how people
# reach the ~2,725 railway-station destinations, so a bus/metro-only network
@ -1183,6 +1652,11 @@ def main() -> None:
)
nr_final = convert_national_rail_to_gtfs(raw_dir, output_dir)
validate_gtfs_feed(nr_final, "National Rail GTFS")
validate_stop_geometry(nr_final, "National Rail GTFS")
# 3. Cross-feed check: every must-have London mode/line is present with
# active service. Catches a feed regression that silently drops a whole mode.
validate_london_coverage(bods_final, nr_final)
# Summary
print()

View file

@ -75,7 +75,9 @@ pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData, SchoolMeta
pub use postcode_population::PostcodePopulation;
pub use postcodes::{OutcodeData, PostcodeData};
pub use property::{
precompute_h3, FeatureStats, Histogram, HistoricalPrice, PostcodePoiMetrics, PropertyData,
QuantRef, RenovationEvent, TenureEvent,
combine_nearest_distances, combined_station_feature_name,
combined_station_source_feature_names, precompute_h3, FeatureStats, Histogram, HistoricalPrice,
PostcodePoiMetrics, PropertyData, QuantRef, RenovationEvent, TenureEvent,
COMBINED_STATION_CATEGORY,
};
pub use travel_time::{slugify, TravelTimeStore};

View file

@ -7,7 +7,10 @@ use serde::Serialize;
use tracing::info;
use crate::consts::{NAN_U16, QUANT_SCALE};
use crate::data::{PropertyData, QuantRef};
use crate::data::{
combine_nearest_distances, combined_station_feature_name,
combined_station_source_feature_names, PropertyData, QuantRef,
};
use crate::utils::{normalize_postcode, GridIndex, InternedColumn};
const GRID_CELL_SIZE: f32 = 0.01;
@ -318,8 +321,17 @@ fn build_poi_filter_feature_data(
let mut encoded_columns = 0usize;
for (metric_idx, name) in poi_metrics.feature_names.iter().enumerate() {
let Some(values) = extract_optional_feature_f32(df, name)? else {
continue;
let values = match extract_optional_feature_f32(df, name)? {
Some(values) => values,
// Some POI columns (the combined-station distance) are synthesized
// in memory by PostcodePoiMetrics and never written to the listings
// parquet. Reconstruct them from their source columns so listing POI
// filters match the map exactly instead of silently rejecting every
// row on an all-NaN column.
None => match synthesize_missing_poi_column(df, name, row_count)? {
Some(values) => values,
None => continue,
},
};
for (row, value) in values.into_iter().enumerate() {
let dst = row * num_features + metric_idx;
@ -338,6 +350,45 @@ fn build_poi_filter_feature_data(
Ok(feature_data)
}
/// Reconstruct a POI metric column that `PostcodePoiMetrics` synthesizes in
/// memory and therefore is absent from the listings parquet. Currently only the
/// combined-station distance, derived as the elementwise nearest of its per-mode
/// source columns (the same rule the postcode side table uses). Returns `None`
/// if `name` is not a synthesized column, or if none of its sources are present.
fn synthesize_missing_poi_column(
df: &DataFrame,
name: &str,
row_count: usize,
) -> Result<Option<Vec<Option<f32>>>> {
if name != combined_station_feature_name() {
return Ok(None);
}
let mut sources: Vec<Vec<f32>> = Vec::new();
for source_name in combined_station_source_feature_names() {
if let Some(values) = extract_optional_feature_f32(df, &source_name)? {
sources.push(
values
.into_iter()
.map(|value| value.unwrap_or(f32::NAN))
.collect(),
);
}
}
if sources.is_empty() {
return Ok(None);
}
let source_refs: Vec<&[f32]> = sources.iter().map(Vec::as_slice).collect();
let combined = combine_nearest_distances(&source_refs, row_count);
Ok(Some(
combined
.into_iter()
.map(|value| value.is_finite().then_some(value))
.collect(),
))
}
fn feature_index(property_data: &PropertyData, name: &str) -> Option<usize> {
property_data
.feature_names
@ -741,6 +792,51 @@ mod tests {
assert!(!any_listing.listing_url.is_empty());
}
#[test]
fn synthesizes_combined_station_column_from_source_columns() {
// Mirrors the postcode side table: the combined-station distance is the
// finite minimum of the per-mode source columns present in the parquet.
let df = df![
"Distance to nearest amenity (Rail station) (km)" => [2.0f32, f32::NAN, 5.0],
"Distance to nearest amenity (Tube station) (km)" => [1.0f32, f32::NAN, f32::NAN],
"Distance to nearest amenity (DLR station) (km)" => [3.0f32, 4.0, f32::NAN],
]
.unwrap();
let combined = synthesize_missing_poi_column(&df, &combined_station_feature_name(), 3)
.unwrap()
.expect("combined-station column should be synthesized");
assert_eq!(combined[0], Some(1.0)); // min(2, 1, 3)
assert_eq!(combined[1], Some(4.0)); // only DLR present
assert_eq!(combined[2], Some(5.0)); // only Rail present
}
#[test]
fn does_not_synthesize_non_combined_or_sourceless_columns() {
let df = df![
"Distance to nearest amenity (Rail station) (km)" => [1.0f32],
]
.unwrap();
// A real per-mode column is read from the parquet, never synthesized.
assert!(synthesize_missing_poi_column(
&df,
"Distance to nearest amenity (Rail station) (km)",
1
)
.unwrap()
.is_none());
// The combined column cannot be built when no source columns exist.
let empty = df!["Postcode" => ["AB1 2CD"]].unwrap();
assert!(
synthesize_missing_poi_column(&empty, &combined_station_feature_name(), 1)
.unwrap()
.is_none()
);
}
#[test]
fn extracts_price_history_from_parquet() {
let path = PathBuf::from("src/data/testdata/listings_price_history.parquet");

View file

@ -18,7 +18,10 @@ mod quant;
mod stats;
pub use h3::precompute_h3;
pub use poi_metrics::{PostcodePoiMetrics, COMBINED_STATION_CATEGORY};
pub use poi_metrics::{
combine_nearest_distances, combined_station_feature_name, combined_station_source_feature_names,
PostcodePoiMetrics, COMBINED_STATION_CATEGORY,
};
pub use quant::QuantRef;
pub use stats::{FeatureStats, Histogram};

View file

@ -1445,6 +1445,87 @@ pub async fn ensure_oauth_providers(
Ok(())
}
/// Point the password-reset email at the SPA's own `/reset-password` page.
///
/// PocketBase's default reset template links to `{APP_URL}/_/#/auth/confirm-password-reset/{TOKEN}`,
/// i.e. its superuser UI under `/pb/_/`. `meta.appURL` is `{public_url}/pb` (set in
/// `ensure_oauth_providers` for OAuth redirects), and the `/pb` proxy allowlist deliberately
/// rejects `/_/` (see `routes/pb_proxy.rs`), so the emailed link 404s. We therefore bake the raw
/// `public_url` (NOT `appURL`, which carries the `/pb` prefix) straight into the template body and
/// keep PocketBase's `{TOKEN}` placeholder for it to substitute at send time. The SPA route reads
/// `?token=` and calls `confirmPasswordReset`.
pub async fn ensure_password_reset_template(
client: &Client,
base_url: &str,
admin_email: &str,
admin_password: &str,
public_url: &str,
) -> anyhow::Result<()> {
let base_url = base_url.trim_end_matches('/');
let token = auth_superuser(client, base_url, admin_email, admin_password).await?;
let site = public_url.trim_end_matches('/');
// `{{TOKEN}}` renders to the literal `{TOKEN}` placeholder that PocketBase fills in.
let action_url = format!("{site}/reset-password?token={{TOKEN}}");
let body = format!(
"<p>Hello,</p>\n\
<p>Click the button below to choose a new password for your Perfect Postcode account.</p>\n\
<p><a class=\"btn\" href=\"{action_url}\" target=\"_blank\" rel=\"noopener\">Reset password</a></p>\n\
<p>If you did not request this, you can safely ignore this email.</p>\n\
<p>Thanks,<br/>The Perfect Postcode team</p>"
);
// PocketBase 0.23+: email templates are per-collection. GET the users collection, set the
// reset template, and PATCH it back (mirrors the OAuth config flow above).
let collection_url = format!("{base_url}/api/collections/users");
let resp = client
.get(&collection_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to fetch users collection for reset template ({status}): {text}");
}
let mut collection: serde_json::Value = resp.json().await?;
// The field is an object `{subject, body}`; preserve any sibling keys PocketBase may add. If a
// future PocketBase version renames or moves it, warn and skip rather than blocking server
// startup over an email template: a broken reset link is far less bad than a server that won't boot.
let Some(template) = collection
.get_mut("resetPasswordTemplate")
.and_then(|v| v.as_object_mut())
else {
warn!(
"users collection has no resetPasswordTemplate object; \
leaving the reset email on PocketBase's default (which 404s via /pb/_/)"
);
return Ok(());
};
template.insert(
"subject".to_string(),
serde_json::json!("Reset your Perfect Postcode password"),
);
template.insert("body".to_string(), serde_json::json!(body));
let reset_template = collection["resetPasswordTemplate"].clone();
let patch_resp = client
.patch(&collection_url)
.header("Authorization", format!("Bearer {token}"))
.json(&serde_json::json!({ "resetPasswordTemplate": reset_template }))
.send()
.await?;
if !patch_resp.status().is_success() {
let status = patch_resp.status();
let text = patch_resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to set password-reset template ({status}): {text}");
}
info!("PocketBase password-reset email template set to {action_url}");
Ok(())
}
/// Spawn a background task that polls PocketBase every 60 seconds for collection counts
/// and exposes them as Prometheus gauges.
pub fn start_metrics_poller(shared: Arc<crate::state::SharedState>) {

View file

@ -166,6 +166,7 @@ impl AppState {
listing_status: InternedColumn::build(&[]),
listing_date_iso: Vec::new(),
features: Vec::new(),
price_history: Vec::new(),
filter_feature_data: Vec::new(),
poi_filter_feature_data: Vec::new(),
grid: GridIndex::build(&[], &[], 0.01),