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[];
/**