seems alright

This commit is contained in:
Andras Schmelczer 2026-05-17 13:52:11 +01:00
parent ebe7bbb51d
commit eac1bd0d13
58 changed files with 23125 additions and 153505 deletions

File diff suppressed because one or more lines are too long

View file

@ -47,6 +47,7 @@
"@typescript-eslint/parser": "^8.59.2",
"autoprefixer": "^10.5.0",
"babel-loader": "^10.1.1",
"compression-webpack-plugin": "^12.0.0",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4",
"eslint": "^9.39.4",
@ -66,6 +67,7 @@
"sharp": "^0.34.5",
"style-loader": "^4.0.0",
"tailwindcss": "^4.2.4",
"terser-webpack-plugin": "^5.3.14",
"ts-loader": "^9.5.7",
"typescript": "^6.0.3",
"vitest": "^4.1.5",
@ -8169,6 +8171,27 @@
"node": ">= 0.8.0"
}
},
"node_modules/compression-webpack-plugin": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-12.0.0.tgz",
"integrity": "sha512-LR4mS19Jqq41XfA3xVMLrtzVNzqJbUHdzPeLRfQoLiAS9s87f0021fDuU89xxVQFcB6d20ufBkv4j1rQ4OowHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"schema-utils": "^4.2.0",
"serialize-javascript": "^7.0.3"
},
"engines": {
"node": ">= 20.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^5.1.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",

View file

@ -54,6 +54,7 @@
"@typescript-eslint/parser": "^8.59.2",
"autoprefixer": "^10.5.0",
"babel-loader": "^10.1.1",
"compression-webpack-plugin": "^12.0.0",
"copy-webpack-plugin": "^14.0.0",
"css-loader": "^7.1.4",
"eslint": "^9.39.4",
@ -73,6 +74,7 @@
"sharp": "^0.34.5",
"style-loader": "^4.0.0",
"tailwindcss": "^4.2.4",
"terser-webpack-plugin": "^5.3.14",
"ts-loader": "^9.5.7",
"typescript": "^6.0.3",
"vitest": "^4.1.5",

View file

@ -236,6 +236,13 @@ export default function App() {
const authCompletedRef = useRef(false);
const [licenseSuccessStatus, setLicenseSuccessStatus] = useState<LicenseSuccessStatus>('hidden');
// Keep a ref to the latest refreshAuth so the mount-only startup effect always
// calls the current implementation without re-running when the callback identity changes.
const refreshAuthRef = useRef(refreshAuth);
useEffect(() => {
refreshAuthRef.current = refreshAuth;
}, [refreshAuth]);
const openAuthModal = useCallback(
(
tab: 'login' | 'register',
@ -284,14 +291,14 @@ export default function App() {
async function refreshOnStartup() {
if (!returnedFromCheckout) {
// Always refresh auth on startup to pick up server-side subscription changes.
refreshAuth().catch(() => {});
refreshAuthRef.current().catch(() => {});
return;
}
setLicenseSuccessStatus('verifying');
for (let attempt = 0; attempt < LICENSE_VERIFICATION_ATTEMPTS; attempt += 1) {
try {
const refreshedUser = await refreshAuth();
const refreshedUser = await refreshAuthRef.current();
if (cancelled) return;
if (hasFullAccess(refreshedUser)) {
trackEvent('Purchase');
@ -314,7 +321,9 @@ export default function App() {
return () => {
cancelled = true;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Mount-only: this is a startup auth refresh / license verification handshake
// that must fire exactly once on initial load. refreshAuth is read via ref.
}, []);
const savedSearches = useSavedSearches(user?.id ?? null);
const [showSaveModal, setShowSaveModal] = useState(false);
@ -381,20 +390,17 @@ export default function App() {
[inviteCode]
);
const handleEditSearch = useCallback(
(id: string, name: string, params: string) => {
const search = params.startsWith('?') ? params : `?${params}`;
dashboardSearchRef.current = search;
const url = `/dashboard${search}`;
window.history.pushState({ page: 'dashboard', hash: '' }, '', url);
setMapUrlState(parseUrlState());
setDashboardRouteKey(search);
setRouteHash('');
setActivePage('dashboard');
setEditingSearch({ id, name });
},
[]
);
const handleEditSearch = useCallback((id: string, name: string, params: string) => {
const search = params.startsWith('?') ? params : `?${params}`;
dashboardSearchRef.current = search;
const url = `/dashboard${search}`;
window.history.pushState({ page: 'dashboard', hash: '' }, '', url);
setMapUrlState(parseUrlState());
setDashboardRouteKey(search);
setRouteHash('');
setActivePage('dashboard');
setEditingSearch({ id, name });
}, []);
const handleCancelEdit = useCallback(() => {
setEditingSearch(null);
@ -451,13 +457,25 @@ export default function App() {
activePageRef.current = activePage;
}, [activePage]);
// Refs for the initial history.replaceState seed below — the popstate effect runs
// mount-only, but it needs to read the *initial* page/hash/inviteCode values once.
const initialPageRef = useRef(activePage);
const initialRouteHashRef = useRef(routeHash);
const initialInviteCodeRef = useRef(inviteCode);
useEffect(() => {
if (!window.history.state?.page) {
const hash = routeHash || normalizeHash(window.location.hash);
const initialActivePage = initialPageRef.current;
const hash = initialRouteHashRef.current || normalizeHash(window.location.hash);
window.history.replaceState(
{ page: activePage, hash },
{ page: initialActivePage, hash },
'',
buildPageUrl(activePage, inviteCode ?? undefined, window.location.search, hash)
buildPageUrl(
initialActivePage,
initialInviteCodeRef.current ?? undefined,
window.location.search,
hash
)
);
}
const handlePopState = (e: PopStateEvent) => {
@ -487,7 +505,10 @@ export default function App() {
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Mount-only: registers a single popstate listener for the app lifetime and
// seeds initial history state. The handler uses only stable setters and module
// functions; initial-render values are read via refs above.
}, []);
const { fetchSearches } = savedSearches;
useEffect(() => {

View file

@ -507,7 +507,7 @@ export default function HomePage({
)}
</td>
{[row.postcode, row.guides].map((has, j) => {
const statusLabel = has ? 'Yes' : 'No';
const statusLabel = has ? t('common.yes') : t('common.no');
return (
<td
key={j}
@ -520,11 +520,11 @@ export default function HomePage({
);
})}
<td
aria-label="Yes"
aria-label={t('common.yes')}
className="px-1.5 md:px-3 py-2.5 md:py-3.5 text-center text-base text-green-500 bg-teal-50 dark:bg-teal-900/30"
>
<span aria-hidden="true">&#x2713;</span>
<span className="sr-only">Yes</span>
<span className="sr-only">{t('common.yes')}</span>
</td>
</tr>
))}

View file

@ -131,7 +131,7 @@ export default function AreaPane({
};
const formatExclusionValue = (exclusion: FilterExclusion, value: number) => {
if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.min')}`;
if (exclusion.kind === 'travel') return `${Math.round(value)} ${t('common.minute')}`;
return formatFilterValue(value, filterValueFormat(globalFeatureByName.get(exclusion.name)));
};
@ -165,369 +165,217 @@ export default function AreaPane({
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && stats != null} />
<div className="flex-1 overflow-y-auto">
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
<div className="space-y-3 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
</h2>
{loading && (
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
<div className="border-b border-warm-200 bg-white dark:border-navy-700 dark:bg-navy-950">
<div className="space-y-3 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<h2 className="truncate text-base font-semibold text-warm-900 dark:text-warm-100">
{isPostcode ? hexagonId : t('areaPane.areaOverview')}
</h2>
{loading && (
<span className="h-3 w-3 shrink-0 rounded-full border-2 border-teal-600 border-t-transparent dark:border-teal-400 dark:border-t-transparent animate-spin" />
)}
</div>
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
{t('areaPane.statsFor', {
type: isPostcode
? t('common.postcode').toLowerCase()
: t('common.area').toLowerCase(),
})}
</p>
</div>
<div className="shrink-0 text-right">
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
</div>
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
{t('common.propertiesPlural')}
</div>
</div>
</div>
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
{t('areaPane.statsBasis')}
</span>
<div className="inline-flex shrink-0 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
<button
type="button"
disabled={!filtersActive}
aria-pressed={statsUseFilters && filtersActive}
onClick={() => onStatsUseFiltersChange(true)}
className={`rounded px-2 py-1 text-xs font-medium ${
statsUseFilters && filtersActive
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.matchingFiltersOption')}
</button>
<button
type="button"
aria-pressed={!statsUseFilters || !filtersActive}
onClick={() => onStatsUseFiltersChange(false)}
className={`rounded px-2 py-1 text-xs font-medium ${
!statsUseFilters || !filtersActive
? '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>
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
{filtersActive
? statsUseFilters
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
: t('areaPane.filtersIgnoredForStats')
: t('areaPane.noFiltersAffectStats')}
</p>
</div>
{showFlipToggleCallout && (
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
<p className="mt-1">
{unfilteredCount != null
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
: t('areaPane.showAllStatsFallback')}
</p>
<button
type="button"
onClick={() => onStatsUseFiltersChange(false)}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('areaPane.showAllStats')}
</button>
{filterExclusions.length > 0 && (
<div className="mt-2 border-t border-amber-200 pt-2 dark:border-amber-800/70">
<p className="font-semibold">{t('areaPane.closestBlockingFilters')}</p>
<ol className="mt-1.5 space-y-1.5">
{filterExclusions.map((exclusion) => (
<li
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
>
<div className="truncate font-medium">
{getExclusionLabel(exclusion)}
</div>
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
{getExclusionAdjustment(exclusion)}
</p>
</li>
))}
</ol>
</div>
)}
</div>
<p className="mt-0.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
{t('areaPane.statsFor', {
type: isPostcode
? t('common.postcode').toLowerCase()
: t('common.area').toLowerCase(),
})}
</p>
</div>
<div className="shrink-0 text-right">
<div className="text-lg font-semibold tabular-nums leading-none text-navy-950 dark:text-warm-50">
{propertyCount == null ? '...' : propertyCount.toLocaleString()}
</div>
<div className="mt-0.5 text-xs font-medium text-warm-500 dark:text-warm-400">
{t('common.propertiesPlural')}
</div>
</div>
)}
</div>
</div>
<div className="rounded border border-warm-200 bg-warm-50 px-2.5 py-2 dark:border-navy-700 dark:bg-navy-900">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-warm-700 dark:text-warm-200">
{t('areaPane.statsBasis')}
</span>
<div className="inline-flex shrink-0 rounded-md bg-warm-200 p-0.5 dark:bg-navy-800">
<button
type="button"
disabled={!filtersActive}
aria-pressed={statsUseFilters && filtersActive}
onClick={() => onStatsUseFiltersChange(true)}
className={`rounded px-2 py-1 text-xs font-medium ${
statsUseFilters && filtersActive
? 'bg-white text-teal-700 shadow-sm dark:bg-navy-700 dark:text-teal-300'
: 'text-warm-600 hover:text-warm-900 disabled:cursor-not-allowed disabled:opacity-50 dark:text-warm-400 dark:hover:text-warm-100'
}`}
>
{t('areaPane.matchingFiltersOption')}
</button>
<button
type="button"
aria-pressed={!statsUseFilters || !filtersActive}
onClick={() => onStatsUseFiltersChange(false)}
className={`rounded px-2 py-1 text-xs font-medium ${
!statsUseFilters || !filtersActive
? '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>
<p className="mt-1.5 text-xs leading-snug text-warm-500 dark:text-warm-400">
{filtersActive
? statsUseFilters
? t('areaPane.filtersAffectStats', { count: activeFilterCount })
: t('areaPane.filtersIgnoredForStats')
: t('areaPane.noFiltersAffectStats')}
</p>
</div>
{showFlipToggleCallout && (
<div className="mt-2 rounded border border-amber-200 bg-amber-50 px-2.5 py-2 text-xs leading-snug text-amber-900 dark:border-amber-800/70 dark:bg-amber-950/40 dark:text-amber-100">
<p className="font-semibold">{t('areaPane.filteredStatsEmpty')}</p>
<p className="mt-1">
{unfilteredCount != null
? t('areaPane.showAllStatsHint', { count: unfilteredCount })
: t('areaPane.showAllStatsFallback')}
</p>
<button
type="button"
onClick={() => onStatsUseFiltersChange(false)}
className="mt-2 rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t('areaPane.showAllStats')}
</button>
{filterExclusions.length > 0 && (
<div className="mt-2 border-t border-amber-200 pt-2 dark:border-amber-800/70">
<p className="font-semibold">{t('areaPane.closestBlockingFilters')}</p>
<ol className="mt-1.5 space-y-1.5">
{filterExclusions.map((exclusion) => (
<li
key={`${exclusion.kind}:${exclusion.name}:${exclusion.direction}:${exclusion.category ?? ''}`}
className="rounded bg-white/70 px-2 py-1.5 dark:bg-navy-950/40"
>
<div className="truncate font-medium">{getExclusionLabel(exclusion)}</div>
<p className="mt-0.5 text-amber-800/80 dark:text-amber-100/80">
{getExclusionAdjustment(exclusion)}
</p>
</li>
))}
</ol>
{hexagonLocation && stats && (
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
)}
{(() => {
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
<JourneyInstructions
postcode={journeyPostcode}
entries={travelTimeEntries}
label={!isPostcode ? journeyPostcode : undefined}
shareCode={shareCode}
/>
) : null;
})()}
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
<div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
{stats.count > 0 && <HistogramLegend />}
{stats.price_history &&
(() => {
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
return uniqueYears.size > 1;
})() && (
<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>
)}
</div>
)}
</div>
</div>
{featureGroups.map((group) => {
const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
);
if (!hasData) return null;
{hexagonLocation && stats && (
<ExternalSearchLinks location={hexagonLocation} filters={filters} />
)}
{(() => {
const journeyPostcode = isPostcode ? hexagonId : stats?.central_postcode;
return journeyPostcode && travelTimeEntries && travelTimeEntries.length > 0 ? (
<JourneyInstructions
postcode={journeyPostcode}
entries={travelTimeEntries}
label={!isPostcode ? journeyPostcode : undefined}
shareCode={shareCode}
/>
) : null;
})()}
{loading && !stats ? (
<LoadingSkeleton />
) : stats ? (
<div>
{hexagonLocation && <StreetViewEmbed location={hexagonLocation} />}
{stats.count > 0 && <HistogramLegend />}
{stats.price_history &&
(() => {
const uniqueYears = new Set(stats.price_history.map((p) => Math.floor(p.year)));
return uniqueYears.size > 1;
})() && (
<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>
)}
{featureGroups.map((group) => {
const hasData = group.features.some(
(feature) => numericByName.has(feature.name) || enumByName.has(feature.name)
);
if (!hasData) return null;
const stackedCharts = STACKED_GROUPS[group.name];
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
const stackedCharts = STACKED_GROUPS[group.name];
const stackedEnumCharts = STACKED_ENUM_GROUPS[group.name];
const stackedEnumFeatureNames = new Set<string>(
stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
) ?? []
);
const stackedEnumFeatureNames = new Set<string>(
stackedEnumCharts?.flatMap((c) =>
[c.feature, ...c.components].filter((s): s is string => Boolean(s))
) ?? []
);
const expanded = isGroupExpanded(group.name);
const expanded = isGroupExpanded(group.name);
return (
<div key={group.name}>
<CollapsibleGroupHeader
name={group.name}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{expanded && (
<div className="px-3 py-2 space-y-3">
{stackedCharts?.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
.filter((s) => s.value > 0);
const isPercentageComposition = chart.unit === '%' && !chart.feature;
const displaySegments = isPercentageComposition
? normalizePercentageSegments(segments)
: segments;
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: displaySegments.reduce((sum, s) => sum + s.value, 0);
// Use rateFeature (e.g. per-1k) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
const displayValue = isPercentageComposition
? 100
: rateStats
? rateStats.mean
: total;
// Use rateFeature for info popup and national average when available
const infoFeatureName = chart.rateFeature ?? chart.feature;
const featureMeta = infoFeatureName
? globalFeatureByName.get(infoFeatureName)
: undefined;
const globalMean = featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
if (total === 0) return null;
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: ts(chart.label) }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{ts(chart.label)}
</span>
)}
<div className="text-right shrink-0">
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(displayValue)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
{globalMean != null && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
</div>
)}
</div>
</div>
<StackedBarChart
segments={displaySegments}
total={total}
colorMap={
chart.label === 'Political vote share'
? PARTY_FEATURE_COLORS
: STACKED_SEGMENT_COLORS
}
/>
</div>
);
})}
{(() => {
const stackedFeatureNames = new Set<string>(
stackedCharts?.flatMap((c) =>
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
Boolean(s)
)
) ?? []
);
return group.features
.filter(
(f) =>
!stackedFeatureNames.has(f.name) &&
!stackedEnumFeatureNames.has(f.name)
)
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
meanLabel={t('areaPane.nationalAvg')}
formatLabel={(v) =>
formatFilterValue(
v,
feature.suffix === '%'
? { raw: feature.raw, suffix: feature.suffix }
: feature.raw
)
}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={(v) =>
formatFilterValue(
v,
feature.suffix === '%'
? { raw: feature.raw, suffix: feature.suffix }
: feature.raw
)
}
/>
))}
</div>
);
}
if (enumStats) {
const globalFeature = globalFeatureByName.get(feature.name);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart
counts={enumStats.counts}
globalCounts={globalFeature?.counts}
featureName={feature.name}
/>
</div>
);
}
return null;
});
})()}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
if (chart.components.length === 1) {
const stats = enumByName.get(chart.components[0]);
if (!stats) return null;
const segments = chart.valueOrder
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
return (
<div key={group.name}>
<CollapsibleGroupHeader
name={group.name}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
className="px-3 py-2.5 text-sm font-bold text-warm-500 bg-warm-50 dark:bg-warm-900 dark:text-warm-400 sticky top-0 z-10 hover:bg-warm-100 dark:hover:bg-warm-800"
/>
{expanded && (
<div className="px-3 py-2 space-y-3">
{stackedCharts?.map((chart) => {
const segments = chart.components
.map((name) => ({
name,
value: numericByName.get(name)?.mean ?? 0,
}))
.filter((s) => s.value > 0);
const total = segments.reduce((sum, s) => sum + s.value, 0);
const isPercentageComposition = chart.unit === '%' && !chart.feature;
const displaySegments = isPercentageComposition
? normalizePercentageSegments(segments)
: segments;
const aggregateStats = chart.feature
? numericByName.get(chart.feature)
: undefined;
const total = aggregateStats
? aggregateStats.mean
: displaySegments.reduce((sum, s) => sum + s.value, 0);
// Use rateFeature (e.g. per-1k) for display if available
const rateStats = chart.rateFeature
? numericByName.get(chart.rateFeature)
: undefined;
const displayValue = isPercentageComposition
? 100
: rateStats
? rateStats.mean
: total;
// Use rateFeature for info popup and national average when available
const infoFeatureName = chart.rateFeature ?? chart.feature;
const featureMeta = infoFeatureName
? globalFeatureByName.get(infoFeatureName)
: undefined;
const globalMean = featureMeta?.histogram
? calculateHistogramMean(featureMeta.histogram)
: undefined;
if (total === 0) return null;
return (
@ -538,7 +386,7 @@ export default function AreaPane({
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={featureMeta}
feature={{ ...featureMeta, name: ts(chart.label) }}
onShowInfo={setInfoFeature}
className="mr-2"
/>
@ -547,62 +395,216 @@ export default function AreaPane({
{ts(chart.label)}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{total.toLocaleString()}
</span>
<div className="text-right shrink-0">
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(displayValue)}
{chart.unit ? ` ${chart.unit}` : ''}
</span>
{globalMean != null && (
<div className="text-[10px] text-warm-400 dark:text-warm-500 whitespace-nowrap">
{t('areaPane.nationalAvg')}: {formatValue(globalMean)}
</div>
)}
</div>
</div>
<StackedBarChart
segments={segments}
segments={displaySegments}
total={total}
colorMap={Object.fromEntries(
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
colorMap={
chart.label === 'Political vote share'
? PARTY_FEATURE_COLORS
: STACKED_SEGMENT_COLORS
}
/>
</div>
);
}
})}
{(() => {
const stackedFeatureNames = new Set<string>(
stackedCharts?.flatMap((c) =>
[c.feature, c.rateFeature, ...c.components].filter((s): s is string =>
Boolean(s)
)
) ?? []
);
return group.features
.filter(
(f) =>
!stackedFeatureNames.has(f.name) &&
!stackedEnumFeatureNames.has(f.name)
)
.map((feature) => {
const numericStats = numericByName.get(feature.name);
const enumStats = enumByName.get(feature.name);
const components = chart.components
.map((name) => {
const stats = enumByName.get(name);
return stats ? { label: name, stats } : null;
})
.filter((c): c is NonNullable<typeof c> => c !== null);
if (numericStats) {
const globalFeature = globalFeatureByName.get(feature.name);
const globalHistogram = globalFeature?.histogram;
const globalMean = globalHistogram
? calculateHistogramMean(globalHistogram)
: undefined;
if (components.length === 0) return null;
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline">
<FeatureLabel
feature={feature}
onShowInfo={setInfoFeature}
className="mr-2"
/>
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{formatValue(numericStats.mean, feature)}
</span>
</div>
{numericStats.histogram &&
(globalHistogram ? (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={globalHistogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
globalMean={globalMean}
meanLabel={t('areaPane.nationalAvg')}
formatLabel={(v) =>
formatFilterValue(
v,
feature.suffix === '%'
? { raw: feature.raw, suffix: feature.suffix }
: feature.raw
)
}
/>
) : (
<DualHistogram
localCounts={numericStats.histogram.counts}
globalCounts={numericStats.histogram.counts}
p1={numericStats.histogram.p1}
p99={numericStats.histogram.p99}
formatLabel={(v) =>
formatFilterValue(
v,
feature.suffix === '%'
? { raw: feature.raw, suffix: feature.suffix }
: feature.raw
)
}
/>
))}
</div>
);
}
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: ts(chart.label) }}
onShowInfo={setInfoFeature}
if (enumStats) {
const globalFeature = globalFeatureByName.get(feature.name);
return (
<div
key={feature.name}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<FeatureLabel feature={feature} onShowInfo={setInfoFeature} />
<EnumBarChart
counts={enumStats.counts}
globalCounts={globalFeature?.counts}
featureName={feature.name}
/>
</div>
);
}
return null;
});
})()}
{stackedEnumCharts?.map((chart) => {
const featureMeta = chart.feature
? globalFeatureByName.get(chart.feature)
: undefined;
if (chart.components.length === 1) {
const stats = enumByName.get(chart.components[0]);
if (!stats) return null;
const segments = chart.valueOrder
.map((value) => ({ name: value, value: stats.counts[value] ?? 0 }))
.filter((s) => s.value > 0);
const total = segments.reduce((sum, s) => sum + s.value, 0);
if (total === 0) return null;
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="flex justify-between items-baseline mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={featureMeta}
onShowInfo={setInfoFeature}
className="mr-2"
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300 truncate mr-2">
{ts(chart.label)}
</span>
)}
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400 whitespace-nowrap">
{total.toLocaleString()}
</span>
</div>
<StackedBarChart
segments={segments}
total={total}
colorMap={Object.fromEntries(
chart.valueOrder.map((v, i) => [v, chart.valueColors[i]])
)}
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300">
{ts(chart.label)}
</span>
)}
</div>
);
}
const components = chart.components
.map((name) => {
const stats = enumByName.get(name);
return stats ? { label: name, stats } : null;
})
.filter((c): c is NonNullable<typeof c> => c !== null);
if (components.length === 0) return null;
return (
<div
key={ts(chart.label)}
className="bg-warm-50 dark:bg-warm-800 rounded p-2"
>
<div className="mb-1.5">
{featureMeta ? (
<FeatureLabel
feature={{ ...featureMeta, name: ts(chart.label) }}
onShowInfo={setInfoFeature}
/>
) : (
<span className="text-xs text-warm-700 dark:text-warm-300">
{ts(chart.label)}
</span>
)}
</div>
<StackedEnumChart
components={components}
valueOrder={chart.valueOrder}
valueColors={chart.valueColors}
/>
</div>
<StackedEnumChart
components={components}
valueOrder={chart.valueOrder}
valueColors={chart.valueColors}
/>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
) : null}
);
})}
</div>
)}
</div>
);
})}
</div>
) : null}
</div>
</div>

View file

@ -86,10 +86,7 @@ export default function FeatureBrowser({
const showTravelModes =
visibleModes.length > 0 &&
(!search ||
'travel time journey commute car bicycle walking transit transport station tube train'.includes(
search.toLowerCase()
));
(!search || t('filters.travelTimeKeywords').toLowerCase().includes(search.toLowerCase()));
// Keep "Transport" first because journey and transport proximity controls belong together.
const mergedGrouped = useMemo(() => {
@ -123,7 +120,7 @@ export default function FeatureBrowser({
name={group.name}
expanded={isExpanded}
onToggle={() => toggleGroup(group.name)}
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-10 hover:bg-warm-200 dark:hover:bg-warm-800"
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"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">
{group.features.length +

View file

@ -9,6 +9,7 @@ vi.mock('react-i18next', () => ({
if (key === 'areaPane.to') return `To ${values?.destination}`;
if (key === 'areaPane.journeysFrom') return `Journeys from ${values?.label}`;
if (key === 'common.min') return 'min';
if (key === 'common.minute') return 'min';
if (key === 'common.loading') return 'Loading';
if (key === 'travel.bestCase') return 'Best case';
if (key === 'areaPane.walk') return 'Walk';

View file

@ -170,7 +170,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
)}
<span className="text-[11px] text-warm-500 dark:text-warm-400">
{leg.mode === 'walk' ? t('areaPane.walk') : t('areaPane.cycle')} · {leg.minutes}{' '}
{t('common.min')}
{t('common.minute')}
</span>
</div>
</div>
@ -191,7 +191,7 @@ function TimelineLeg({ leg, isLast }: { leg: JourneyLeg; isLast: boolean }) {
<div className="flex items-center gap-1.5 flex-wrap">
<RouteBadge mode={leg.mode} />
<span className="text-[11px] text-warm-500 dark:text-warm-400">
{leg.minutes} {t('common.min')}
{leg.minutes} {t('common.minute')}
</span>
</div>
{leg.from && leg.to && (
@ -333,7 +333,7 @@ export default function JourneyInstructions({
{!j.loading && totalMin > 0 && (
<span className="text-xs font-semibold text-teal-700 dark:text-teal-400">
{isBestCase ? `${t('travel.bestCase')} · ` : ''}
{totalMin} {t('common.min')}
{totalMin} {t('common.minute')}
</span>
)}
</div>
@ -381,7 +381,7 @@ export default function JourneyInstructions({
)}
<span className="text-xs text-warm-600 dark:text-warm-300">
{isBestCase ? t('travel.bestCase') : t('areaPane.walk')} · {totalMin}{' '}
{t('common.min')}
{t('common.minute')}
</span>
</div>
{showGoogleMapsLink && (

View file

@ -1,6 +1,7 @@
import { useCallback, useRef, useEffect, useState, useMemo, memo } from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Map as MapGL, useControl, ScaleControl } from 'react-map-gl/maplibre';
import type { MapRef } from 'react-map-gl/maplibre';
import { MapboxOverlay } from '@deck.gl/mapbox';
@ -85,10 +86,10 @@ function formatListingPrice(price: number): string {
return `£${price.toLocaleString()}`;
}
function formatListingHeadline(listing: ActualListing): string | null {
function formatListingHeadline(listing: ActualListing, t: TFunction): string | null {
const parts: string[] = [];
if (listing.bedrooms != null) parts.push(`${listing.bedrooms} bed`);
if (listing.bathrooms != null) parts.push(`${listing.bathrooms} bath`);
if (listing.bedrooms != null) parts.push(t('common.bedsCount', { count: listing.bedrooms }));
if (listing.bathrooms != null) parts.push(t('common.bathsCount', { count: listing.bathrooms }));
if (listing.property_sub_type) parts.push(listing.property_sub_type);
else if (listing.property_type) parts.push(listing.property_type);
return parts.length > 0 ? parts.join(' · ') : null;
@ -730,9 +731,9 @@ export default memo(function Map({
) : null}
</div>
)}
{formatListingHeadline(listingPopup.listing) && (
{formatListingHeadline(listingPopup.listing, t) && (
<div className="text-xs text-warm-700 dark:text-warm-200 mt-0.5">
{formatListingHeadline(listingPopup.listing)}
{formatListingHeadline(listingPopup.listing, t)}
</div>
)}
{listingPopup.listing.address && (

View file

@ -6,6 +6,7 @@ import type { SearchedLocation } from './LocationSearch';
import { useMapData } from '../../hooks/useMapData';
import { usePOIData } from '../../hooks/usePOIData';
import { useActualListings } from '../../hooks/useActualListings';
import { buildTravelParam } from '../../lib/travel-params';
import { useFilters } from '../../hooks/useFilters';
import { useHexagonSelection } from '../../hooks/useHexagonSelection';
import { usePaneResize } from '../../hooks/usePaneResize';
@ -15,7 +16,7 @@ import { useUrlSync } from '../../hooks/useUrlSync';
import { useTutorial } from '../../hooks/useTutorial';
import { getTutorialStyles } from '../../lib/tutorial-styles';
import { travelFieldKey, useTravelTime } from '../../hooks/useTravelTime';
import { apiUrl, authHeaders } from '../../lib/api';
import { apiUrl, authHeaders, buildFilterString } from '../../lib/api';
import { useFilterCounts } from '../../hooks/useFilterCounts';
import { trackEvent } from '../../lib/analytics';
import { INITIAL_VIEW_STATE, POSTCODE_SEARCH_ZOOM } from '../../lib/consts';
@ -408,10 +409,15 @@ export default function MapPage({
}, []);
const pois = usePOIData(mapData.bounds, selectedPOICategories);
const { listings: actualListings } = useActualListings(
mapData.bounds,
mapData.currentView?.zoom ?? 0
const actualListingsFilterParam = useMemo(
() => buildFilterString(filters, features),
[filters, features]
);
const actualListingsTravelParam = useMemo(() => buildTravelParam(entries), [entries]);
const { listings: actualListings } = useActualListings(mapData.bounds, {
filterParam: actualListingsFilterParam,
travelParam: actualListingsTravelParam,
});
const [isAreaGroupExpanded, toggleAreaGroup] = useCollapsibleGroups(true);
useUrlSync(
@ -464,11 +470,7 @@ export default function MapPage({
mapData.resolution,
areaStats
);
const tutorial = useTutorial(
initialLoading,
isMobile,
deferTutorial || mapData.licenseRequired
);
const tutorial = useTutorial(initialLoading, isMobile, deferTutorial || mapData.licenseRequired);
const tutorialTheme = useMemo(() => getTutorialStyles(theme), [theme]);
const densityLabel = t('mapLegend.historicalMatches');
const hasActiveFilters = Object.keys(filters).length > 0 || activeEntries.length > 0;
@ -499,15 +501,7 @@ export default function MapPage({
entries,
shareCode
).toString(),
[
entries,
features,
filters,
rightPaneTab,
selectedPOICategories,
shareCode,
shareAndSaveView,
]
[entries, features, filters, rightPaneTab, selectedPOICategories, shareCode, shareAndSaveView]
);
const handleSaveSearch = useCallback(
async (name: string) => {
@ -652,11 +646,7 @@ export default function MapPage({
};
const exportToast = (
<ExportToast
notice={exportNotice}
closeLabel={t('common.close')}
onClose={clearExportNotice}
/>
<ExportToast notice={exportNotice} closeLabel={t('common.close')} onClose={clearExportNotice} />
);
const toasts = exportToast;
@ -671,9 +661,7 @@ export default function MapPage({
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{
strong: (
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
),
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
}}
/>
</span>

View file

@ -61,64 +61,67 @@ export function PropertiesPane({
<div className="relative flex h-full flex-col">
<IndeterminateProgressBar show={loading && properties.length > 0} />
<div className="flex-1 overflow-y-auto">
{showInfo && (
<InfoPopup
title={t('propertyCard.propertyData')}
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: t('common.viewDataSource'),
onClick: () => {
onNavigateToSource('epc');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
{t('propertyCard.propertyDataDesc')}
</p>
</InfoPopup>
)}
<div className="p-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('propertyCard.searchPlaceholder')}
className="p-2"
/>
</div>
<div>
{loading && properties.length === 0 ? (
<PropertyLoadingSkeleton />
) : (
<>
{filtered.map((property, idx) => (
<PropertyCard key={idx} property={property} />
))}
{properties.length < total && (
<button
onClick={onLoadMore}
disabled={loading}
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
)}
</button>
)}
</>
{showInfo && (
<InfoPopup
title={t('propertyCard.propertyData')}
onClose={() => setShowInfo(false)}
sourceLink={
onNavigateToSource
? {
label: t('common.viewDataSource'),
onClick: () => {
onNavigateToSource('epc');
setShowInfo(false);
},
}
: undefined
}
>
<p className="text-sm text-warm-700 dark:text-warm-300 mb-4 leading-relaxed">
{t('propertyCard.propertyDataDesc')}
</p>
</InfoPopup>
)}
</div>
<div className="p-2">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('propertyCard.searchPlaceholder')}
className="p-2"
/>
</div>
<div>
{loading && properties.length === 0 ? (
<PropertyLoadingSkeleton />
) : (
<>
{filtered.map((property) => (
<PropertyCard
key={`${property.lat},${property.lon}|${property.postcode ?? ''}|${property.address ?? ''}`}
property={property}
/>
))}
{properties.length < total && (
<button
onClick={onLoadMore}
disabled={loading}
className="w-full p-4 text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/30 disabled:opacity-50 transition-colors"
>
{loading ? (
<span className="flex items-center justify-center gap-2">
<span className="inline-block w-4 h-4 border-2 border-teal-600 dark:border-teal-400 border-t-transparent rounded-full animate-spin" />
{t('common.loading')}
</span>
) : (
`${t('common.loadMore')} (${t('common.remaining', { count: total - properties.length })})`
)}
</button>
)}
</>
)}
</div>
</div>
</div>
);

View file

@ -79,7 +79,7 @@ export function TravelTimeCard({
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-2">
<ModeIcon className="w-4 h-4 text-teal-600 dark:text-teal-400" />
<span className="text-sm font-medium text-navy-950 dark:text-warm-100">
{t('travel.travelTime', { mode: modes.label(mode) })}
@ -158,10 +158,10 @@ export function TravelTimeCard({
/>
<div className="relative h-4 mt-1 mx-2.5 text-[10px] text-warm-500 dark:text-warm-400 leading-tight">
<span className="absolute left-0">
{formatFilterValue(displayRange[0])} {t('common.min')}
{formatFilterValue(displayRange[0])} {t('common.minute')}
</span>
<span className="absolute right-0">
{formatFilterValue(displayRange[1])} {t('common.min')}
{formatFilterValue(displayRange[1])} {t('common.minute')}
</span>
</div>
{filterImpact != null && filterImpact > 0 && (

View file

@ -294,7 +294,7 @@ export function ActiveFilterList({
name={group.name}
expanded={expanded}
onToggle={() => onToggleGroup(group.name)}
className="sticky top-0 z-10 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-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"
>
<span className="text-xs font-medium text-warm-400 dark:text-warm-500">{count}</span>
</CollapsibleGroupHeader>

View file

@ -1,4 +1,4 @@
import { useEffect, type FormEvent } from 'react';
import { useEffect, useRef, type FormEvent } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
@ -30,6 +30,8 @@ export function ClearFiltersDialog({
}: ClearFiltersDialogProps) {
const { t } = useTranslation();
const isEditing = !!editingSearchName && !!onUpdateAndClear;
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
@ -40,17 +42,41 @@ export function ClearFiltersDialog({
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
useEffect(() => {
if (!open) return;
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
const firstFocusable = dialogRef.current?.querySelector<HTMLElement>(
'input, button, select, textarea, a[href], [tabindex]:not([tabindex="-1"])'
);
(firstFocusable ?? dialogRef.current)?.focus();
return () => {
previouslyFocusedRef.current?.focus?.();
};
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
role="presentation"
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="clear-filters-dialog-title"
tabIndex={-1}
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
<h2
id="clear-filters-dialog-title"
className="text-lg font-semibold text-navy-950 dark:text-white"
>
{t('filters.clearAllTitle')}
</h2>
<button
@ -67,9 +93,7 @@ export function ClearFiltersDialog({
i18nKey="filters.clearAllUpdatePrompt"
values={{ name: editingSearchName }}
components={{
strong: (
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
),
strong: <strong className="font-semibold text-navy-950 dark:text-warm-100" />,
}}
/>
</p>

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { GoogleIcon } from './icons/GoogleIcon';
import { trackEvent } from '../../lib/analytics';
import { useModalA11y } from '../../hooks/useModalA11y';
type View = 'login' | 'register' | 'forgot';
@ -34,11 +35,20 @@ export default function AuthModal({
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [resetSent, setResetSent] = useState(false);
const dialogRef = useModalA11y();
useEffect(() => {
trackEvent('Auth Modal Open', { tab: initialTab });
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const switchView = useCallback(
(newView: View) => {
setView(newView);
@ -97,14 +107,26 @@ export default function AuthModal({
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
role="presentation"
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700">
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="auth-modal-title"
tabIndex={-1}
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">{title}</h2>
<h2 id="auth-modal-title" className="text-lg font-semibold text-navy-950 dark:text-white">
{title}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />

View file

@ -26,6 +26,7 @@ export function FeatureLabel({
}: FeatureLabelProps) {
const { t } = useTranslation();
const textClass = size === 'sm' ? 'text-sm' : 'text-xs';
const gapClass = size === 'sm' ? 'gap-2' : 'gap-1';
const mobileHide = hideIconOnMobile ? 'hidden md:block ' : '';
const iconClass = `${mobileHide}w-3.5 h-3.5 text-teal-600 dark:text-teal-400 shrink-0`;
const featureIcon = getFeatureIcon(feature.name, iconClass);
@ -56,7 +57,7 @@ export function FeatureLabel({
return (
<div
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} gap-1 min-w-0 ${className}`}
className={`flex ${size === 'xs' ? 'items-center' : 'items-start'} ${gapClass} min-w-0 ${className}`}
>
{featureIcon}
{GroupIcon && <GroupIcon className={iconClass} />}

View file

@ -3,10 +3,7 @@ interface IndeterminateProgressBarProps {
className?: string;
}
export function IndeterminateProgressBar({
show,
className = '',
}: IndeterminateProgressBarProps) {
export function IndeterminateProgressBar({ show, className = '' }: IndeterminateProgressBarProps) {
if (!show) return null;
return (

View file

@ -1,4 +1,4 @@
import { useRef, useCallback, type ReactNode } from 'react';
import { useRef, useCallback, useEffect, useId, type ReactNode } from 'react';
import { useClickOutside } from '../../hooks/useClickOutside';
import { CloseIcon } from './icons';
import { IconButton } from './IconButton';
@ -12,6 +12,8 @@ interface InfoPopupProps {
export default function InfoPopup({ title, children, onClose, sourceLink }: InfoPopupProps) {
const popupRef = useRef<HTMLDivElement>(null);
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
const titleId = useId();
const handleClose = useCallback(() => {
onClose();
@ -19,14 +21,42 @@ export default function InfoPopup({ title, children, onClose, sourceLink }: Info
useClickOutside(popupRef, handleClose);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
useEffect(() => {
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
const firstFocusable = popupRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
(firstFocusable ?? popupRef.current)?.focus();
return () => {
previouslyFocusedRef.current?.focus?.();
};
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
role="presentation"
>
<div
ref={popupRef}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className="bg-white dark:bg-warm-800 border border-warm-200 dark:border-warm-700 rounded-lg shadow-xl max-w-md w-full max-h-full overflow-y-auto p-5 outline-none"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">{title}</h3>
<h3 id={titleId} className="text-sm font-semibold text-warm-900 dark:text-warm-100 pr-4">
{title}
</h3>
<IconButton onClick={onClose} className="shrink-0">
<CloseIcon />
</IconButton>

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { useModalA11y } from '../../hooks/useModalA11y';
interface LicenseSuccessModalProps {
onClose: () => void;
@ -14,6 +15,7 @@ export default function LicenseSuccessModal({
const { t } = useTranslation();
const isSuccess = status === 'success';
const isVerifying = status === 'verifying';
const dialogRef = useModalA11y();
const particles = useMemo(
() =>
Array.from({ length: 40 }, (_, i) => ({
@ -36,6 +38,14 @@ export default function LicenseSuccessModal({
return () => clearTimeout(timer);
}, [isSuccess, onClose]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const title =
status === 'verifying'
? t('licenseSuccess.verifyingTitle')
@ -56,9 +66,12 @@ export default function LicenseSuccessModal({
: t('licenseSuccess.description');
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
role="presentation"
>
{isSuccess && (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
{particles.map((p) => (
<div
key={p.id}
@ -78,7 +91,14 @@ export default function LicenseSuccessModal({
</div>
)}
<div className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="license-success-modal-title"
tabIndex={-1}
className="relative z-10 w-full max-w-sm mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 text-center overflow-hidden outline-none"
>
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8">
<div className="h-14 mb-3 flex items-center justify-center">
{isVerifying ? (
@ -87,7 +107,9 @@ export default function LicenseSuccessModal({
<div className="text-5xl">{isSuccess ? '🎉' : '✓'}</div>
)}
</div>
<h2 className="text-2xl font-bold text-white">{title}</h2>
<h2 id="license-success-modal-title" className="text-2xl font-bold text-white">
{title}
</h2>
<p className="text-warm-300 text-sm mt-2">{subtitle}</p>
</div>
<div className="px-6 py-6">

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CheckIcon } from './icons/CheckIcon';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { useModalA11y } from '../../hooks/useModalA11y';
export default function SaveSearchModal({
onClose,
@ -20,6 +21,7 @@ export default function SaveSearchModal({
const { t } = useTranslation();
const [name, setName] = useState('');
const [saved, setSaved] = useState(false);
const dialogRef = useModalA11y();
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@ -44,18 +46,32 @@ export default function SaveSearchModal({
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" />
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
role="presentation"
>
<div className="absolute inset-0 bg-black/50 dark:bg-black/70" aria-hidden="true" />
<div
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700"
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="save-search-modal-title"
tabIndex={-1}
className="relative w-full max-w-sm mx-4 bg-white dark:bg-warm-900 rounded-lg shadow-xl border border-warm-200 dark:border-warm-700 outline-none"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-5 pb-3">
<h2 className="text-lg font-semibold text-navy-950 dark:text-white">
<h2
id="save-search-modal-title"
className="text-lg font-semibold text-navy-950 dark:text-white"
>
{saved ? t('saveSearch.saved') : t('saveSearch.title')}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="text-warm-400 hover:text-warm-700 dark:text-warm-400 dark:hover:text-warm-200"
>
<CloseIcon className="w-5 h-5" />

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { CloseIcon } from './icons/CloseIcon';
import { SpinnerIcon } from './icons/SpinnerIcon';
import { apiUrl, logNonAbortError } from '../../lib/api';
import { useModalA11y } from '../../hooks/useModalA11y';
interface UpgradeModalProps {
isLoggedIn: boolean;
@ -28,6 +29,7 @@ export default function UpgradeModal({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pricePence, setPricePence] = useState<number | null>(null);
const dialogRef = useModalA11y();
useEffect(() => {
fetch(apiUrl('pricing'))
@ -38,6 +40,14 @@ export default function UpgradeModal({
.catch((err) => logNonAbortError('Failed to fetch pricing', err));
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onZoomToFreeZone();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onZoomToFreeZone]);
const priceLabel =
pricePence === null
? '...'
@ -59,11 +69,23 @@ export default function UpgradeModal({
};
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50">
<div className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden">
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50"
role="presentation"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="upgrade-modal-title"
tabIndex={-1}
className="relative w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl shadow-2xl border border-warm-200 dark:border-warm-700 overflow-hidden outline-none"
>
{/* Close button */}
<button
type="button"
onClick={onZoomToFreeZone}
aria-label={t('common.close')}
className="absolute top-3 right-3 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
>
<CloseIcon className="w-5 h-5" />
@ -71,7 +93,9 @@ export default function UpgradeModal({
{/* Header */}
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">{t('upgrade.title')}</h2>
<h2 id="upgrade-modal-title" className="text-2xl font-bold text-white mb-2">
{t('upgrade.title')}
</h2>
<p className="text-warm-300 text-sm">
{isShareReturn ? t('upgrade.sharedAreaDescription') : t('upgrade.description')}
</p>

View file

@ -4,9 +4,16 @@ import { apiUrl, authHeaders, logNonAbortError } from '../lib/api';
const DEBOUNCE_MS = 200;
export function useActualListings(bounds: Bounds | null) {
interface UseActualListingsOptions {
filterParam?: string;
travelParam?: string;
}
export function useActualListings(
bounds: Bounds | null,
{ filterParam = '', travelParam = '' }: UseActualListingsOptions = {}
) {
const [listings, setListings] = useState<ActualListing[]>([]);
const [truncated, setTruncated] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const requestIdRef = useRef(0);
@ -18,7 +25,6 @@ export function useActualListings(bounds: Bounds | null) {
if (!bounds) {
abortControllerRef.current?.abort();
if (listings.length !== 0) setListings([]);
if (truncated) setTruncated(false);
return;
}
@ -30,6 +36,8 @@ export function useActualListings(bounds: Bounds | null) {
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const params = new URLSearchParams({ bounds: boundsStr });
if (filterParam) params.set('filters', filterParam);
if (travelParam) params.set('travel', travelParam);
const res = await fetch(
apiUrl('actual-listings', params),
authHeaders({ signal: abortControllerRef.current.signal })
@ -38,7 +46,6 @@ export function useActualListings(bounds: Bounds | null) {
const json: ActualListingsResponse = await res.json();
if (requestIdRef.current !== requestId) return;
setListings(json.listings || []);
setTruncated(Boolean(json.truncated));
} catch (err) {
logNonAbortError('Failed to fetch actual listings', err);
}
@ -48,9 +55,9 @@ export function useActualListings(bounds: Bounds | null) {
if (debounceRef.current) clearTimeout(debounceRef.current);
abortControllerRef.current?.abort();
};
// listings/truncated intentionally excluded — they're internal state, not inputs.
// listings intentionally excluded — it's internal state, not an input.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bounds]);
}, [bounds, filterParam, travelParam]);
return { listings, truncated };
return { listings };
}

View file

@ -111,7 +111,6 @@ export function useDeckLayers({
isDark,
hexagonData: data,
postcodeData,
resolution: usePostcodeView ? 0 : Math.round(zoom),
usePostcodeView,
});
@ -280,21 +279,33 @@ export function useDeckLayers({
const isEnum = enumCountRef.current > 0;
const distKey = viewFeatureRef.current ? `dist_${viewFeatureRef.current}` : '';
// Per-render memo: each of getRatios0/1/2 would otherwise call distToRatios
// on the same row, tripling the work. Cache by row reference.
const ratiosCache = new WeakMap<HexagonData, number[]>();
const getRatios = (d: HexagonData): number[] => {
let r = ratiosCache.get(d);
if (!r) {
r = distToRatios(d[distKey]);
ratiosCache.set(d, r);
}
return r;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pieProps: any = isEnum
? {
extensions: [new PieHexExtension(requireEnumPalette(enumPaletteRef.current))],
getCenter: (d: HexagonData) => [d.lon, d.lat],
getRatios0: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
const r = getRatios(d);
return [r[0], r[1], r[2], r[3]];
},
getRatios1: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
const r = getRatios(d);
return [r[4], r[5], r[6], r[7]];
},
getRatios2: (d: HexagonData) => {
const r = distToRatios(d[distKey]);
const r = getRatios(d);
return [r[8], r[9]];
},
updateTriggers: {

View file

@ -1,12 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
import {
apiUrl,
buildFilterString,
logNonAbortError,
authHeaders,
isAbortError,
} from '../lib/api';
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import type { TravelTimeEntry } from './useTravelTime';
import { buildTravelParam } from '../lib/travel-params';

View file

@ -45,31 +45,46 @@ export function useListingLayers({
}: UseListingLayersProps) {
const [popupInfo, setPopupInfo] = useState<ListingPopupInfo | null>(null);
const visibleListings = useMemo(() => {
if (listings.length === 0) return listings;
if (usePostcodeView) {
const allowed = new Set<string>();
for (const feature of postcodeData) {
if (feature.properties.count > 0) {
allowed.add(normalizePostcode(feature.properties.postcode));
}
}
if (allowed.size === 0) return [];
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
}
// Split into two memos so the inactive view's data changes don't invalidate
// the active filtered list. (e.g. in postcode view, hexagonData updates must
// not retrigger filtering / downstream layer rebuilds.)
const postcodeFilteredListings = useMemo(() => {
if (!usePostcodeView || listings.length === 0) return null;
const allowed = new Set<string>();
for (const cell of hexagonData) {
if (cell.count > 0) allowed.add(cell.h3);
for (const feature of postcodeData) {
if (feature.properties.count > 0) {
allowed.add(normalizePostcode(feature.properties.postcode));
}
}
if (allowed.size === 0) return [];
return listings.filter((listing) => allowed.has(normalizePostcode(listing.postcode)));
}, [listings, postcodeData, usePostcodeView]);
const hexFilteredListings = useMemo(() => {
if (usePostcodeView || listings.length === 0) return null;
const allowed = new Set<string>();
let cellResolution: number | null = null;
for (const cell of hexagonData) {
if (cell.count > 0) {
allowed.add(cell.h3);
if (cellResolution == null) cellResolution = getResolution(cell.h3);
}
}
if (allowed.size === 0 || cellResolution == null) return [];
const resolutionForLookup = cellResolution;
return listings.filter((listing) => {
try {
return allowed.has(latLngToCell(listing.lat, listing.lon, resolution));
return allowed.has(latLngToCell(listing.lat, listing.lon, resolutionForLookup));
} catch {
return false;
}
});
}, [listings, hexagonData, postcodeData, resolution, usePostcodeView]);
}, [listings, hexagonData, usePostcodeView]);
const visibleListings = useMemo(() => {
if (listings.length === 0) return listings;
return (usePostcodeView ? postcodeFilteredListings : hexFilteredListings) ?? [];
}, [listings, usePostcodeView, postcodeFilteredListings, hexFilteredListings]);
const handleHover = useCallback((info: PickingInfo<ActualListing>) => {
if (info.object && info.x !== undefined && info.y !== undefined) {

View file

@ -26,8 +26,8 @@ import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/
import { type TravelTimeEntry } from './useTravelTime';
import { buildTravelParam as serializeTravelParam } from '../lib/travel-params';
/** Return the p-th percentile (0100) from a sorted array via linear interpolation. */
function percentile(sorted: number[], p: number): number {
/** Return the p-th percentile (0100) from a sorted typed array via linear interpolation. */
function percentile(sorted: Float64Array, p: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const idx = (p / 100) * (sorted.length - 1);
@ -262,10 +262,20 @@ export function useMapData({
useEffect(() => {
if (!activeFeature || !activeDragRequest) return;
// Abort any in-flight previous drag fetch before starting a new one.
if (dragAbortRef.current) dragAbortRef.current.abort();
dragAbortRef.current = new AbortController();
// Capture the controller locally so this effect's cleanup unambiguously
// aborts THIS request's controller, even if `dragAbortRef.current` has
// been swapped by a subsequent effect run.
const controller = new AbortController();
dragAbortRef.current = controller;
const { signal } = controller;
const { boundsStr, dragTravelParam, fieldsParam, filtersStr, requestKey } = activeDragRequest;
// Capture activeFeature in a local so the async .then() callback cannot
// observe a stale-or-newer value via closure surprise.
const effectActiveFeature = activeFeature;
latestDragRequestKeyRef.current = requestKey;
setDragDataKey('');
dragFeatureRef.current = null;
@ -278,14 +288,15 @@ export function useMapData({
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('postcodes', params), authHeaders({ signal: dragAbortRef.current.signal }))
fetch(apiUrl('postcodes', params), authHeaders({ signal }))
.then((res) => res.json())
.then((json: { features: PostcodeFeature[] }) => {
if (signal.aborted) return;
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragPostcodeData(json.features);
setDragHexData(null);
setDragDataKey(requestKey);
dragFeatureRef.current = activeFeature;
dragFeatureRef.current = effectActiveFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag postcode data', err));
} else {
@ -299,31 +310,36 @@ export function useMapData({
if (viewFeatureIsEnum && dataViewFeature) params.set('enum_dist', dataViewFeature);
if (shareCode) params.set('share', shareCode);
fetch(apiUrl('hexagons', params), authHeaders({ signal: dragAbortRef.current.signal }))
fetch(apiUrl('hexagons', params), authHeaders({ signal }))
.then((res) => res.json())
.then((json: ApiResponse) => {
if (signal.aborted) return;
if (latestDragRequestKeyRef.current !== requestKey) return;
setDragHexData(json.features);
setDragPostcodeData(null);
setDragDataKey(requestKey);
dragFeatureRef.current = activeFeature;
dragFeatureRef.current = effectActiveFeature;
})
.catch((err) => logNonAbortError('Failed to fetch drag hex data', err));
}
return () => {
if (dragAbortRef.current) {
dragAbortRef.current.abort();
// Abort the controller captured by THIS effect run rather than reading
// from the ref (which may already have been replaced by a newer run).
controller.abort();
if (dragAbortRef.current === controller) {
dragAbortRef.current = null;
}
if (latestDragRequestKeyRef.current === requestKey) {
latestDragRequestKeyRef.current = '';
}
// Do not clear latestDragRequestKeyRef here: a newer effect run will
// overwrite it with its own requestKey, and clearing it would create a
// brief window in which a late-resolving fetch from this run could pass
// the staleness check against an empty key.
};
}, [
activeFeature,
activeDragRequest,
dataViewFeature,
resolution,
usePostcodeView,
viewFeatureIsEnum,
shareCode,
@ -538,10 +554,14 @@ export function useMapData({
}
if (vals.length === 0) return null;
vals.sort((a, b) => a - b);
// Typed-array sort uses the engine's optimized numeric sort with no
// per-element comparator call — measurably faster than `vals.sort((a,b)=>a-b)`
// for the 5k10k samples a busy viewport produces.
const sorted = Float64Array.from(vals);
sorted.sort();
return [
percentile(vals, COLOR_RANGE_LOW_PERCENTILE),
percentile(vals, COLOR_RANGE_HIGH_PERCENTILE),
percentile(sorted, COLOR_RANGE_LOW_PERCENTILE),
percentile(sorted, COLOR_RANGE_HIGH_PERCENTILE),
];
}, [
bounds,

View file

@ -0,0 +1,64 @@
import { useEffect, useRef } from 'react';
/**
* Shared modal accessibility behavior: locks body scroll, traps Tab focus
* inside the dialog, restores focus on unmount, and focuses the first
* focusable element (or the dialog itself) on mount.
*/
export function useModalA11y(): React.RefObject<HTMLDivElement | null> {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
const focusableSelector =
'input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
const firstFocusable = dialog?.querySelector<HTMLElement>(focusableSelector);
(firstFocusable ?? dialog)?.focus();
// Lock body scroll while preserving scroll position.
const prevOverflow = document.body.style.overflow;
const prevPaddingRight = document.body.style.paddingRight;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
document.body.style.paddingRight = `${scrollbarWidth}px`;
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab' || !dialog) return;
const focusables = Array.from(dialog.querySelectorAll<HTMLElement>(focusableSelector)).filter(
(el) => el.offsetParent !== null || el === document.activeElement
);
if (focusables.length === 0) {
e.preventDefault();
dialog.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first || !dialog.contains(active)) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = prevOverflow;
document.body.style.paddingRight = prevPaddingRight;
previouslyFocused?.focus?.();
};
}, []);
return dialogRef;
}

View file

@ -12,8 +12,16 @@ export interface SavedSearch {
created: string;
}
const POLL_INTERVAL_MS = 2000;
const MAX_POLL_ATTEMPTS = 15;
// Exponential backoff: 2s, 3s, 4s, 6s, 8s, 12s, ... capped at 15s.
// Caps total wait under a minute while staying responsive for fast jobs.
const POLL_BASE_MS = 2000;
const POLL_MAX_MS = 15000;
const POLL_BACKOFF = 1.5;
const MAX_POLL_ATTEMPTS = 8;
function nextPollDelay(attempt: number): number {
return Math.min(POLL_MAX_MS, Math.round(POLL_BASE_MS * Math.pow(POLL_BACKOFF, attempt)));
}
export function useSavedSearches(userId: string | null) {
const [searches, setSearches] = useState<SavedSearch[]>([]);
@ -21,14 +29,16 @@ export function useSavedSearches(userId: string | null) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAttemptsRef = useRef(0);
const pollInFlightRef = useRef(false);
const isMountedRef = useRef(true);
const userIdRef = useRef(userId);
userIdRef.current = userId;
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
pollAttemptsRef.current = 0;
@ -37,6 +47,15 @@ export function useSavedSearches(userId: string | null) {
// Clean up polling on unmount or userId change
useEffect(() => stopPolling, [userId, stopPolling]);
// Mark the hook as unmounted so late-arriving async work doesn't touch state
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
stopPolling();
};
}, [stopPolling]);
const fetchRecords = useCallback(async (uid: string): Promise<SavedSearch[]> => {
const records = await pb.collection('saved_searches').getFullList({
sort: '-created',
@ -57,28 +76,41 @@ export function useSavedSearches(userId: string | null) {
const startPolling = useCallback(() => {
if (pollTimerRef.current) return;
pollAttemptsRef.current = 0;
pollTimerRef.current = setInterval(async () => {
pollInFlightRef.current = false;
const scheduleNext = () => {
if (!isMountedRef.current) return;
const delay = nextPollDelay(pollAttemptsRef.current);
pollTimerRef.current = setTimeout(tick, delay);
};
const tick = async () => {
pollTimerRef.current = null;
if (pollInFlightRef.current) {
scheduleNext();
return;
}
const uid = userIdRef.current;
if (!uid) {
stopPolling();
return;
}
if (!uid) return;
pollAttemptsRef.current++;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
stopPolling();
return;
}
if (pollAttemptsRef.current > MAX_POLL_ATTEMPTS) return;
pollInFlightRef.current = true;
try {
const mapped = await fetchRecords(uid);
if (!isMountedRef.current) return;
setSearches(mapped);
if (!mapped.some((s) => !s.screenshotUrl)) {
stopPolling();
}
if (!mapped.some((s) => !s.screenshotUrl)) return;
scheduleNext();
} catch {
// Silent — background poll errors don't surface to UI
// Silent — background poll errors don't surface to UI; keep trying.
if (isMountedRef.current) scheduleNext();
} finally {
pollInFlightRef.current = false;
}
}, POLL_INTERVAL_MS);
}, [stopPolling, fetchRecords]);
};
scheduleNext();
}, [fetchRecords]);
const fetchSearches = useCallback(async () => {
if (!userId) return;

View file

@ -25,21 +25,28 @@ const de: Translations = {
total: 'Gesamt',
min: 'Min.',
max: 'Max.',
minute: 'Min.',
or: 'oder',
area: 'Gebiet',
properties: 'Immobilien',
postcode: 'Postleitzahl',
noAreaSelected: 'Kein Gebiet ausgewählt',
noAreaSelectedDesc:
'Klicke auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
'Klicken Sie auf ein farbiges Gebiet auf der Karte, um Kriminalität, Schulen, Preise und mehr zu sehen',
clickForDetails: 'Für Details klicken',
property: 'Immobilie',
propertiesPlural: 'Immobilien',
bedsCount: '{{count}} Schlafzimmer',
bedsCount_other: '{{count}} Schlafzimmer',
bathsCount: '{{count}} Bad',
bathsCount_other: '{{count}} Bäder',
places: 'Orte',
noData: 'Keine Daten',
allLow: 'Alles niedrig',
connectingToServer: 'Verbindung zum Server...',
closePane: 'Bereich schließen',
yes: 'Ja',
no: 'Nein',
},
// ── Header / Nav ───────────────────────────────────
@ -310,8 +317,7 @@ const de: Translations = {
'Family trade-offs to compare': 'Familienkompromisse zum Vergleich',
'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.':
'Kombinieren Sie Schulen mit Parks, Straßenlärm, Kriminalität, Wohnfläche, Pendelweg, Breitband und Preis, damit die Auswahlliste den gesamten Umzug widerspiegelt.',
'Does this show school catchment guarantees?':
'Zeigt dies garantierte Schul-Einzugsgebiete?',
'Does this show school catchment guarantees?': 'Zeigt dies garantierte Schul-Einzugsgebiete?',
'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.':
'Nein. Es hilft dabei, vielversprechende Gebiete zu identifizieren, Einzugsgebiete und Zulassungen müssen jedoch bei der Schule oder der örtlichen Behörde überprüft werden.',
'Can I combine school filters with parks and safety?':
@ -480,8 +486,7 @@ const de: Translations = {
'Wie Konto- und gespeicherte Suchdaten im Produkt verarbeitet werden.',
'Compare Bristol postcodes': 'Vergleichen Sie die Postleitzahlen von Bristol',
'Trust and coverage': 'Vertrauen und Abdeckung',
'Perfect Postcode data sources and coverage':
'Perfect Postcode Datenquellen und Abdeckung',
'Perfect Postcode data sources and coverage': 'Perfect Postcode Datenquellen und Abdeckung',
'Perfect Postcode data sources - Property, schools, commute and local context':
'Perfect Postcode Datenquellen: Immobilien, Schulen, Pendelweg und lokaler Kontext',
'Review the public and official datasets used by Perfect Postcode, including property prices, EPC, schools, crime, broadband, noise and travel-time context.':
@ -501,8 +506,7 @@ const de: Translations = {
'Travel-time data': 'Reisezeitdaten',
'Travel-time filters are designed for consistent area comparison. Route availability, disruption, parking, walking access, and timetable details should be verified before committing to an area.':
'Reisezeitfilter sind für einen konsistenten Gebietsvergleich konzipiert. Bevor Sie sich für ein Gebiet entscheiden, sollten Sie Routenverfügbarkeit, Störungen, Parkmöglichkeiten, Fußläufigkeit und Fahrplandetails überprüfen.',
'Why does coverage focus on England?':
'Warum konzentriert sich die Abdeckung auf England?',
'Why does coverage focus on England?': 'Warum konzentriert sich die Abdeckung auf England?',
'Several core property, education, and local-context datasets are jurisdiction-specific. England coverage keeps comparisons more consistent.':
'Mehrere zentrale Datensätze zu Immobilien, Bildung und lokalem Kontext sind jurisdiktionsspezifisch. Eine Abdeckung von England sorgt für konsistentere Vergleiche.',
'How should I handle stale or missing data?':
@ -588,15 +592,15 @@ const de: Translations = {
createAccount: 'Konto erstellen',
resetPassword: 'Passwort zurücksetzen',
valueProp:
'Speichere Suchen, merke dir Immobilien und erstelle eine Auswahlliste passender Gebiete.',
'Speichern Sie Suchen, merken Sie sich Immobilien und erstellen Sie eine Auswahlliste passender Gebiete.',
continueWithGoogle: 'Weiter mit Google',
email: 'E-Mail',
emailPlaceholder: 'du@beispiel.de',
emailPlaceholder: 'name@beispiel.de',
password: 'Passwort',
passwordPlaceholderRegister: 'Mind. 8 Zeichen',
passwordPlaceholderLogin: 'Dein Passwort',
passwordPlaceholderLogin: 'Ihr Passwort',
forgotPassword: 'Passwort vergessen?',
resetSent: 'Prüfe deine E-Mails für einen Link zum Zurücksetzen.',
resetSent: 'Prüfen Sie Ihre E-Mails für einen Link zum Zurücksetzen.',
pleaseWait: 'Bitte warten...',
sendResetLink: 'Link zum Zurücksetzen senden',
backToLogin: 'Zurück zur Anmeldung',
@ -606,7 +610,7 @@ const de: Translations = {
upgrade: {
title: 'Jede passende Postleitzahl finden',
description:
'Du erkundest gerade das Demogebiet. Erhalte lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
'Sie erkunden gerade das Demogebiet. Erhalten Sie lebenslangen Zugang zu jeder Postleitzahl, jedem Filter und jedem Viertel in England. Eine Zahlung, für immer.',
free: 'Kostenlos',
freeForEarly: 'Kostenlos für Frühnutzer. Keine Kreditkarte erforderlich.',
oneTimePayment: 'Einmalzahlung. Lebenslanger Zugang.',
@ -618,7 +622,7 @@ const de: Translations = {
continueWithDemo: 'Mit Demo fortfahren',
backToSharedArea: 'Zurück zum geteilten Gebiet',
sharedAreaDescription:
'Du siehst ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichere dir lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
'Sie sehen ein geteiltes Gebiet. Um darüber hinaus zu erkunden, sichern Sie sich lebenslangen Zugriff auf jede Postleitzahl, jeden Filter und jede Nachbarschaft in England.',
checkoutFailed: 'Bezahlvorgang fehlgeschlagen',
},
@ -626,7 +630,7 @@ const de: Translations = {
saveSearch: {
title: 'Suche speichern',
saved: 'Suche gespeichert',
savedSuccess: 'Deine Suche wurde erfolgreich gespeichert.',
savedSuccess: 'Ihre Suche wurde erfolgreich gespeichert.',
viewSavedSearches: 'Gespeicherte Suchen ansehen',
name: 'Name',
namePlaceholder: 'Meine Suche',
@ -636,15 +640,15 @@ const de: Translations = {
// ── License Success ────────────────────────────────
licenseSuccess: {
verifyingTitle: 'Zugang wird geprüft',
verifyingSubtitle: 'Wir prüfen dein Konto, bevor wir die Karte freischalten.',
verifyingSubtitle: 'Wir prüfen Ihr Konto, bevor wir die Karte freischalten.',
verifyingDescription: 'Das dauert nach dem Bezahlen normalerweise nur ein paar Sekunden.',
activationDelayedTitle: 'Zahlung erhalten',
activationDelayedSubtitle: 'Der Zugang wird noch aktiviert.',
activationDelayedDescription:
'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisiere gleich noch einmal oder kontaktiere den Support, falls der Zugang nicht erscheint.',
'Wir konnten die Kontoaktualisierung noch nicht bestätigen. Aktualisieren Sie gleich noch einmal oder kontaktieren Sie den Support, falls der Zugang nicht erscheint.',
stayOnPricing: 'Auf der Preisseite bleiben',
title: 'Du bist dabei.',
subtitle: 'Dein lebenslanger Zugang ist jetzt aktiv.',
title: 'Sie sind dabei.',
subtitle: 'Ihr lebenslanger Zugang ist jetzt aktiv.',
description: 'Voller Zugang zu allen Funktionen, allen Postleitzahlen, in ganz England.',
startExploring: 'Jetzt entdecken',
},
@ -655,18 +659,18 @@ const de: Translations = {
addFilter: 'Filter hinzufügen',
findingPerfectPostcode: 'Die perfekte Postleitzahl finden',
addFiltersHint:
'Füge unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die deinen Kriterien entsprechen',
'Fügen Sie unten Filter hinzu, um die Karte auf Gebiete einzugrenzen, die Ihren Kriterien entsprechen',
upgradePrompt:
'Finde passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
'Finden Sie passende Postleitzahlen mit Kriminalität, Schulen, Lärm, Breitband, Preisen und über 50 weiteren Filtern in ganz England.',
oneTimeLifetime: 'Einmalzahlung, lebenslanger Zugang.',
upgradeToFullMap: 'Zur Vollversion upgraden',
chooseFilters:
'Klicke auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
'Klicken Sie auf Hinzufügen, um zu filtern. Die kleinen Schaltflächen zeigen Daten oder färben die Karte.',
searchFeatures: 'Filter durchsuchen...',
noMatchingFeatures: 'Keine passenden Filter',
tryDifferentSearch: 'Versuche einen anderen Suchbegriff',
tryDifferentSearch: 'Versuchen Sie einen anderen Suchbegriff',
allFeaturesActive: 'Alle Filter sind aktiv',
removeFilterHint: 'Entferne einen Filter, um verfügbare Merkmale zu sehen',
removeFilterHint: 'Entfernen Sie einen Filter, um verfügbare Merkmale zu sehen',
featureInfo: 'Über diese Daten',
aboutData: 'Über diese Daten',
aboutDataShort: 'Info',
@ -679,7 +683,7 @@ const de: Translations = {
replayTutorial: 'Interaktives Tutorial erneut abspielen',
clearAll: 'Alle löschen',
clearAllTitle: 'Alle Filter löschen?',
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
clearAllSavePrompt: 'Möchten Sie Ihre aktuellen Filter vor dem Löschen speichern?',
clearAllUpdatePrompt:
'<strong>{{name}}</strong> mit den aktuellen Filtern aktualisieren, bevor gelöscht wird?',
saveAndClear: 'Speichern & löschen',
@ -700,12 +704,14 @@ const de: Translations = {
ethnicity: 'Ethnie',
poiType: 'POI-Typ',
party: 'Partei',
travelTimeKeywords:
'Reisezeit Fahrzeit Pendelzeit Pendeln Fahrt Reise Auto Fahrrad Rad Radfahren zu Fuß Gehen ÖPNV Verkehr Transport Bahnhof Bahn U-Bahn S-Bahn Zug Bus Straßenbahn öffentlich Route travel time journey commute car bicycle bike walking transit transport station train tube bus metro rail route',
},
// ── Philosophy Popup ───────────────────────────────
philosophy: {
intro:
'Beginne mit deinen Muss-Kriterien, dann füge Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn du Filter hinzufügst. Die verbleibenden Gebiete sind deine besten Treffer.',
'Beginnen Sie mit Ihren Muss-Kriterien, dann fügen Sie Kann-Kriterien hinzu. Die Karte grenzt sich ein, wenn Sie Filter hinzufügen. Die verbleibenden Gebiete sind Ihre besten Treffer.',
step1Title: 'Budget und Grundlagen',
step1Desc: '(Preisrahmen, Wohnfläche, Immobilientyp)',
step2Title: 'Pendelweg',
@ -718,7 +724,7 @@ const de: Translations = {
step5Desc: '(Restaurants, Parks, Breitbandgeschwindigkeit)',
step6Title: 'Energie',
step6Desc: '(EPC-Bewertungen, Dämmung, Heizkosten)',
tip: 'Tipp: Wenn nichts passt, lockere eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
tip: 'Tipp: Wenn nichts passt, lockern Sie eine Bedingung nach der anderen, um zu sehen, welcher Kompromiss die meisten Optionen eröffnet.',
},
// ── Travel Time ────────────────────────────────────
@ -755,14 +761,14 @@ const de: Translations = {
bicycleDesc: ' mit dem Fahrrad, auf fahrradfreundlichen Strecken.',
walkingDesc: ' zu Fuß, über Fußwege und Bürgersteige.',
mainDesc: 'Zeigt die Reisezeit vom ausgewählten Ziel zu jedem Gebiet.',
sliderHint: 'Verwende den Schieberegler, um deine maximale Pendelzeit festzulegen.',
sliderHint: 'Verwenden Sie den Schieberegler, um Ihre maximale Pendelzeit festzulegen.',
},
// ── AI Filter ──────────────────────────────────────
aiFilter: {
describeIdealArea: 'Beschreibe, wo du leben möchtest',
describeIdealArea: 'Beschreiben Sie, wo Sie leben möchten',
aiSearch: 'KI-Suche',
describeHint: 'beschreibe, wonach du suchst',
describeHint: 'beschreiben Sie, wonach Sie suchen',
placeholder: 'z. B. 2 Schlafzimmer unter £525k, 45 Min. zur Arbeit, ruhig...',
example1: '2 Schlafzimmer unter £525k, 45 Min. zur Arbeit',
example2: 'Familienfreundliche Gebiete nahe guten Schulen unter £650k',
@ -772,7 +778,7 @@ const de: Translations = {
generatingFilters: 'Filter werden generiert...',
refiningResults: 'Ergebnisse werden verfeinert...',
weeklyLimitReached:
'Du hast das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
'Sie haben das wöchentliche KI-Nutzungslimit erreicht. Es wird nächste Woche automatisch zurückgesetzt.',
},
// ── Map Legend ─────────────────────────────────────
@ -1268,7 +1274,7 @@ const de: Translations = {
upgrade: 'Upgraden',
redirecting: 'Weiterleitung…',
receiveNewsletter: 'Newsletter-E-Mails erhalten',
needHelp: 'Brauchst du Hilfe? Schreib uns an',
needHelp: 'Brauchen Sie Hilfe? Schreiben Sie uns an',
responseTime: 'Wir antworten in der Regel innerhalb von 24 Stunden.',
shareLinksTitle: 'Geteilte Links',
noShareLinksYet: 'Noch keine geteilten Links',
@ -1281,12 +1287,12 @@ const de: Translations = {
searches: 'Suchen',
noSavedSearches: 'Noch keine gespeicherten Suchen',
noSavedSearchesDesc:
'Speichere deine Filter und Kartenansicht, um genau dort weiterzumachen, wo du aufgehört hast.',
'Speichern Sie Ihre Filter und Kartenansicht, um genau dort weiterzumachen, wo Sie aufgehört haben.',
clickToRename: 'Klicken zum Umbenennen',
notesPlaceholder: 'Notiere deine Gedanken...',
notesPlaceholder: 'Notieren Sie Ihre Gedanken...',
deleteSearch: 'Suche löschen',
deleteSearchConfirm:
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
'Möchten Sie diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
updating: 'Aktualisiere...',
},
@ -1301,7 +1307,7 @@ const de: Translations = {
copyInviteLink: 'Einladungslink kopieren',
adminInvitesTitle: 'Admin-Einladungen (100% Rabatt)',
referralInvitesTitle: 'Empfehlungseinladungen (30% Rabatt)',
yourInviteLinks: 'Deine Einladungslinks',
yourInviteLinks: 'Ihre Einladungslinks',
noInvitesYet: 'Noch keine Einladungen erstellt',
link: 'Link',
status: 'Status',
@ -1312,13 +1318,13 @@ const de: Translations = {
// ── Invite Page ────────────────────────────────────
invitePage: {
youreInvited: 'Du bist eingeladen!',
youreInvited: 'Sie sind eingeladen!',
specialOffer: 'Sonderangebot!',
invitedByFree: '{{name}} hat dich eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
genericFreeInvite: 'Du wurdest eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit dir geteilt.',
exploreEvery: 'Finde Postleitzahlen, die zu deinem Leben passen',
invitedByFree: '{{name}} hat Sie eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
invitedByDiscount: '{{name}} hat 30% Rabatt auf lebenslangen Zugang mit Ihnen geteilt.',
genericFreeInvite: 'Sie wurden eingeladen, kostenlosen lebenslangen Zugang zu erhalten.',
genericDiscount: 'Ein Freund hat 30% Rabatt auf lebenslangen Zugang mit Ihnen geteilt.',
exploreEvery: 'Finden Sie Postleitzahlen, die zu Ihrem Leben passen',
propertyInfo: 'Preise, Pendelzeit, Schulen, Kriminalität, Lärm, Breitband, EPC und mehr',
invalidInvite: 'Ungültige Einladung',
inviteAlreadyUsed: 'Einladung bereits verwendet',
@ -1326,13 +1332,13 @@ const de: Translations = {
invalidInviteLink: 'Ungültiger Einladungslink',
invalidInviteLinkDesc: 'Dieser Einladungslink ist ungültig oder abgelaufen.',
licenseActivated: 'Lizenz aktiviert!',
fullAccessGranted: 'Du hast jetzt vollen Zugang zu Perfect Postcode.',
fullAccessGranted: 'Sie haben jetzt vollen Zugang zu Perfect Postcode.',
activating: 'Wird aktiviert...',
activateLicense: 'Lizenz aktivieren',
claimDiscount: 'Rabatt einlösen',
registerToClaim: 'Registrieren zum Einlösen',
youAlreadyHaveLicense: 'Du hast bereits eine Lizenz',
accountHasFullAccess: 'Dein Konto hat bereits vollen Zugang.',
youAlreadyHaveLicense: 'Sie haben bereits eine Lizenz',
accountHasFullAccess: 'Ihr Konto hat bereits vollen Zugang.',
failedToValidate: 'Einladungslink konnte nicht validiert werden',
},

View file

@ -23,6 +23,7 @@ const en = {
total: 'Total',
min: 'min',
max: 'max',
minute: 'min',
or: 'or',
area: 'Area',
properties: 'Properties',
@ -33,11 +34,17 @@ const en = {
clickForDetails: 'Click for details',
property: 'property',
propertiesPlural: 'properties',
bedsCount: '{{count}} bed',
bedsCount_other: '{{count}} beds',
bathsCount: '{{count}} bath',
bathsCount_other: '{{count}} baths',
places: 'places',
noData: 'No data',
allLow: 'All low',
connectingToServer: 'Connecting to server...',
closePane: 'Close pane',
yes: 'Yes',
no: 'No',
},
// ── Header / Nav ───────────────────────────────────
@ -653,7 +660,8 @@ const en = {
clearAll: 'Clear all',
clearAllTitle: 'Clear all filters?',
clearAllSavePrompt: 'Would you like to save your current filters before clearing?',
clearAllUpdatePrompt: 'Update <strong>{{name}}</strong> with your current filters before clearing?',
clearAllUpdatePrompt:
'Update <strong>{{name}}</strong> with your current filters before clearing?',
saveAndClear: 'Save & Clear',
updateAndClear: 'Update & Clear',
clearWithoutSaving: 'Clear without saving',
@ -672,6 +680,8 @@ const en = {
ethnicity: 'Ethnicity',
poiType: 'POI type',
party: 'Party',
travelTimeKeywords:
'travel time journey commute car bicycle bike cycling walking walk transit transport public station tube train bus metro subway underground rail route',
},
// ── Philosophy Popup ───────────────────────────────

View file

@ -25,6 +25,7 @@ const fr: Translations = {
total: 'Total',
min: 'min',
max: 'max',
minute: 'min',
or: 'ou',
area: 'Zone',
properties: 'Propriétés',
@ -35,11 +36,17 @@ const fr: Translations = {
clickForDetails: 'Cliquez pour les détails',
property: 'propriété',
propertiesPlural: 'propriétés',
bedsCount: '{{count}} ch.',
bedsCount_other: '{{count}} ch.',
bathsCount: '{{count}} sdb',
bathsCount_other: '{{count}} sdb',
places: 'lieux',
noData: 'Aucune donnée',
allLow: 'Tout est faible',
connectingToServer: 'Connexion au serveur...',
closePane: 'Fermer le panneau',
yes: 'Oui',
no: 'Non',
},
// ── Header / Nav ───────────────────────────────────
@ -318,7 +325,7 @@ const fr: Translations = {
"Oui. La recherche adaptée à l'école peut être combinée avec la criminalité, les parcs, les déplacements domicile-travail, le prix, la taille de la propriété et les services locaux.",
'Is Ofsted the only school signal?': 'Ofsted est-il le seul signal scolaire ?',
'No single score should decide a move. Use the map as a starting point, then review current school information in detail.':
"Aucun score isolé ne devrait décider dun déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur lécole.",
'Aucun score isolé ne devrait décider dun déménagement. Utilisez la carte comme point de départ, puis examinez en détail les informations actuelles sur lécole.',
'See where education, property, transport, and environment data comes from.':
"Découvrez d'où proviennent les données sur l'éducation, l'immobilier, les transports et l'environnement.",
'Explore school-aware searches': "Explorez les recherches adaptées à l'école",
@ -337,7 +344,7 @@ const fr: Translations = {
'Compare postcodes consistently across England.':
'Comparez les codes postaux de manière cohérente dans toute lAngleterre.',
'Check the street before spending a viewing slot':
"Vérifiez la rue avant dy consacrer un créneau de visite",
'Vérifiez la rue avant dy consacrer un créneau de visite',
'Use the postcode checker to review price history, local context, amenities, schools, and environment signals before you commit time to visiting.':
"Utilisez le vérificateur de code postal pour examiner l'historique des prix, le contexte local, les commodités, les écoles et les signaux environnementaux avant de consacrer du temps à votre visite.",
'Compare neighbouring postcodes': 'Comparez les codes postaux voisins',
@ -702,6 +709,8 @@ const fr: Translations = {
ethnicity: 'Origine ethnique',
poiType: 'Type de POI',
party: 'Parti',
travelTimeKeywords:
'temps trajet déplacement navette domicile-travail voiture vélo bicyclette cyclisme marche à pied piéton transports en commun public station gare train métro tramway bus RER itinéraire route travel time journey commute car bicycle bike walking transit transport station tube train',
},
// ── Philosophy Popup ───────────────────────────────
@ -843,8 +852,7 @@ const fr: Translations = {
lowerMinTo: 'Abaisser le minimum à {{value}}',
raiseMaxTo: 'Augmenter le maximum à {{value}}',
allowCategory: 'Autoriser {{value}}',
missingFilterValue:
'Aucune valeur pour ce filtre ; supprimez-le',
missingFilterValue: 'Aucune valeur pour ce filtre ; supprimez-le',
noFilterDataShort: 'Aucune donnée',
travelTo: 'Trajet vers {{destination}}',
viewProperties: 'Voir {{count}} propriétés',
@ -1297,7 +1305,8 @@ const fr: Translations = {
// ── Invites Page ───────────────────────────────────
invitesPage: {
inviteLinksLicensed: 'Les liens dinvitation sont disponibles pour les utilisateurs sous licence.',
inviteLinksLicensed:
'Les liens dinvitation sont disponibles pour les utilisateurs sous licence.',
inviteAdminLabel: 'Inviter des amis (100% de réduction)',
inviteReferralLabel: 'Inviter des amis (30% de réduction)',
generateFreeInvite: 'Générer un lien dinvitation gratuit',

View file

@ -22,8 +22,9 @@ const hi: Translations = {
none: 'कोई नहीं',
viewDataSource: 'डेटा स्रोत देखें',
total: 'कुल',
min: 'िनट',
min: 'न्यूनतम',
max: 'अधिकतम',
minute: 'मिनट',
or: 'या',
area: 'क्षेत्र',
properties: 'संपत्तियां',
@ -34,11 +35,17 @@ const hi: Translations = {
clickForDetails: 'विवरण के लिए क्लिक करें',
property: 'संपत्ति',
propertiesPlural: 'संपत्तियां',
bedsCount: '{{count}} बेड',
bedsCount_other: '{{count}} बेड',
bathsCount: '{{count}} बाथ',
bathsCount_other: '{{count}} बाथ',
places: 'स्थान',
noData: 'कोई डेटा नहीं',
allLow: 'सभी कम',
connectingToServer: 'सर्वर से कनेक्ट हो रहा है...',
closePane: 'पैन बंद करें',
yes: 'हाँ',
no: 'नहीं',
},
header: {
@ -670,6 +677,8 @@ const hi: Translations = {
ethnicity: 'जातीय समूह',
poiType: 'POI प्रकार',
party: 'पार्टी',
travelTimeKeywords:
'यात्रा यात्रा समय सफर आवागमन कार गाड़ी साइकिल बाइक पैदल चलना सार्वजनिक परिवहन परिवहन यातायात स्टेशन ट्रेन रेल मेट्रो ट्यूब बस मार्ग travel time journey commute car bicycle bike walking transit transport station tube train',
},
philosophy: {

View file

@ -23,8 +23,9 @@ const hu: Translations = {
none: 'Egyik sem',
viewDataSource: 'Adatforrás megtekintése',
total: 'Összesen',
min: 'perc',
min: 'min.',
max: 'max.',
minute: 'perc',
or: 'vagy',
area: 'Terület',
properties: 'Ingatlanok',
@ -35,11 +36,17 @@ const hu: Translations = {
clickForDetails: 'Kattints a részletekhez',
property: 'ingatlan',
propertiesPlural: 'ingatlanok',
bedsCount: '{{count}} hsz.',
bedsCount_other: '{{count}} hsz.',
bathsCount: '{{count}} fsz.',
bathsCount_other: '{{count}} fsz.',
places: 'helyek',
noData: 'Nincs adat',
allLow: 'Mind alacsony',
connectingToServer: 'Kapcsolódás a szerverhez...',
closePane: 'Panel bezárása',
yes: 'Igen',
no: 'Nem',
},
// ── Header / Nav ───────────────────────────────────
@ -446,7 +453,8 @@ const hu: Translations = {
'Make commute constraints explicit': 'Tegye egyértelművé az ingázási korlátozásokat',
'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.':
'Ha fontos a központ, állomás, kórház, egyetem vagy üzleti park elérése, először használja az utazási idő szűrőit, majd hasonlítsa össze a fennmaradó irányítószámokat ingatlanadatok alapján.',
'Compare value, not just headline price': 'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
'Compare value, not just headline price':
'Hasonlítsa össze az értéket, ne csak a kiinduló árat',
'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.':
'Használja együtt az ár-, ingatlantípus- és alapterület-szűrőket. Ez segít megkülönböztetni az alacsonyabb költségű területeket azoktól a területektől, amelyek egyszerűen kisebb vagy eltérő otthonokat tartalmaznak.',
'Screen environmental and local-service signals':
@ -686,6 +694,8 @@ const hu: Translations = {
ethnicity: 'Etnikai csoport',
poiType: 'POI-típus',
party: 'Párt',
travelTimeKeywords:
'utazási idő utazás ingázás menetidő autó kocsi kerékpár bicikli biciklizés gyaloglás gyalog séta tömegközlekedés közlekedés közösségi közlekedés állomás vonat metró villamos busz HÉV útvonal travel time journey commute car bicycle bike walking transit transport station tube train',
},
// ── Philosophy Popup ───────────────────────────────

File diff suppressed because it is too large Load diff

View file

@ -18,7 +18,9 @@ function AppErrorFallback() {
<div className="flex min-h-screen items-center justify-center bg-warm-50 px-6 text-center text-warm-900 dark:bg-navy-950 dark:text-warm-100">
<div>
<h1 className="text-xl font-semibold">Something went wrong</h1>
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">Refresh the page to try again.</p>
<p className="mt-2 text-sm text-warm-600 dark:text-warm-300">
Refresh the page to try again.
</p>
</div>
</div>
);

View file

@ -74,13 +74,13 @@ export function initBugsink(): boolean {
),
release:
nonempty(runtimeConfig.release) ??
readBuildTimeString(typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined),
readBuildTimeString(
typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined
),
sendDefaultPii:
runtimeConfig.sendDefaultPii ??
readBuildTimeBoolean(
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean'
? __BUGSINK_SEND_DEFAULT_PII__
: undefined
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean' ? __BUGSINK_SEND_DEFAULT_PII__ : undefined
),
tracesSampleRate: 0,
});

View file

@ -135,7 +135,6 @@ export interface ActualListing {
export interface ActualListingsResponse {
listings: ActualListing[];
total: number;
truncated: boolean;
}
export interface POICategoryGroup {
@ -198,7 +197,9 @@ export interface Property {
[key: string]: string | number | boolean | RenovationEvent[] | string[] | undefined;
}
export interface HexagonPropertiesResponse {
/** Shared paginated list of `Property` records returned by both
* `/api/hexagon-properties` and `/api/postcode-properties`. */
export interface PropertyListResponse {
properties: Property[];
total: number;
limit: number;
@ -206,6 +207,10 @@ export interface HexagonPropertiesResponse {
truncated: boolean;
}
/** @deprecated Use `PropertyListResponse`. Kept as an alias during the
* rollout so consumers can migrate without breaking. */
export type HexagonPropertiesResponse = PropertyListResponse;
export interface NumericFeatureStats {
name: string;
count: number;

View file

@ -4,6 +4,9 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const zlib = require('zlib');
const sharp = require('sharp');
const webpack = require('webpack');
const packageJson = require('./package.json');
@ -150,11 +153,49 @@ module.exports = (env, argv) => {
filename: '[name].[contenthash:8].css',
chunkFilename: '[name].[contenthash:8].css',
}),
new CompressionPlugin({
filename: '[path][base].gz',
algorithm: 'gzip',
test: /\.(js|css|html|svg|json|wasm)$/,
threshold: 1024,
minRatio: 0.8,
}),
new CompressionPlugin({
filename: '[path][base].br',
algorithm: 'brotliCompress',
test: /\.(js|css|html|svg|json|wasm)$/,
compressionOptions: {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
},
},
threshold: 1024,
minRatio: 0.8,
}),
]
: [new ReactRefreshWebpackPlugin()]),
],
optimization: isProduction
? {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
extractComments: false,
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
passes: 2,
},
format: {
comments: false,
},
keep_classnames: true,
keep_fnames: false,
},
}),
],
splitChunks: {
chunks: 'all',
cacheGroups: {

View file

@ -7,15 +7,19 @@ Reuses the same england-latest.osm.pbf as pois.py.
"""
import argparse
import logging
from pathlib import Path
import osmium
import polars as pl
from pyproj import Transformer
from shapely import wkb
from shapely.errors import GEOSException
from shapely.geometry import MultiPolygon, Polygon
from tqdm import tqdm
logger = logging.getLogger(__name__)
MIN_AREA_SQM = 5_000 # ~70m x 70m — skip pocket parks and small ponds
@ -68,6 +72,7 @@ class GreenspaceHandler(osmium.SimpleHandler):
self._wkb_factory = osmium.geom.WKBFactory()
self._progress = progress
self.geometries = []
self.skipped_areas = 0
def area(self, a):
self._progress.update(1)
@ -76,7 +81,14 @@ class GreenspaceHandler(osmium.SimpleHandler):
try:
wkb_data = self._wkb_factory.create_multipolygon(a)
geom = wkb.loads(wkb_data, hex=True)
except Exception:
except (RuntimeError, GEOSException, ValueError) as exc:
self.skipped_areas += 1
logger.warning(
"Failed to assemble multipolygon for area orig_id=%s (%s)",
getattr(a, "orig_id", lambda: "?")(),
type(exc).__name__,
exc_info=True,
)
return
if geom.is_empty or not geom.is_valid:
@ -113,6 +125,11 @@ def main():
print(
f"Found {len(handler.geometries)} greenspace/water polygons >= {MIN_AREA_SQM} sqm"
)
if handler.skipped_areas:
logger.warning(
"Skipped %d areas due to geometry assembly errors",
handler.skipped_areas,
)
# Merge overlapping geometries per 10km grid cell for efficiency
if handler.geometries:

View file

@ -14,6 +14,7 @@ License: Open Government Licence v3.0
"""
import argparse
import logging
import tempfile
from pathlib import Path
@ -21,10 +22,13 @@ import numpy as np
import polars as pl
import shapefile as shp
from pyproj import Transformer
from shapely.errors import GEOSException
from shapely.geometry import shape as to_shapely
from pipeline.utils.download import download, extract_zip
logger = logging.getLogger(__name__)
URL = "https://api.os.uk/downloads/v1/products/OpenGreenspace/downloads?area=GB&format=ESRI%C2%AE+Shapefile&redirect"
_to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True)
@ -76,6 +80,7 @@ def _read_access_points(
lngs: list[float] = []
categories: list[str] = []
skipped = 0
error_skipped = 0
for sr in reader.shapeRecords():
site_id = sr.record[ref_idx]
@ -89,7 +94,13 @@ def _read_access_points(
if geom.is_empty:
continue
lng, lat = _to_wgs84.transform(geom.x, geom.y)
except Exception:
except (GEOSException, ValueError, AttributeError, TypeError):
error_skipped += 1
logger.warning(
"Failed to process access point geometry for site_id=%s",
site_id,
exc_info=True,
)
continue
lats.append(lat)
@ -98,6 +109,11 @@ def _read_access_points(
if skipped:
print(f" Skipped {skipped:,} access points with unknown site ID")
if error_skipped:
logger.warning(
"Skipped %d access point records due to geometry/transform errors",
error_skipped,
)
return lats, lngs, categories
@ -116,6 +132,7 @@ def _read_site_centroids(
lats: list[float] = []
lngs: list[float] = []
categories: list[str] = []
error_skipped = 0
for sr in reader.shapeRecords():
site_id = sr.record[id_idx]
@ -129,13 +146,25 @@ def _read_site_centroids(
continue
centroid = geom.centroid
lng, lat = _to_wgs84.transform(centroid.x, centroid.y)
except Exception:
except (GEOSException, ValueError, AttributeError, TypeError):
error_skipped += 1
logger.warning(
"Failed to compute centroid for site_id=%s",
site_id,
exc_info=True,
)
continue
lats.append(lat)
lngs.append(lng)
categories.append(func)
if error_skipped:
logger.warning(
"Skipped %d site centroid records due to geometry/transform errors",
error_skipped,
)
return lats, lngs, categories

View file

@ -1,10 +1,12 @@
import argparse
import logging
from pathlib import Path
from tempfile import mkdtemp
import osmium
import polars as pl
from shapely import make_valid
from shapely.errors import GEOSException
from shapely.geometry import Point
from shapely.wkb import loads as load_wkb
from tqdm import tqdm
@ -17,6 +19,8 @@ from pipeline.utils.england_geometry import (
load_england_polygon,
)
logger = logging.getLogger(__name__)
BATCH_SIZE = 50_000
MIN_OCCURENCE_COUNT = 20
@ -57,6 +61,7 @@ class POIHandler(osmium.SimpleHandler):
self._tmp_dir = tmp_dir
self._batch_num = 0
self.poi_count = 0
self.skipped_areas = 0
self._progress = progress
self._england = england_polygon
self._wkb_factory = osmium.geom.WKBFactory()
@ -120,7 +125,14 @@ class POIHandler(osmium.SimpleHandler):
def _point_from_area(self, area: osmium.osm.Area) -> tuple[float, float] | None:
try:
geom = load_wkb(self._wkb_factory.create_multipolygon(area), hex=True)
except Exception:
except (RuntimeError, GEOSException, ValueError) as exc:
self.skipped_areas += 1
logger.warning(
"Failed to build multipolygon WKB for area orig_id=%s (%s)",
getattr(area, "orig_id", lambda: "?")(),
type(exc).__name__,
exc_info=True,
)
return None
return _representative_lat_lon(geom, self._england)
@ -185,6 +197,11 @@ def main() -> None:
handler._flush_batch() # write any remaining POIs
print(f"Extracted {handler.poi_count:,} POIs")
if handler.skipped_areas:
logger.warning(
"Skipped %d areas due to geometry assembly errors",
handler.skipped_areas,
)
batch_files = sorted(tmp_dir.glob("batch_*.parquet"))
df = pl.concat([pl.scan_parquet(f) for f in batch_files])

View file

@ -11,7 +11,7 @@ dependencies = [
"numpy>=1.26.0",
"pandas>=2.0.0",
"plotly>=6.5.2",
"polars>=1.37.1",
"polars>=1.37.1,<2.0.0",
"pyarrow>=15.0.0",
"tqdm>=4.67.1",
"fastexcel>=0.19.0",
@ -26,8 +26,6 @@ dependencies = [
"pillow>=12.0.0",
"folium>=0.20.0",
"pyogrio>=0.12.1",
"httpx",
"polars",
]
[tool.uv]

View file

@ -47,4 +47,7 @@ lto = "thin"
[profile.production]
inherits = "release"
lto = true
lto = "fat"
codegen-units = 1
strip = true
panic = "abort"

View file

@ -0,0 +1,99 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use serde::Serialize;
/// Uniform API error type. Implements `IntoResponse` and serializes as JSON so
/// every endpoint returns a structurally-identical error body the frontend
/// can rely on, regardless of which route raised it.
#[derive(Debug, Clone)]
pub enum ApiError {
BadRequest(String),
Unauthorized,
Forbidden(String),
NotFound(String),
Conflict(String),
Internal(String),
BadGateway(String),
ServiceUnavailable(String),
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
message: String,
}
impl ApiError {
fn status(&self) -> StatusCode {
match self {
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden(_) => StatusCode::FORBIDDEN,
Self::NotFound(_) => StatusCode::NOT_FOUND,
Self::Conflict(_) => StatusCode::CONFLICT,
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::BadGateway(_) => StatusCode::BAD_GATEWAY,
Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
}
}
fn code(&self) -> &'static str {
match self {
Self::BadRequest(_) => "bad_request",
Self::Unauthorized => "unauthorized",
Self::Forbidden(_) => "forbidden",
Self::NotFound(_) => "not_found",
Self::Conflict(_) => "conflict",
Self::Internal(_) => "internal_error",
Self::BadGateway(_) => "upstream_error",
Self::ServiceUnavailable(_) => "service_unavailable",
}
}
fn message(&self) -> String {
match self {
Self::Unauthorized => "Authentication required".to_string(),
Self::BadRequest(m)
| Self::Forbidden(m)
| Self::NotFound(m)
| Self::Conflict(m)
| Self::Internal(m)
| Self::BadGateway(m)
| Self::ServiceUnavailable(m) => m.clone(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = self.status();
let body = ErrorBody {
error: self.code().to_string(),
message: self.message(),
};
(status, Json(body)).into_response()
}
}
/// Bridge from the legacy `(StatusCode, String)` tuples to the new error type
/// so partially-migrated routes keep compiling while the migration progresses.
impl From<(StatusCode, String)> for ApiError {
fn from((status, message): (StatusCode, String)) -> Self {
match status {
StatusCode::BAD_REQUEST => Self::BadRequest(message),
StatusCode::UNAUTHORIZED => Self::Unauthorized,
StatusCode::FORBIDDEN => Self::Forbidden(message),
StatusCode::NOT_FOUND => Self::NotFound(message),
StatusCode::CONFLICT => Self::Conflict(message),
StatusCode::BAD_GATEWAY => Self::BadGateway(message),
StatusCode::SERVICE_UNAVAILABLE => Self::ServiceUnavailable(message),
_ => Self::Internal(message),
}
}
}
impl From<String> for ApiError {
fn from(message: String) -> Self {
Self::Internal(message)
}
}

View file

@ -14,6 +14,10 @@ pub const MAX_CELLS_PER_REQUEST: usize = 200000;
pub const MAX_POIS_PER_REQUEST: usize = 3000;
pub const DEFAULT_PROPERTIES_LIMIT: usize = 100;
pub const DEFAULT_ACTUAL_LISTINGS_LIMIT: usize = 500;
pub const MAX_ACTUAL_LISTINGS_LIMIT: usize = 2000;
pub const MAX_PLACES_LIMIT: usize = 20;
pub const DEFAULT_PLACES_LIMIT: usize = 7;
pub const MAX_PRICE_HISTORY_POINTS: usize = 5000;
pub const POSTCODE_SEARCH_OFFSET: f64 = 0.02;

View file

@ -268,6 +268,32 @@ fn extract_opt_datetime_iso(df: &DataFrame, name: &str) -> Result<Vec<Option<Str
.collect())
}
fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?;
let list = column
.list()
.with_context(|| format!("Column '{name}' is not a list column"))?;
let mut out = Vec::with_capacity(list.len());
for series_opt in list.into_iter() {
let entries = match series_opt {
Some(series) => {
let strings = series.str().with_context(|| {
format!("Column '{name}' list inner is not a string column")
})?;
strings
.into_iter()
.filter_map(|value| value.map(ToString::to_string))
.collect()
}
None => Vec::new(),
};
out.push(entries);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@ -298,29 +324,3 @@ mod tests {
assert!(!any_listing.listing_url.is_empty());
}
}
fn extract_str_list(df: &DataFrame, name: &str) -> Result<Vec<Vec<String>>> {
let column = df
.column(name)
.with_context(|| format!("Missing column '{name}'"))?;
let list = column
.list()
.with_context(|| format!("Column '{name}' is not a list column"))?;
let mut out = Vec::with_capacity(list.len());
for series_opt in list.into_iter() {
let entries = match series_opt {
Some(series) => {
let strings = series.str().with_context(|| {
format!("Column '{name}' list inner is not a string column")
})?;
strings
.into_iter()
.filter_map(|value| value.map(ToString::to_string))
.collect()
}
None => Vec::new(),
};
out.push(entries);
}
Ok(out)
}

View file

@ -331,7 +331,10 @@ impl PlaceData {
let lon = extract_f32_col(&df, "lon")?;
let population: Vec<u32> = if df.column("population").is_ok() {
let pop_f32 = extract_f32_col(&df, "population")?;
pop_f32.iter().map(|&val| val.max(0.0) as u32).collect()
pop_f32
.iter()
.map(|&val| val.max(0.0).min(u32::MAX as f32) as u32)
.collect()
} else {
vec![0; row_count]
};
@ -419,11 +422,11 @@ mod tests {
fn test_city_rows() -> [(&'static str, f32, f32, u32); 5] {
[
("London", 51.5074456, -0.1277653, 8_908_083),
("Westminster", 51.4973206, -0.137149, 211_365),
("City of London", 51.5156177, -0.0919983, 10_847),
("Cambridge", 52.2055314, 0.1186637, 145_818),
("Oxford", 51.7520131, -1.2578499, 165_000),
("London", 51.507_446, -0.1277653, 8_908_083),
("Westminster", 51.497_322, -0.137149, 211_365),
("City of London", 51.515_617, -0.0919983, 10_847),
("Cambridge", 52.205_532, 0.1186637, 145_818),
("Oxford", 51.752_014, -1.2578499, 165_000),
]
}
@ -503,7 +506,7 @@ mod tests {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(51.3713049, -0.101957, &cities),
nearest_display_city(51.371_304, -0.101957, &cities),
Some("London")
);
}
@ -513,7 +516,7 @@ mod tests {
let cities = test_city_candidates();
assert_eq!(
nearest_display_city(52.1277704, -0.0813098, &cities),
nearest_display_city(52.127_77, -0.0813098, &cities),
Some("Cambridge")
);
}

View file

@ -1014,6 +1014,22 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[
},
];
/// Feature names that describe an individual property (price, size, type, etc.) rather
/// than the surrounding area. Use this to skip filters that should not exclude live
/// listings on the map even though they hide aggregated property rows.
pub fn property_level_feature_names() -> Vec<&'static str> {
const PROPERTY_GROUPS: &[&str] = &["Properties", "Property prices"];
FEATURE_GROUPS
.iter()
.filter(|group| PROPERTY_GROUPS.contains(&group.name))
.flat_map(|group| group.features.iter())
.map(|feature| match feature {
Feature::Numeric(c) => c.name,
Feature::Enum(c) => c.name,
})
.collect()
}
/// Flat ordered list of all numeric feature names (follows group order).
pub fn all_numeric_feature_names() -> Vec<&'static str> {
FEATURE_GROUPS

View file

@ -1,6 +1,7 @@
#![allow(clippy::min_ident_chars)]
mod aggregation;
mod api_error;
mod auth;
mod bugsink;
mod checkout_sessions;

View file

@ -1,80 +1,239 @@
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use rustc_hash::FxHashSet;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::api_error::ApiError;
use crate::consts::{DEFAULT_ACTUAL_LISTINGS_LIMIT, MAX_ACTUAL_LISTINGS_LIMIT};
use crate::data::ActualListing;
use crate::parsing::require_bounds;
use crate::state::SharedState;
use crate::features::property_level_feature_names;
use crate::parsing::{
parse_filters_with_poi, require_bounds, row_passes_filters, row_passes_poi_filters,
};
use crate::state::{AppState, SharedState};
const MAX_RESULTS: usize = 5000;
use super::travel_time::{parse_optional_travel, row_passes_travel_filters, TravelEntry};
#[derive(Deserialize)]
pub struct ActualListingsParams {
bounds: Option<String>,
/// `;;`-separated filters: `name:min:max;;...`
filters: Option<String>,
/// Pipe-separated travel time entries: `mode:slug|mode:slug:min:max`
travel: Option<String>,
/// Page size — defaults to DEFAULT_ACTUAL_LISTINGS_LIMIT, capped at
/// MAX_ACTUAL_LISTINGS_LIMIT.
limit: Option<usize>,
/// Number of results to skip. Defaults to 0.
offset: Option<usize>,
}
#[derive(Serialize)]
pub struct ActualListingsResponse {
pub listings: Vec<ActualListing>,
pub total: usize,
pub limit: usize,
pub offset: usize,
pub truncated: bool,
}
pub async fn get_actual_listings(
State(shared): State<Arc<SharedState>>,
Query(params): Query<ActualListingsParams>,
) -> Result<Json<ActualListingsResponse>, (StatusCode, String)> {
) -> Result<Json<ActualListingsResponse>, ApiError> {
let state = shared.load_state();
let limit = params
.limit
.unwrap_or(DEFAULT_ACTUAL_LISTINGS_LIMIT)
.min(MAX_ACTUAL_LISTINGS_LIMIT);
let offset = params.offset.unwrap_or(0);
let Some(actual_listings) = state.actual_listings.clone() else {
return Ok(Json(ActualListingsResponse {
listings: Vec::new(),
total: 0,
limit,
offset,
truncated: false,
}));
};
let (south, west, north, east) = require_bounds(params.bounds)?;
let (south, west, north, east) = require_bounds(params.bounds).map_err(ApiError::from)?;
let response = tokio::task::spawn_blocking(move || {
let t0 = std::time::Instant::now();
let row_indices = actual_listings.grid.query(south, west, north, east);
let total = row_indices.len();
let truncated = total > MAX_RESULTS;
let quant = state.data.quant_ref();
let poi_quant = state.data.poi_metrics.quant_ref();
let (mut parsed_filters, mut parsed_enum_filters, parsed_poi_filters) = parse_filters_with_poi(
params.filters.as_deref(),
&state.feature_name_to_index,
&state.data.enum_values,
&quant,
&state.data.poi_metrics.name_to_index,
&poi_quant,
)
.map_err(ApiError::BadRequest)?;
let mut listings: Vec<ActualListing> = row_indices
.iter()
.take(MAX_RESULTS)
.map(|&row| actual_listings.listing_at(row as usize))
.collect();
// Drop property-level filters (price, sqm, build year, beds, type, etc.) so they
// don't hide live listings — those are individual-property concerns the user can
// judge from the pin itself. We only keep area/postcode-level filters here.
let property_level_idxs: FxHashSet<usize> = property_level_feature_names()
.into_iter()
.filter_map(|name| state.feature_name_to_index.get(name).copied())
.collect();
parsed_filters.retain(|f| !property_level_idxs.contains(&f.feat_idx));
parsed_enum_filters.retain(|f| !property_level_idxs.contains(&f.feat_idx));
// Sort newest first so the most relevant pins win when the viewport is busy.
listings.sort_by(|left, right| {
right
.listing_date_iso
.cmp(&left.listing_date_iso)
.then_with(|| right.asking_price.cmp(&left.asking_price))
});
let travel_entries =
parse_optional_travel(params.travel.as_deref()).map_err(ApiError::BadRequest)?;
let elapsed = t0.elapsed();
info!(
results = listings.len(),
total,
truncated,
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/actual-listings"
);
let has_area_filters = !parsed_filters.is_empty()
|| !parsed_enum_filters.is_empty()
|| !parsed_poi_filters.is_empty()
|| !travel_entries.is_empty();
ActualListingsResponse {
listings,
total,
truncated,
}
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
let state_clone = state.clone();
let response =
tokio::task::spawn_blocking(move || -> Result<ActualListingsResponse, String> {
let t0 = std::time::Instant::now();
let passing_postcodes = if has_area_filters {
Some(compute_passing_postcodes(
&state_clone,
south,
west,
north,
east,
&parsed_filters,
&parsed_enum_filters,
&parsed_poi_filters,
&travel_entries,
)?)
} else {
None
};
let row_indices = actual_listings.grid.query(south, west, north, east);
let total_in_bounds = row_indices.len();
// Build (row, sort_key) pairs so we can sort by index without
// materializing the full ActualListing for every matching row.
let mut matching_rows: Vec<usize> = row_indices
.iter()
.filter_map(|&row_idx| {
let row = row_idx as usize;
if let Some(allowed) = passing_postcodes.as_ref() {
if !allowed.contains(actual_listings.postcode[row].as_str()) {
return None;
}
}
Some(row)
})
.collect();
let total_matching = matching_rows.len();
matching_rows.sort_by(|&left, &right| {
actual_listings.listing_date_iso[right]
.cmp(&actual_listings.listing_date_iso[left])
.then_with(|| {
actual_listings.asking_price[right].cmp(&actual_listings.asking_price[left])
})
});
let truncated = total_matching > offset.saturating_add(limit);
let listings: Vec<ActualListing> = matching_rows
.iter()
.skip(offset)
.take(limit)
.map(|&row| actual_listings.listing_at(row))
.collect();
let elapsed = t0.elapsed();
info!(
results = listings.len(),
total = total_matching,
total_in_bounds,
offset,
filtered = passing_postcodes.is_some(),
ms = format_args!("{:.1}", elapsed.as_secs_f64() * 1000.0),
"GET /api/actual-listings"
);
Ok(ActualListingsResponse {
listings,
total: total_matching,
limit,
offset,
truncated,
})
})
.await
.map_err(|error| ApiError::Internal(error.to_string()))?
.map_err(ApiError::Internal)?;
Ok(Json(response))
}
#[allow(clippy::too_many_arguments)]
fn compute_passing_postcodes(
state: &AppState,
south: f64,
west: f64,
north: f64,
east: f64,
parsed_filters: &[crate::parsing::ParsedFilter],
parsed_enum_filters: &[crate::parsing::ParsedEnumFilter],
parsed_poi_filters: &[crate::parsing::ParsedPoiFilter],
travel_entries: &[TravelEntry],
) -> Result<FxHashSet<String>, String> {
let num_features = state.data.num_features;
let feature_data = &state.data.feature_data;
let poi_metrics = &state.data.poi_metrics;
let has_poi_filters = !parsed_poi_filters.is_empty();
let travel_data = if travel_entries.is_empty() {
Vec::new()
} else {
let store = &state.travel_time_store;
travel_entries
.iter()
.map(|entry| {
store
.get(&entry.mode, &entry.slug)
.map_err(|err| format!("Failed to load travel data: {}", err))
})
.collect::<Result<Vec<_>, _>>()?
};
let has_travel = !travel_entries.is_empty();
let mut passing: FxHashSet<String> = FxHashSet::default();
state
.grid
.for_each_in_bounds(south, west, north, east, |row_idx| {
let row = row_idx as usize;
if !row_passes_filters(
row,
parsed_filters,
parsed_enum_filters,
feature_data,
num_features,
) {
return;
}
if has_poi_filters && !row_passes_poi_filters(row, parsed_poi_filters, poi_metrics) {
return;
}
let postcode = state.data.postcode(row);
if has_travel && !row_passes_travel_filters(postcode, travel_entries, &travel_data) {
return;
}
// Property postcodes share the same canonical "OUT IN" format used by
// ActualListingData::load (normalize_postcode), so we can match by string.
if !passing.contains(postcode) {
passing.insert(postcode.to_string());
}
});
Ok(passing)
}

View file

@ -1,6 +1,8 @@
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use std::sync::{Arc, Once};
static OUT_OF_RANGE_WARN: Once = Once::new();
use axum::extract::{Query, State};
use axum::http::StatusCode;
@ -260,6 +262,14 @@ pub(super) fn top_filter_exclusions(
continue;
};
let Some(category) = values.get(raw as usize) else {
OUT_OF_RANGE_WARN.call_once(|| {
warn!(
feature = %data.feature_names[filter.feat_idx],
raw,
max = values.len(),
"Enum value index out of range (logged once)"
);
});
continue;
};
@ -372,10 +382,10 @@ pub(super) fn top_filter_exclusions(
.unwrap_or(f32::INFINITY);
let replace = path_score < current_score
|| (path_score == current_score
|| (path_score.total_cmp(&current_score) == std::cmp::Ordering::Equal
&& best_path
.as_ref()
.map_or(true, |current| path.len() < current.len()));
.is_none_or(|current| path.len() < current.len()));
if replace {
best_path = Some(path);
}
@ -394,8 +404,7 @@ pub(super) fn top_filter_exclusions(
exclusions.sort_by(|a, b| {
a.relative_difference
.partial_cmp(&b.relative_difference)
.unwrap_or(std::cmp::Ordering::Equal)
.total_cmp(&b.relative_difference)
.then_with(|| b.rejected_count.cmp(&a.rejected_count))
.then_with(|| a.name.cmp(&b.name))
});
@ -524,6 +533,27 @@ pub async fn get_hexagon_stats(
// for the requested journey destination (so it has journey data). Fall back
// to geographic proximity to the hexagon center.
let central_postcode = if !matching_rows.is_empty() {
let center: h3o::LatLng = cell.into();
let center_lat = center.lat() as f32;
let center_lon = center.lng() as f32;
let lat = state.data.lat.as_slice();
let lon = state.data.lon.as_slice();
let distance_sq = |row: usize| -> Option<f32> {
match (lat.get(row), lon.get(row)) {
(Some(&la), Some(&lo)) if la.is_finite() && lo.is_finite() => {
Some((la - center_lat).powi(2) + (lo - center_lon).powi(2))
}
_ => {
OUT_OF_RANGE_WARN.call_once(|| {
warn!(
"matching_rows index out of range or non-finite lat/lon (logged once)"
);
});
None
}
}
};
if let Some(ref travel_data) = journey_travel_data {
// Find the row with the shortest travel time in the travel data
let best_row = matching_rows
@ -537,40 +567,24 @@ pub async fn get_hexagon_stats(
.map(|(row, _)| row);
// Fall back to geographic center if no row has travel data
let row = best_row.unwrap_or_else(|| {
let center: h3o::LatLng = cell.into();
let center_lat = center.lat() as f32;
let center_lon = center.lng() as f32;
let row = best_row.or_else(|| {
matching_rows
.iter()
.copied()
.min_by(|&a, &b| {
let da = (state.data.lat[a] - center_lat).powi(2)
+ (state.data.lon[a] - center_lon).powi(2);
let db = (state.data.lat[b] - center_lat).powi(2)
+ (state.data.lon[b] - center_lon).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.expect("matching_rows is non-empty")
.filter_map(|row| distance_sq(row).map(|d| (row, d)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(row, _)| row)
});
Some(state.data.postcode(row).to_string())
row.map(|row| state.data.postcode(row).to_string())
} else {
// No journey destination requested — use geographic center
let center: h3o::LatLng = cell.into();
let center_lat = center.lat() as f32;
let center_lon = center.lng() as f32;
let closest_row = matching_rows
.iter()
.copied()
.min_by(|&a, &b| {
let da = (state.data.lat[a] - center_lat).powi(2)
+ (state.data.lon[a] - center_lon).powi(2);
let db = (state.data.lat[b] - center_lat).powi(2)
+ (state.data.lon[b] - center_lon).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.expect("matching_rows is non-empty");
Some(state.data.postcode(closest_row).to_string())
.filter_map(|row| distance_sq(row).map(|d| (row, d)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(row, _)| row);
closest_row.map(|row| state.data.postcode(row).to_string())
}
} else {
None

View file

@ -292,6 +292,47 @@ async fn mark_invite_used(
return Err(StatusCode::BAD_GATEWAY.into_response());
}
// Defense in depth: PocketBase has no atomic compare-and-swap for record
// updates, and our local + distributed locks could in principle fail (lock
// server timeout, server restart mid-redemption). Re-read the record and
// confirm WE actually own it — if a concurrent redemption beat us to the
// PATCH, both writes succeeded but the loser's user_id is overwritten and
// we must NOT grant a license.
let verify_url = format!("{pb_url}/api/collections/invites/records/{invite_id}");
let verify_resp = match state
.http_client
.get(&verify_url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
{
Ok(r) => r,
Err(err) => {
warn!("Failed to verify invite redemption: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
if !verify_resp.status().is_success() {
return Err(StatusCode::BAD_GATEWAY.into_response());
}
let body: serde_json::Value = match verify_resp.json().await {
Ok(v) => v,
Err(err) => {
warn!("Failed to parse invite verify response: {err}");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
};
let actual_user = body["used_by_id"].as_str().unwrap_or("");
if actual_user != user_id {
warn!(
invite_id,
expected = user_id,
actual = actual_user,
"Invite redemption race lost — invite already claimed by another user"
);
return Err((StatusCode::CONFLICT, "Invite was already redeemed").into_response());
}
Ok(())
}
@ -512,11 +553,16 @@ pub async fn get_invite(
.await
{
Ok(resp) if resp.status().is_success() => {
let user_body: serde_json::Value = resp.json().await.unwrap_or_default();
user_body["email"]
.as_str()
.and_then(|e| e.split('@').next())
.and_then(sanitize_invited_by)
match resp.json::<serde_json::Value>().await {
Ok(user_body) => user_body["email"]
.as_str()
.and_then(|e| e.split('@').next())
.and_then(sanitize_invited_by),
Err(err) => {
tracing::error!("Failed to parse inviter user record JSON: {err}");
return StatusCode::BAD_GATEWAY.into_response();
}
}
}
_ => None,
}
@ -689,26 +735,6 @@ pub async fn post_redeem_invite(
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
assert_eq!(
filter,
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
);
}
#[test]
fn redeemable_invite_filter_rejects_unsafe_values() {
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
}
}
/// List invites. Users only see invites they created, including admins.
pub async fn get_invites(
State(shared): State<Arc<SharedState>>,
@ -787,3 +813,23 @@ pub async fn get_invites(
Json(InviteListResponse { invites }).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redeemable_invite_filter_allows_unused_or_same_user_invite() {
let filter = redeemable_invite_filter("abc123", "user123").unwrap();
assert_eq!(
filter,
"code=\"abc123\" && (used_by_id=\"\" || used_by_id=\"user123\")"
);
}
#[test]
fn redeemable_invite_filter_rejects_unsafe_values() {
assert!(redeemable_invite_filter("bad-code", "user123").is_err());
assert!(redeemable_invite_filter("abc123", "bad-user").is_err());
}
}

View file

@ -1,11 +1,12 @@
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::api_error::ApiError;
use crate::consts::{DEFAULT_PLACES_LIMIT, MAX_PLACES_LIMIT};
use crate::data::{normalize_search_text, slugify};
use crate::state::SharedState;
@ -96,15 +97,18 @@ fn postcode_starts_with_compact(postcode: &str, compact_query: &str) -> bool {
pub async fn get_places(
State(shared): State<Arc<SharedState>>,
Query(params): Query<PlacesParams>,
) -> Result<Json<PlacesResponse>, (StatusCode, String)> {
) -> Result<Json<PlacesResponse>, ApiError> {
let state = shared.load_state();
let query = if params.q.is_empty() {
return Err((StatusCode::BAD_REQUEST, "'q' must not be empty".into()));
return Err(ApiError::BadRequest("'q' must not be empty".into()));
} else {
params.q
};
let limit = params.limit.unwrap_or(7).min(20);
let limit = params
.limit
.unwrap_or(DEFAULT_PLACES_LIMIT)
.min(MAX_PLACES_LIMIT);
let mode_filter = params.mode;
let places = tokio::task::spawn_blocking(move || {
@ -264,7 +268,7 @@ pub async fn get_places(
(results, postcodes, addresses)
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
.map_err(|error| ApiError::Internal(error.to_string()))?;
Ok(Json(PlacesResponse {
places: places.0,

View file

@ -1,11 +1,11 @@
use std::sync::Arc;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::api_error::ApiError;
use crate::consts::MAX_POIS_PER_REQUEST;
use crate::data::{resolve_poi_category_filter, POICategoryGroup};
use crate::parsing::require_bounds;
@ -39,9 +39,9 @@ pub struct POIParams {
pub async fn get_pois(
State(shared): State<Arc<SharedState>>,
Query(params): Query<POIParams>,
) -> Result<Json<POIsResponse>, (StatusCode, String)> {
) -> Result<Json<POIsResponse>, ApiError> {
let state = shared.load_state();
let (south, west, north, east) = require_bounds(params.bounds)?;
let (south, west, north, east) = require_bounds(params.bounds).map_err(ApiError::from)?;
let category_filter: Option<rustc_hash::FxHashSet<u16>> = params
.categories
@ -109,7 +109,7 @@ pub async fn get_pois(
pois
})
.await
.map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
.map_err(|error| ApiError::Internal(error.to_string()))?;
Ok(Json(POIsResponse { pois }))
}

View file

@ -14,7 +14,7 @@ use crate::parsing::{parse_filters_with_poi, row_passes_filters, row_passes_poi_
use crate::state::SharedState;
use crate::utils::normalize_postcode;
use super::properties::{HexagonPropertiesResponse, Property};
use super::properties::{Property, PropertyListResponse};
use super::travel_time::{load_travel_data, parse_optional_travel, row_passes_travel_filters};
#[derive(Deserialize)]
@ -36,7 +36,7 @@ pub async fn get_postcode_properties(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<PostcodePropertiesParams>,
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
let state = shared.load_state();
let normalized = normalize_postcode(&params.postcode);
@ -183,7 +183,7 @@ pub async fn get_postcode_properties(
"GET /api/postcode-properties"
);
Ok(HexagonPropertiesResponse {
Ok(PropertyListResponse {
properties,
total,
limit,

View file

@ -62,8 +62,11 @@ pub struct Property {
pub features: FxHashMap<String, f32>,
}
/// Shared paginated list of `Property` records. Used by both
/// `/api/hexagon-properties` (lookup by H3 cell) and `/api/postcode-properties`
/// (lookup by postcode) so the frontend can render either result the same way.
#[derive(Serialize)]
pub struct HexagonPropertiesResponse {
pub struct PropertyListResponse {
pub properties: Vec<Property>,
pub total: usize,
pub limit: usize,
@ -183,7 +186,7 @@ pub async fn get_hexagon_properties(
State(shared): State<Arc<SharedState>>,
Extension(user): Extension<OptionalUser>,
Query(params): Query<HexagonPropertiesParams>,
) -> Result<Json<HexagonPropertiesResponse>, axum::response::Response> {
) -> Result<Json<PropertyListResponse>, axum::response::Response> {
let state = shared.load_state();
let cell = h3o::CellIndex::from_str(&params.h3).map_err(|error| {
warn!(h3 = %params.h3, error = %error, "Invalid H3 cell index");
@ -306,7 +309,7 @@ pub async fn get_hexagon_properties(
"GET /api/hexagon-properties"
);
Ok(HexagonPropertiesResponse {
Ok(PropertyListResponse {
properties,
total,
limit,

4
uv.lock generated
View file

@ -1401,7 +1401,6 @@ dev = [
requires-dist = [
{ name = "fastexcel", specifier = ">=0.19.0" },
{ name = "folium", specifier = ">=0.20.0" },
{ name = "httpx" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1" },
{ name = "ipywidgets", specifier = ">=8.0.0" },
{ name = "jupyter", specifier = ">=1.0.0" },
@ -1411,8 +1410,7 @@ requires-dist = [
{ name = "pandas", specifier = ">=2.0.0" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "plotly", specifier = ">=6.5.2" },
{ name = "polars" },
{ name = "polars", specifier = ">=1.37.1" },
{ name = "polars", specifier = ">=1.37.1,<2.0.0" },
{ name = "pyarrow", specifier = ">=15.0.0" },
{ name = "pyogrio", specifier = ">=0.12.1" },
{ name = "pyproj", specifier = ">=3.7.2" },