This commit is contained in:
Andras Schmelczer 2026-05-17 10:16:30 +01:00
parent 47d89f6fad
commit 017902b8e6
82 changed files with 331466 additions and 54841 deletions

View file

@ -20,6 +20,7 @@
"@protomaps/basemaps": "^5.7.2",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@sentry/react": "^10.53.1",
"@types/supercluster": "^7.1.3",
"i18next": "^26.0.10",
"maplibre-gl": "^5.24.0",
@ -5287,6 +5288,97 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry-internal/feedback": "10.53.1",
"@sentry-internal/replay": "10.53.1",
"@sentry-internal/replay-canvas": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz",
"integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",

View file

@ -27,6 +27,7 @@
"@protomaps/basemaps": "^5.7.2",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@sentry/react": "^10.53.1",
"@types/supercluster": "^7.1.3",
"i18next": "^26.0.10",
"maplibre-gl": "^5.24.0",

View file

@ -318,6 +318,7 @@ export default function App() {
const savedSearches = useSavedSearches(user?.id ?? null);
const [showSaveModal, setShowSaveModal] = useState(false);
const [editingSearch, setEditingSearch] = useState<{ id: string; name: string } | null>(null);
useEffect(() => {
const controller = new AbortController();
@ -374,11 +375,52 @@ export default function App() {
}
setRouteHash(targetHash);
setActivePage(page);
setEditingSearch(null);
if (targetHash) scrollToHash(targetHash);
},
[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 handleCancelEdit = useCallback(() => {
setEditingSearch(null);
}, []);
const updateEditingSearch = useCallback(
async (params: string) => {
if (!editingSearch) return;
await savedSearches.updateSearchParams(editingSearch.id, params);
setEditingSearch(null);
},
[editingSearch, savedSearches]
);
const handleUpdateEdit = useCallback(
async (params: string) => {
try {
await updateEditingSearch(params);
navigateTo('saved');
} catch {
// Error stored on savedSearches.error
}
},
[updateEditingSearch, navigateTo]
);
useEffect(() => {
if (authLoading || !user || postAuthIntent !== 'checkout') return;
@ -439,6 +481,8 @@ export default function App() {
if (page === 'dashboard') {
setMapUrlState(parseUrlState());
setDashboardRouteKey(window.location.search);
} else {
setEditingSearch(null);
}
};
window.addEventListener('popstate', handlePopState);
@ -517,8 +561,17 @@ export default function App() {
onToggleTheme={toggleTheme}
exportState={activePage === 'dashboard' ? exportState : null}
dashboardParams={activePage === 'dashboard' ? dashboardParams : ''}
onSaveSearch={activePage === 'dashboard' && user ? () => setShowSaveModal(true) : null}
onSaveSearch={
activePage === 'dashboard' && user
? editingSearch
? () => handleUpdateEdit(dashboardParams)
: () => setShowSaveModal(true)
: null
}
savingSearch={savedSearches.saving}
editingSearch={activePage === 'dashboard' ? editingSearch : null}
onCancelEdit={handleCancelEdit}
onUpdateEdit={() => handleUpdateEdit(dashboardParams)}
user={user}
onLoginClick={() => openAuthModal('login')}
onRegisterClick={() => openAuthModal('register')}
@ -553,9 +606,7 @@ export default function App() {
onDeleteSearch={savedSearches.deleteSearch}
onUpdateSearchNotes={savedSearches.updateSearchNotes}
onUpdateSearchName={savedSearches.updateSearchName}
onOpenSearch={(params) => {
window.location.href = `/dashboard?${params}`;
}}
onOpenSearch={handleEditSearch}
/>
) : activePage === 'account' && user ? (
<AccountPage
@ -609,6 +660,10 @@ export default function App() {
deferTutorial={licenseSuccessStatus !== 'hidden'}
onSaveSearch={user ? savedSearches.saveSearch : undefined}
savingSearch={savedSearches.saving}
editingSearch={editingSearch}
onCancelEdit={handleCancelEdit}
onUpdateEdit={handleUpdateEdit}
onUpdateEditInPlace={updateEditingSearch}
/>
)}
</Suspense>

View file

@ -198,7 +198,7 @@ function SavedSearchesTab({
onDelete: (id: string) => Promise<void>;
onUpdateNotes: (id: string, notes: string) => void;
onUpdateName: (id: string, name: string) => void;
onOpen: (params: string) => void;
onOpen: (id: string, name: string, params: string) => void;
}) {
const { t, i18n } = useTranslation();
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
@ -302,7 +302,7 @@ function SavedSearchesTab({
<div className="flex gap-2 mt-auto">
<button
onClick={() => onOpen(search.params)}
onClick={() => onOpen(search.id, search.name, search.params)}
className="flex-1 px-3 py-1.5 text-sm font-medium rounded bg-teal-600 text-white hover:bg-teal-700"
>
{t('common.open')}
@ -358,7 +358,7 @@ export function SavedPage({
onDeleteSearch: (id: string) => Promise<void>;
onUpdateSearchNotes: (id: string, notes: string) => void;
onUpdateSearchName: (id: string, name: string) => void;
onOpenSearch: (params: string) => void;
onOpenSearch: (id: string, name: string, params: string) => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'searches' | 'shared-links'>(

View file

@ -84,7 +84,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
{
name: 'Good+ primary schools within 2km',
type: 'numeric',
group: 'Education',
group: 'Schools',
min: 0,
max: 8,
step: 1,
@ -92,7 +92,7 @@ const DEMO_FEATURES: FeatureMeta[] = [
{
name: 'Noise (dB)',
type: 'numeric',
group: 'Environment',
group: 'Defining characteristics',
min: 40,
max: 80,
step: 1,

View file

@ -44,7 +44,6 @@ interface AreaPaneProps {
loading: boolean;
hexagonId: string | null;
isPostcode?: boolean;
onViewProperties: () => void;
hexagonLocation: HexagonLocation | null;
filters: FeatureFilters;
unfilteredCount?: number | null;
@ -82,7 +81,6 @@ export default function AreaPane({
loading,
hexagonId,
isPostcode = false,
onViewProperties,
hexagonLocation,
filters,
unfilteredCount,
@ -100,7 +98,6 @@ export default function AreaPane({
const filtersActive = activeFilterCount > 0;
const filteredStatsEmpty = filtersActive && statsUseFilters && stats?.count === 0;
const showFlipToggleCallout = filteredStatsEmpty && unfilteredCount !== 0;
const canViewProperties = stats && stats.count > 0 && (statsUseFilters || !filtersActive);
const featureGroups = useMemo(() => groupFeaturesByCategory(globalFeatures), [globalFeatures]);
const [infoFeature, setInfoFeature] = useState<FeatureMeta | null>(null);
@ -275,14 +272,6 @@ export default function AreaPane({
)}
</div>
)}
{canViewProperties && (
<button
onClick={onViewProperties}
className="w-full text-sm py-1.5 rounded bg-teal-600 hover:bg-teal-700 text-white font-medium"
>
{t('areaPane.viewPropertiesShort')}
</button>
)}
</div>
</div>

View file

@ -44,6 +44,8 @@ export default function ExternalSearchLinks({
if (!urls) return null;
const primaryLinkClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded bg-teal-600 hover:bg-teal-700 text-white dark:bg-teal-500 dark:text-navy-950 dark:hover:bg-teal-400 font-medium shadow-sm';
const linkClass =
'flex-1 text-center text-xs py-1.5 px-2 rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-teal-600 dark:text-teal-400 hover:bg-warm-50 dark:hover:bg-warm-700 font-medium';
const disabledClass =
@ -56,7 +58,12 @@ export default function ExternalSearchLinks({
</h3>
<div className="flex flex-wrap gap-2">
{rightmoveHref ? (
<a href={rightmoveHref} target="_blank" rel="noopener noreferrer" className={linkClass}>
<a
href={rightmoveHref}
target="_blank"
rel="noopener noreferrer"
className={primaryLinkClass}
>
Rightmove
</a>
) : (

View file

@ -161,7 +161,7 @@ export default function FeatureBrowser({
title={t('filters.aboutData')}
size="md"
>
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
<InfoIcon className="w-4 h-4" />
</IconButton>
<button
type="button"

View file

@ -106,6 +106,9 @@ interface FiltersProps {
onClearAll: () => void;
onSaveSearch?: (name: string) => Promise<void>;
savingSearch?: boolean;
editingSearchName?: string | null;
onUpdateSearch?: () => Promise<void>;
onExitEditing?: () => void;
destinationDropdownPortal?: boolean;
}
@ -148,6 +151,9 @@ export default memo(function Filters({
onClearAll,
onSaveSearch,
savingSearch,
editingSearchName,
onUpdateSearch,
onExitEditing,
destinationDropdownPortal = true,
}: FiltersProps) {
const { t } = useTranslation();
@ -229,7 +235,7 @@ export default memo(function Filters({
const backendFeature = backendName
? features.find((feature) => feature.name === backendName)
: undefined;
return { ...(backendFeature ?? schoolMeta), name, group: 'Education' };
return { ...(backendFeature ?? schoolMeta), name, group: 'Schools' };
});
}, [filters, features, schoolMeta]);
const specificCrimeFilterItems = useMemo(() => {
@ -441,7 +447,7 @@ export default memo(function Filters({
const getAddFilterGroupName = useCallback(
(name: string): string | null => {
if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Education';
if (name === SCHOOL_FILTER_NAME) return schoolMeta.group ?? 'Schools';
if (name === SPECIFIC_CRIMES_FILTER_NAME) return specificCrimeMeta.group ?? 'Crime';
if (name === ELECTION_VOTE_SHARE_FILTER_NAME) {
return electionVoteShareMeta.group ?? 'Neighbours';
@ -569,14 +575,14 @@ export default memo(function Filters({
const handleClearAllClick = useCallback(() => {
if (badgeCount === 0) return;
if (onSaveSearch) {
if (onUpdateSearch || onSaveSearch) {
setShowClearPopup(true);
setClearSaveName('');
setClearSaveError(null);
} else {
onClearAll();
}
}, [badgeCount, onSaveSearch, onClearAll]);
}, [badgeCount, onUpdateSearch, onSaveSearch, onClearAll]);
const handleSaveAndClear = useCallback(
async (e: FormEvent) => {
@ -593,10 +599,22 @@ export default memo(function Filters({
[clearSaveName, savingSearch, onSaveSearch, onClearAll, t]
);
const handleUpdateAndClear = useCallback(async () => {
if (savingSearch || !onUpdateSearch) return;
try {
await onUpdateSearch();
setShowClearPopup(false);
onClearAll();
} catch {
setClearSaveError(t('saveSearch.saving'));
}
}, [savingSearch, onUpdateSearch, onClearAll, t]);
const handleClearWithoutSaving = useCallback(() => {
setShowClearPopup(false);
onClearAll();
}, [onClearAll]);
if (editingSearchName) onExitEditing?.();
}, [onClearAll, editingSearchName, onExitEditing]);
return (
<div
@ -732,9 +750,11 @@ export default memo(function Filters({
saveName={clearSaveName}
saveError={clearSaveError}
savingSearch={savingSearch}
editingSearchName={editingSearchName ?? null}
onClose={() => setShowClearPopup(false)}
onSaveNameChange={setClearSaveName}
onSaveAndClear={handleSaveAndClear}
onUpdateAndClear={onUpdateSearch ? handleUpdateAndClear : undefined}
onClearWithoutSaving={handleClearWithoutSaving}
/>
</div>

View file

@ -65,18 +65,27 @@ describe('JourneyInstructions', () => {
expect(screen.getByText(/Canary Wharf/)).toBeTruthy();
});
it('builds explicit Google Maps transit directions instead of a path URL', () => {
it('builds explicit Google Maps transit directions with destination coordinates', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-16T12:00:00Z'));
const url = googleMapsUrl('NW7 2GA', 'Bank tube station');
const url = googleMapsUrl('NW7 2GA', 'Bank tube station', 51.5132819, -0.0895555);
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe('https://www.google.com/maps/dir/');
expect(parsed.searchParams.get('api')).toBe('1');
expect(parsed.searchParams.get('origin')).toBe('NW7 2GA');
expect(parsed.searchParams.get('destination')).toBe('Bank Station, London');
expect(parsed.searchParams.get('destination')).toBe('51.5132819,-0.0895555');
expect(parsed.searchParams.get('travelmode')).toBe('transit');
expect(parsed.searchParams.get('departure_time')).toBe('1779085800');
});
it('does not rewrite destination names when coordinates are unavailable', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-16T12:00:00Z'));
const parsed = new URL(googleMapsUrl('NW7 2GA', 'Bank tube station'));
expect(parsed.searchParams.get('destination')).toBe('Bank tube station');
});
});

View file

@ -26,6 +26,8 @@ interface JourneyData {
minutes: number | null;
/** Best-case (5th percentile) total travel time from R5. */
bestMinutes: number | null;
destinationLat: number | null;
destinationLon: number | null;
/** Whether the dashboard filter is currently using best-case time. */
useBest: boolean;
loading: boolean;
@ -39,6 +41,8 @@ export interface JourneyInstructionPreset {
minutes: number | null;
/** Best-case (5th percentile) total travel time. */
bestMinutes?: number | null;
destinationLat?: number | null;
destinationLon?: number | null;
useBest?: boolean;
}
@ -94,20 +98,33 @@ function nextMondayAt730(): number {
return Math.floor(monday.getTime() / 1000);
}
function googleMapsDestination(destination: string): string {
const clean = stripId(destination).trim();
if (/\btube station$/i.test(clean)) {
return `${clean.replace(/\s+tube station$/i, ' Station')}, London`;
function googleMapsDestination(
destination: string,
destinationLat?: number | null,
destinationLon?: number | null
): string {
if (
destinationLat != null &&
destinationLon != null &&
Number.isFinite(destinationLat) &&
Number.isFinite(destinationLon)
) {
return `${destinationLat},${destinationLon}`;
}
return clean;
return stripId(destination).trim();
}
export function googleMapsUrl(origin: string, destination: string): string {
export function googleMapsUrl(
origin: string,
destination: string,
destinationLat?: number | null,
destinationLon?: number | null
): string {
const ts = nextMondayAt730();
const params = new URLSearchParams({
api: '1',
origin,
destination: googleMapsDestination(destination),
destination: googleMapsDestination(destination, destinationLat, destinationLon),
travelmode: 'transit',
departure_time: ts.toString(),
});
@ -224,6 +241,8 @@ export default function JourneyInstructions({
legs: null,
minutes: null,
bestMinutes: null,
destinationLat: null,
destinationLon: null,
useBest: e.useBest,
loading: true,
}));
@ -246,17 +265,21 @@ export default function JourneyInstructions({
journey: JourneyLeg[] | null;
minutes: number | null;
best_minutes: number | null;
destination_lat?: number | null;
destination_lon?: number | null;
}) => {
setJourneys((prev) =>
prev.map((j, i) =>
i === idx
? {
...j,
legs: data.journey,
minutes: data.minutes,
bestMinutes: data.best_minutes,
loading: false,
}
...j,
legs: data.journey,
minutes: data.minutes,
bestMinutes: data.best_minutes,
destinationLat: data.destination_lat ?? null,
destinationLon: data.destination_lon ?? null,
loading: false,
}
: j
)
);
@ -275,14 +298,16 @@ export default function JourneyInstructions({
const displayedJourneys: JourneyData[] = hasPresetJourneys
? (presetJourneys ?? []).map((journey) => ({
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
useBest: journey.useBest ?? false,
loading: false,
}))
slug: journey.slug,
label: journey.label,
legs: journey.legs,
minutes: journey.minutes,
bestMinutes: journey.bestMinutes ?? null,
destinationLat: journey.destinationLat ?? null,
destinationLon: journey.destinationLon ?? null,
useBest: journey.useBest ?? false,
loading: false,
}))
: journeys;
return (
@ -326,7 +351,7 @@ export default function JourneyInstructions({
))}
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, destination)}
href={googleMapsUrl(postcode, destination, j.destinationLat, j.destinationLon)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"
@ -361,7 +386,7 @@ export default function JourneyInstructions({
</div>
{showGoogleMapsLink && (
<a
href={googleMapsUrl(postcode, destination)}
href={googleMapsUrl(postcode, destination, j.destinationLat, j.destinationLon)}
target="_blank"
rel="noopener noreferrer"
className="mt-2 flex items-center justify-center gap-1.5 w-full text-[11px] font-medium text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300 bg-white dark:bg-warm-900 border border-warm-200 dark:border-warm-700 rounded-md py-1.5 transition-colors"

View file

@ -1,5 +1,5 @@
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import type { MapFlyToOptions, PostcodeGeometry } from '../../types';
import type { SearchedLocation } from './LocationSearch';
@ -82,6 +82,10 @@ export default function MapPage({
deferTutorial = false,
onSaveSearch,
savingSearch,
editingSearch,
onCancelEdit,
onUpdateEdit,
onUpdateEditInPlace,
}: MapPageProps) {
const { t } = useTranslation();
const [selectedPOICategories, setSelectedPOICategories] =
@ -164,6 +168,7 @@ export default function MapPage({
viewFeature,
activeFeature,
pinnedFeature,
filterRange,
travelTimeEntries: entries,
shareCode,
});
@ -283,7 +288,6 @@ export default function MapPage({
setRightPaneTab,
handleHexagonClick,
handleHexagonHover,
handleViewPropertiesFromArea,
handlePropertiesTabClick,
handleLoadMoreProperties,
handleCloseSelection,
@ -506,6 +510,9 @@ export default function MapPage({
},
[dashboardParams, onSaveSearch]
);
const handleUpdateEditInPlaceWithParams = useCallback(async () => {
await onUpdateEditInPlace?.(dashboardParams);
}, [dashboardParams, onUpdateEditInPlace]);
const checkoutReturnPath = useMemo(
() => `/dashboard${dashboardParams ? `?${dashboardParams}` : ''}`,
[dashboardParams]
@ -543,7 +550,6 @@ export default function MapPage({
loading={loadingAreaStats}
hexagonId={selectedHexagon?.id || null}
isPostcode={selectedHexagon?.type === 'postcode'}
onViewProperties={handleViewPropertiesFromArea}
hexagonLocation={hexagonLocation}
filters={filters}
unfilteredCount={unfilteredAreaCount}
@ -621,6 +627,11 @@ export default function MapPage({
onClearAll={handleClearAll}
onSaveSearch={onSaveSearch ? handleSaveSearch : undefined}
savingSearch={savingSearch}
editingSearchName={editingSearch?.name ?? null}
onUpdateSearch={
editingSearch && onUpdateEditInPlace ? handleUpdateEditInPlaceWithParams : undefined
}
onExitEditing={onCancelEdit}
destinationDropdownPortal={options?.destinationDropdownPortal}
/>
</Suspense>
@ -643,6 +654,40 @@ export default function MapPage({
/>
);
const toasts = exportToast;
const editingBar =
editingSearch && isMobile ? (
<div className="flex items-center gap-2 px-3 py-2 border-b border-warm-200 dark:border-navy-700 bg-warm-50 dark:bg-navy-900">
<span
className="flex-1 min-w-0 truncate text-xs text-warm-700 dark:text-warm-200"
title={editingSearch.name}
>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{
strong: (
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
),
}}
/>
</span>
<button
onClick={onCancelEdit}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-200 hover:bg-warm-100 dark:hover:bg-navy-800"
>
{t('common.cancel')}
</button>
<button
onClick={() => onUpdateEdit?.(dashboardParams)}
disabled={savingSearch}
className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5"
>
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
) : null;
const upgradeModal = mapData.licenseRequired ? (
<Suspense fallback={null}>
<UpgradeModal
@ -714,6 +759,7 @@ export default function MapPage({
renderPropertiesPane={renderPropertiesPane}
toasts={toasts}
upgradeModal={upgradeModal}
editingBar={editingBar}
/>
);
}

View file

@ -9,6 +9,7 @@ interface VisualViewportState {
interface MobileBottomSheetProps {
children: ReactNode;
legend?: ReactNode;
editingBar?: ReactNode;
onCoveredHeightChange?: (height: number) => void;
}
@ -104,6 +105,7 @@ function getKeyboardEditableElement(target: EventTarget | null): HTMLElement | n
export default function MobileBottomSheet({
children,
legend,
editingBar,
onCoveredHeightChange,
}: MobileBottomSheetProps) {
const [keyboardAvoidanceActive, setKeyboardAvoidanceActive] = useState(false);
@ -244,6 +246,8 @@ export default function MobileBottomSheet({
</div>
</div>
{editingBar && <div className="shrink-0">{editingBar}</div>}
{legend && (
<div className="shrink-0 border-y border-warm-200 dark:border-navy-700">{legend}</div>
)}

View file

@ -86,16 +86,17 @@ export function TravelTimeCard({
</span>
</div>
<div className="flex items-center gap-2 md:gap-0.5">
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')}>
<InfoIcon className="w-3.5 h-3.5" />
<IconButton onClick={() => setShowInfo(true)} title={t('filters.aboutData')} size="md">
<InfoIcon className="w-5 h-5 md:w-3.5 md:h-3.5" />
</IconButton>
{slug && (
<IconButton
onClick={onTogglePin}
active={isPinned || isActive}
title={isPinned ? t('filters.clearColourMap') : t('filters.colourMap')}
size="md"
>
<EyeIcon className="w-3.5 h-3.5" filled={isPinned || isActive} />
<EyeIcon className="w-5 h-5 md:w-3.5 md:h-3.5" filled={isPinned || isActive} />
</IconButton>
)}
<IconButton onClick={() => onRemove()} title={t('travel.removeTravelTime')}>

View file

@ -110,14 +110,14 @@ export function ActiveFiltersPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-b border-l-4 border-warm-200 border-l-teal-500 bg-white px-3 py-2 cursor-pointer shadow-sm hover:bg-warm-50 dark:border-navy-700 dark:border-l-teal-400 dark:bg-navy-900 dark:hover:bg-navy-800"
className="shrink-0 flex items-center justify-between border-b border-l-4 border-teal-200 border-l-teal-600 bg-teal-50 px-3 py-2.5 cursor-pointer shadow-md ring-1 ring-inset ring-teal-100 hover:bg-teal-100 dark:border-teal-900/50 dark:border-l-teal-300 dark:bg-teal-950/30 dark:ring-teal-800/60 dark:hover:bg-teal-900/40"
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.activeFilters')}
</span>
{badgeCount > 0 && (
<span className="rounded-full bg-teal-50 px-1.5 py-0.5 text-xs font-medium text-teal-700 ring-1 ring-teal-100 dark:bg-teal-900/30 dark:text-teal-300 dark:ring-teal-800">
<span className="rounded-full bg-teal-600 px-1.5 py-0.5 text-xs font-bold text-white ring-1 ring-teal-700 dark:bg-teal-300 dark:text-navy-950 dark:ring-teal-200">
{badgeCount}
</span>
)}

View file

@ -110,9 +110,9 @@ export function AddFilterPanel({
>
<button
onClick={onToggleCollapsed}
className="shrink-0 flex items-center justify-between border-b border-l-4 border-warm-200 border-l-teal-500 bg-white px-3 py-2 cursor-pointer shadow-sm hover:bg-warm-50 dark:border-navy-700 dark:border-l-teal-400 dark:bg-navy-900 dark:hover:bg-navy-800"
className="shrink-0 flex items-center justify-between border-b border-l-4 border-teal-200 border-l-teal-600 bg-teal-50 px-3 py-2.5 cursor-pointer shadow-md ring-1 ring-inset ring-teal-100 hover:bg-teal-100 dark:border-teal-900/50 dark:border-l-teal-300 dark:bg-teal-950/30 dark:ring-teal-800/60 dark:hover:bg-teal-900/40"
>
<span className="text-sm font-semibold text-navy-950 dark:text-warm-100">
<span className="text-sm font-bold text-navy-950 dark:text-warm-100">
{t('filters.addFilter')}
</span>
<ChevronIcon

View file

@ -1,5 +1,5 @@
import { useEffect, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { CloseIcon, SpinnerIcon } from '../../ui/icons';
@ -8,9 +8,11 @@ interface ClearFiltersDialogProps {
saveName: string;
saveError: string | null;
savingSearch?: boolean;
editingSearchName?: string | null;
onClose: () => void;
onSaveNameChange: (value: string) => void;
onSaveAndClear: (e: FormEvent) => void;
onUpdateAndClear?: () => void;
onClearWithoutSaving: () => void;
}
@ -19,12 +21,15 @@ export function ClearFiltersDialog({
saveName,
saveError,
savingSearch,
editingSearchName,
onClose,
onSaveNameChange,
onSaveAndClear,
onUpdateAndClear,
onClearWithoutSaving,
}: ClearFiltersDialogProps) {
const { t } = useTranslation();
const isEditing = !!editingSearchName && !!onUpdateAndClear;
useEffect(() => {
if (!open) return;
@ -55,39 +60,74 @@ export function ClearFiltersDialog({
<CloseIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={onSaveAndClear} className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
{t('filters.clearAllSavePrompt')}
</p>
<div>
<input
type="text"
value={saveName}
onChange={(e) => onSaveNameChange(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={t('saveSearch.namePlaceholder')}
autoFocus
/>
{isEditing ? (
<div className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
<Trans
i18nKey="filters.clearAllUpdatePrompt"
values={{ name: editingSearchName }}
components={{
strong: (
<strong className="font-semibold text-navy-950 dark:text-warm-100" />
),
}}
/>
</p>
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={onClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutUpdating')}
</button>
<button
type="button"
onClick={onUpdateAndClear}
disabled={savingSearch}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('filters.updateAndClear')}
</button>
</div>
</div>
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={onClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutSaving')}
</button>
<button
type="submit"
disabled={!saveName.trim() || savingSearch}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
</button>
</div>
</form>
) : (
<form onSubmit={onSaveAndClear} className="p-5 pt-2 space-y-4">
<p className="text-sm text-warm-600 dark:text-warm-400">
{t('filters.clearAllSavePrompt')}
</p>
<div>
<input
type="text"
value={saveName}
onChange={(e) => onSaveNameChange(e.target.value)}
className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500"
placeholder={t('saveSearch.namePlaceholder')}
autoFocus
/>
</div>
{saveError && <p className="text-sm text-red-600 dark:text-red-300">{saveError}</p>}
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center">
<button
type="button"
onClick={onClearWithoutSaving}
className="px-4 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 text-warm-700 dark:text-warm-300 hover:bg-warm-50 dark:hover:bg-warm-700"
>
{t('filters.clearWithoutSaving')}
</button>
<button
type="submit"
disabled={!saveName.trim() || savingSearch}
className="flex items-center justify-center gap-2 px-4 py-2 text-sm rounded bg-teal-600 text-white font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('saveSearch.saving') : t('filters.saveAndClear')}
</button>
</div>
</form>
)}
</div>
</div>
);

View file

@ -54,6 +54,7 @@ interface MobileMapPageProps {
renderPropertiesPane: () => ReactNode;
toasts: ReactNode;
upgradeModal: ReactNode;
editingBar?: ReactNode;
}
export function MobileMapPage({
@ -95,6 +96,7 @@ export function MobileMapPage({
renderPropertiesPane,
toasts,
upgradeModal,
editingBar,
}: MobileMapPageProps) {
return (
<div className="flex-1 overflow-hidden relative">
@ -154,6 +156,7 @@ export function MobileMapPage({
<MobileBottomSheet
legend={mobileLegend}
editingBar={editingBar}
onCoveredHeightChange={onBottomSheetCoveredHeightChange}
>
{filtersPane}

View file

@ -47,6 +47,10 @@ export interface MapPageProps {
deferTutorial?: boolean;
onSaveSearch?: (name: string, paramsOverride?: string) => Promise<void>;
savingSearch?: boolean;
editingSearch?: { id: string; name: string } | null;
onCancelEdit?: () => void;
onUpdateEdit?: (params: string) => Promise<void>;
onUpdateEditInPlace?: (params: string) => Promise<void>;
}
export type MapFlyTo = (lat: number, lng: number, zoom: number, options?: MapFlyToOptions) => void;

View file

@ -212,7 +212,7 @@ export function DestinationDropdown({
(portal ? (
createPortal(dropdown, document.body)
) : (
<div className="absolute top-full left-0 right-0 mt-1 z-30">{dropdown}</div>
<div className="relative z-30 mt-1">{dropdown}</div>
))}
</div>
);

View file

@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import type { AuthUser } from '../../hooks/useAuth';
import { shortenUrl, prewarmScreenshot, paramsWithLanguage } from '../../lib/api';
import { copyToClipboard } from '../../lib/clipboard';
@ -64,6 +64,11 @@ export const PAGE_PATHS: Record<Page, string> = {
const DASHBOARD_TABLET_SIDEBAR_QUERY = '(min-width: 768px) and (max-width: 1023px)';
export interface EditingSearchState {
id: string;
name: string;
}
export default function Header({
activePage,
activeHash,
@ -74,6 +79,9 @@ export default function Header({
dashboardParams,
onSaveSearch,
savingSearch,
editingSearch,
onCancelEdit,
onUpdateEdit,
user,
onLoginClick,
onRegisterClick,
@ -89,6 +97,9 @@ export default function Header({
dashboardParams: string;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
editingSearch: EditingSearchState | null;
onCancelEdit: () => void;
onUpdateEdit: () => void;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
@ -170,9 +181,38 @@ export default function Header({
: 'text-warm-300 hover:bg-navy-800 hover:text-white'
}`;
const showEditingBar = !isMobile && editingSearch && activePage === 'dashboard';
return (
<>
<header className="relative z-50 h-12 bg-navy-900 text-white flex items-center px-4 shrink-0">
{showEditingBar && (
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-0 flex items-center justify-center px-4">
<div className="pointer-events-auto flex items-center gap-3 max-w-[60%]">
<span className="text-sm text-warm-300 truncate" title={editingSearch.name}>
<Trans
i18nKey="savedPage.isBeingUpdated"
values={{ name: editingSearch.name }}
components={{ strong: <strong className="font-semibold text-white" /> }}
/>
</span>
<button
onClick={onCancelEdit}
className="cursor-pointer px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm"
>
{t('common.cancel')}
</button>
<button
onClick={onUpdateEdit}
disabled={savingSearch}
className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5"
>
{savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />}
{savingSearch ? t('savedPage.updating') : t('common.update')}
</button>
</div>
</div>
)}
{/* Left: Logo + nav */}
<div className="flex items-center gap-4">
<a
@ -261,7 +301,7 @@ export default function Header({
{exportState.exporting ? t('header.exporting') : t('header.exportLabel')}
</button>
)}
{onSaveSearch && (
{onSaveSearch && !editingSearch && (
<button
onClick={onSaveSearch}
disabled={savingSearch}
@ -369,6 +409,7 @@ export default function Header({
exportState={exportState}
onSaveSearch={onSaveSearch}
savingSearch={savingSearch}
isEditingSearch={!!editingSearch}
user={user}
onLoginClick={onLoginClick}
onRegisterClick={onRegisterClick}

View file

@ -21,6 +21,7 @@ interface MobileMenuProps {
exportState: HeaderExportState | null;
onSaveSearch: (() => void) | null;
savingSearch: boolean;
isEditingSearch: boolean;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
@ -40,6 +41,7 @@ export default function MobileMenu({
exportState,
onSaveSearch,
savingSearch,
isEditingSearch,
user,
onLoginClick,
onRegisterClick,
@ -144,7 +146,7 @@ export default function MobileMenu({
) : (
<BookmarkIcon className="w-4 h-4" />
)}
{t('common.save')}
{isEditingSearch ? t('common.update') : t('common.save')}
</button>
)}
{dashboardSavedItem}

View file

@ -126,7 +126,7 @@ describe('useMapData', () => {
});
});
it('resets the colour range to drag preview data while a slider is active', async () => {
it('resets the colour range to visible drag preview data while a slider is active', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
@ -139,16 +139,28 @@ describe('useMapData', () => {
const filters = { price: [20, 80] as [number, number] };
const { result, rerender } = renderHook(
({ activeFeature }: { activeFeature: string | null }) =>
({
activeFeature,
filterRange,
}: {
activeFeature: string | null;
filterRange: [number, number] | null;
}) =>
useMapData({
filters,
features,
viewFeature: 'price',
activeFeature,
pinnedFeature: null,
filterRange,
travelTimeEntries: noTravelTimeEntries,
}),
{ initialProps: { activeFeature: null as string | null } }
{
initialProps: {
activeFeature: null as string | null,
filterRange: filters.price,
},
}
);
await act(async () => {
@ -171,27 +183,58 @@ describe('useMapData', () => {
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
await act(async () => {
rerender({ activeFeature: 'price' });
rerender({ activeFeature: 'price', filterRange: filters.price });
await flushPromises();
});
expect(requests).toHaveLength(2);
const previewData = [
{
h3: 'preview-outside-low',
count: 1,
lat: 1.1,
lon: 1.1,
min_price: 0,
max_price: 10,
avg_price: 5,
},
{
h3: 'preview-low',
count: 1,
lat: 1.25,
lon: 1.25,
min_price: 20,
max_price: 20,
avg_price: 20,
},
{
h3: 'preview-high',
count: 1,
lat: 1.75,
lon: 1.75,
min_price: 80,
max_price: 80,
avg_price: 80,
},
{
h3: 'preview-outside-high',
count: 1,
lat: 1.9,
lon: 1.9,
min_price: 90,
max_price: 100,
avg_price: 95,
},
];
await act(async () => {
requests[1].resolve(
response([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
])
);
requests[1].resolve(response(previewData));
await flushPromises();
});
expect(result.current.data).toEqual([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
]);
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
expect(result.current.data).toEqual(previewData);
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
});
it('does not use metadata min/max while slider preview colour data is loading', async () => {
@ -270,6 +313,82 @@ describe('useMapData', () => {
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
});
it('does not use stale committed feature data while slider preview colour data is loading', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
name: 'price',
type: 'numeric',
min: 0,
max: 1_000,
},
];
const { result, rerender } = renderHook(
({
filters,
activeFeature,
}: {
filters: Record<string, [number, number]>;
activeFeature: string | null;
}) =>
useMapData({
filters,
features,
viewFeature: 'price',
activeFeature,
pinnedFeature: null,
travelTimeEntries: noTravelTimeEntries,
}),
{
initialProps: {
filters: { price: [0, 1_000] as [number, number] },
activeFeature: null as string | null,
},
}
);
await act(async () => {
result.current.handleViewChange(viewChange(bounds));
});
await act(async () => {
vi.advanceTimersByTime(150);
});
await act(async () => {
requests[0].resolve(
response([
{ h3: 'stale-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'stale-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 1_000 },
])
);
await flushPromises();
});
expect(result.current.colorRange?.[1]).toBeCloseTo(950);
await act(async () => {
rerender({
filters: { price: [20, 80] },
activeFeature: 'price',
});
await flushPromises();
});
expect(result.current.colorRange).toBeNull();
await act(async () => {
requests[1].resolve(
response([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
])
);
await flushPromises();
});
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
});
it('does not reuse cached drag preview data when the drag request changes', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [

View file

@ -45,18 +45,38 @@ interface UseMapDataOptions {
viewFeature: string | null;
activeFeature: string | null;
pinnedFeature: string | null;
filterRange?: [number, number] | null;
travelTimeEntries: TravelTimeEntry[];
/** Share-link code from the URL; appended to data fetches so the backend
* grants bbox-scoped access for unlicensed recipients. */
shareCode?: string;
}
function getFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function valueInVisibleRange(
value: number,
minValue: number | null,
maxValue: number | null,
visibleRange: [number, number] | null
): number | null {
if (!visibleRange) return value;
const itemMin = minValue ?? value;
const itemMax = maxValue ?? value;
if (itemMax < visibleRange[0] || itemMin > visibleRange[1]) return null;
return Math.max(visibleRange[0], Math.min(visibleRange[1], value));
}
export function useMapData({
filters,
features,
viewFeature,
activeFeature,
pinnedFeature,
filterRange = null,
travelTimeEntries,
shareCode,
}: UseMapDataOptions) {
@ -487,8 +507,15 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
continue;
}
const val = feat.properties[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
const val = getFiniteNumber(feat.properties[`avg_${dataViewFeature}`]);
if (val == null) continue;
const visibleValue = valueInVisibleRange(
val,
getFiniteNumber(feat.properties[`min_${dataViewFeature}`]),
getFiniteNumber(feat.properties[`max_${dataViewFeature}`]),
filterRange
);
if (visibleValue != null) vals.push(visibleValue);
}
} else {
if (data.length === 0) return null;
@ -498,8 +525,15 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
const val = getFiniteNumber(item[`avg_${dataViewFeature}`]);
if (val == null) continue;
const visibleValue = valueInVisibleRange(
val,
getFiniteNumber(item[`min_${dataViewFeature}`]),
getFiniteNumber(item[`max_${dataViewFeature}`]),
filterRange
);
if (visibleValue != null) vals.push(visibleValue);
}
}
@ -515,6 +549,7 @@ export function useMapData({
dataViewFeature,
effectivePostcodeData,
features,
filterRange,
hasCurrentRangeData,
usePostcodeView,
]);

View file

@ -176,6 +176,46 @@ export function useSavedSearches(userId: string | null) {
}
}, []);
const updateSearchParams = useCallback(
async (id: string, params: string) => {
if (!userId) return;
setSaving(true);
setError(null);
try {
const record = await pb.collection('saved_searches').update(id, { params });
trackEvent('Search Update');
setSearches((prev) =>
prev.map((s) => (s.id === id ? { ...s, params, screenshotUrl: '' } : s))
);
// Refresh screenshot in the background
const screenshotParams = new URLSearchParams(params);
const screenshotUrl = apiUrl('screenshot', screenshotParams);
fetch(screenshotUrl, authHeaders())
.then((res) => {
if (!res.ok) throw new Error(`Screenshot ${res.status}`);
return res.blob();
})
.then((blob) => {
const patch = new FormData();
patch.append('screenshot', blob, 'screenshot.jpg');
return pb.collection('saved_searches').update(record.id, patch);
})
.then(() => fetchSearches())
.catch((err) => {
console.warn('Background screenshot failed:', err);
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update search';
setError(msg);
throw err;
} finally {
setSaving(false);
}
},
[userId, fetchSearches]
);
return {
searches,
loading,
@ -186,5 +226,6 @@ export function useSavedSearches(userId: string | null) {
deleteSearch,
updateSearchNotes,
updateSearchName,
updateSearchParams,
};
}

View file

@ -4,6 +4,7 @@ const de: Translations = {
// ── Common ──────────────────────────────────────────
common: {
save: 'Speichern',
update: 'Aktualisieren',
cancel: 'Abbrechen',
close: 'Schließen',
delete: 'Löschen',
@ -677,8 +678,12 @@ const de: Translations = {
clearAll: 'Alle löschen',
clearAllTitle: 'Alle Filter löschen?',
clearAllSavePrompt: 'Möchtest du deine aktuellen Filter vor dem Löschen speichern?',
clearAllUpdatePrompt:
'<strong>{{name}}</strong> mit den aktuellen Filtern aktualisieren, bevor gelöscht wird?',
saveAndClear: 'Speichern & löschen',
updateAndClear: 'Aktualisieren & löschen',
clearWithoutSaving: 'Ohne Speichern löschen',
clearWithoutUpdating: 'Ohne Aktualisieren löschen',
filtersOut: 'filtert {{value}} heraus',
schoolType: 'Schultyp',
schoolRating: 'Schulbewertung',
@ -1280,6 +1285,8 @@ const de: Translations = {
deleteSearch: 'Suche löschen',
deleteSearchConfirm:
'Möchtest du diese gespeicherte Suche wirklich löschen? Dies kann nicht rückgängig gemacht werden.',
isBeingUpdated: '<strong>{{name}}</strong> wird aktualisiert',
updating: 'Aktualisiere...',
},
// ── Invites Page ───────────────────────────────────
@ -1378,6 +1385,7 @@ const de: Translations = {
'Property prices': 'Immobilienpreise',
Transport: 'Verkehr',
Education: 'Bildung',
'Defining characteristics': 'Prägende Merkmale',
'Area development': 'Gebietsentwicklung',
Crime: 'Kriminalität',
Neighbours: 'Nachbarn',

View file

@ -2,6 +2,7 @@ const en = {
// ── Common ──────────────────────────────────────────
common: {
save: 'Save',
update: 'Update',
cancel: 'Cancel',
close: 'Close',
delete: 'Delete',
@ -652,8 +653,11 @@ 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?',
saveAndClear: 'Save & Clear',
updateAndClear: 'Update & Clear',
clearWithoutSaving: 'Clear without saving',
clearWithoutUpdating: 'Clear without updating',
filtersOut: 'filters out {{value}}',
schoolType: 'School type',
schoolRating: 'School rating',
@ -1245,6 +1249,8 @@ const en = {
notesPlaceholder: 'Jot down your thoughts...',
deleteSearch: 'Delete search',
deleteSearchConfirm: 'Are you sure you want to delete this saved search? This cant be undone.',
isBeingUpdated: '<strong>{{name}}</strong> is being updated',
updating: 'Updating...',
},
// ── Invites Page ───────────────────────────────────
@ -1342,6 +1348,7 @@ const en = {
'Property prices': 'Property prices',
Transport: 'Transport',
Education: 'Education',
'Defining characteristics': 'Defining characteristics',
'Area development': 'Area development',
Crime: 'Crime',
Neighbours: 'Neighbours',

View file

@ -4,6 +4,7 @@ const fr: Translations = {
// ── Common ──────────────────────────────────────────
common: {
save: 'Enregistrer',
update: 'Mettre à jour',
cancel: 'Annuler',
close: 'Fermer',
delete: 'Supprimer',
@ -681,8 +682,12 @@ const fr: Translations = {
clearAll: 'Tout effacer',
clearAllTitle: 'Effacer tous les filtres ?',
clearAllSavePrompt: 'Souhaitez-vous sauvegarder vos filtres actuels avant de les effacer ?',
clearAllUpdatePrompt:
'Mettre à jour <strong>{{name}}</strong> avec vos filtres actuels avant deffacer ?',
saveAndClear: 'Sauvegarder et effacer',
updateAndClear: 'Mettre à jour et effacer',
clearWithoutSaving: 'Effacer sans sauvegarder',
clearWithoutUpdating: 'Effacer sans mettre à jour',
filtersOut: 'exclut {{value}}',
schoolType: 'Type décole',
schoolRating: 'Note de lécole',
@ -1286,6 +1291,8 @@ const fr: Translations = {
deleteSearch: 'Supprimer la recherche',
deleteSearchConfirm:
'Êtes-vous sûr de vouloir supprimer cette recherche enregistrée ? Cette action est irréversible.',
isBeingUpdated: 'Mise à jour de <strong>{{name}}</strong>',
updating: 'Mise à jour...',
},
// ── Invites Page ───────────────────────────────────
@ -1384,6 +1391,7 @@ const fr: Translations = {
'Property prices': 'Prix immobiliers',
Transport: 'Transports',
Education: 'Éducation',
'Defining characteristics': 'Caractéristiques déterminantes',
'Area development': 'Développement du quartier',
Crime: 'Criminalité',
Neighbours: 'Voisins',

View file

@ -3,6 +3,7 @@ import type { Translations } from './en';
const hi: Translations = {
common: {
save: 'सहेजें',
update: 'अपडेट करें',
cancel: 'रद्द करें',
close: 'बंद करें',
delete: 'हटाएं',
@ -649,8 +650,12 @@ const hi: Translations = {
clearAll: 'सभी साफ करें',
clearAllTitle: 'सभी फिल्टर साफ करें?',
clearAllSavePrompt: 'क्या साफ करने से पहले आप अपने मौजूदा फिल्टर सहेजना चाहेंगे?',
clearAllUpdatePrompt:
'साफ करने से पहले <strong>{{name}}</strong> को अपने मौजूदा फिल्टर के साथ अपडेट करें?',
saveAndClear: 'सहेजें और साफ करें',
updateAndClear: 'अपडेट करें और साफ करें',
clearWithoutSaving: 'बिना सहेजे साफ करें',
clearWithoutUpdating: 'बिना अपडेट किए साफ करें',
filtersOut: '{{value}} को फिल्टर करता है',
schoolType: 'स्कूल प्रकार',
schoolRating: 'स्कूल रेटिंग',
@ -1210,6 +1215,8 @@ const hi: Translations = {
deleteSearch: 'खोज हटाएं',
deleteSearchConfirm:
'क्या आप वाकई यह सहेजी गई खोज हटाना चाहते हैं? इसे वापस नहीं किया जा सकता.',
isBeingUpdated: '<strong>{{name}}</strong> अपडेट हो रहा है',
updating: 'अपडेट हो रहा है...',
},
invitesPage: {
@ -1299,6 +1306,7 @@ const hi: Translations = {
'Property prices': 'संपत्ति कीमतें',
Transport: 'परिवहन',
Education: 'शिक्षा',
'Defining characteristics': 'मुख्य विशेषताएं',
'Area development': 'क्षेत्र विकास',
Crime: 'अपराध',
Neighbours: 'पड़ोसी',

View file

@ -4,6 +4,7 @@ const hu: Translations = {
// ── Common ──────────────────────────────────────────
common: {
save: 'Mentés',
update: 'Frissítés',
cancel: 'Mégse',
close: 'Bezárás',
delete: 'Törlés',
@ -665,8 +666,12 @@ const hu: Translations = {
clearAll: 'Összes törlése',
clearAllTitle: 'Összes szűrő törlése?',
clearAllSavePrompt: 'Szeretnéd menteni a jelenlegi szűrőket a törlés előtt?',
clearAllUpdatePrompt:
'Frissíted a(z) <strong>{{name}}</strong> keresést a jelenlegi szűrőkkel törlés előtt?',
saveAndClear: 'Mentés és törlés',
updateAndClear: 'Frissítés és törlés',
clearWithoutSaving: 'Törlés mentés nélkül',
clearWithoutUpdating: 'Törlés frissítés nélkül',
filtersOut: '{{value}} elemet kiszűr',
schoolType: 'Iskolatípus',
schoolRating: 'Iskolai értékelés',
@ -1264,6 +1269,8 @@ const hu: Translations = {
deleteSearch: 'Keresés törlése',
deleteSearchConfirm:
'Biztosan törölni szeretnéd ezt a mentett keresést? Ez nem vonható vissza.',
isBeingUpdated: '<strong>{{name}}</strong> frissítése folyamatban',
updating: 'Frissítés...',
},
// ── Invites Page ───────────────────────────────────
@ -1363,6 +1370,7 @@ const hu: Translations = {
'Property prices': 'Ingatlanárak',
Transport: 'Közlekedés',
Education: 'Oktatás',
'Defining characteristics': 'Meghatározó jellemzők',
'Area development': 'Területi fejlődés',
Crime: 'Bűnözés',
Neighbours: 'Szomszédok',

View file

@ -4,6 +4,7 @@ const zh: Translations = {
// ── Common ──────────────────────────────────────────
common: {
save: '保存',
update: '更新',
cancel: '取消',
close: '关闭',
delete: '删除',
@ -616,8 +617,11 @@ const zh: Translations = {
clearAll: '全部清除',
clearAllTitle: '清除所有筛选条件?',
clearAllSavePrompt: '是否要在清除前保存当前的筛选条件?',
clearAllUpdatePrompt: '在清除前使用当前筛选条件更新 <strong>{{name}}</strong>',
saveAndClear: '保存并清除',
updateAndClear: '更新并清除',
clearWithoutSaving: '不保存直接清除',
clearWithoutUpdating: '不更新直接清除',
filtersOut: '筛除 {{value}}',
schoolType: '学校类型',
schoolRating: '学校评级',
@ -1195,6 +1199,8 @@ const zh: Translations = {
notesPlaceholder: '记下您的想法...',
deleteSearch: '删除搜索',
deleteSearchConfirm: '确定要删除这个保存的搜索吗?此操作无法撤销。',
isBeingUpdated: '正在更新 <strong>{{name}}</strong>',
updating: '更新中...',
},
// ── Invites Page ───────────────────────────────────
@ -1290,6 +1296,7 @@ const zh: Translations = {
'Property prices': '房价',
Transport: '交通',
Education: '教育',
'Defining characteristics': '主要特征',
'Area development': '区域发展',
Crime: '犯罪',
Neighbours: '邻居',

View file

@ -9,6 +9,7 @@
<title>Perfect Postcode - Find where to buy before browsing listings</title>
<meta name="description" content="Filter every postcode in England by budget, commute, schools, crime, noise, broadband, property prices and amenities before you start chasing viewings." />
<meta name="x-og-placeholder" content="__PERFECT_POSTCODE_OG_TAGS__" />
<script id="perfect-postcode-bugsink-config" type="application/json">__PERFECT_POSTCODE_BUGSINK_CONFIG__</script>
<script>
(function() {
var theme = localStorage.getItem('theme');

View file

@ -1,15 +1,29 @@
import { createRoot } from 'react-dom/client';
import App from './App';
import { i18nReady } from './i18n';
import { BugsinkErrorBoundary, initBugsink } from './lib/bugsink';
import './index.css';
import './hooks/usePlausible';
initBugsink();
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
const root = container;
function AppErrorFallback() {
return (
<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>
</div>
</div>
);
}
function renderApp() {
const hasPrerenderedMarkup = root.children.length > 0;
@ -18,7 +32,11 @@ function renderApp() {
}
root.removeAttribute('data-prerender-path');
createRoot(root).render(<App />);
createRoot(root).render(
<BugsinkErrorBoundary fallback={<AppErrorFallback />}>
<App />
</BugsinkErrorBoundary>
);
}
void i18nReady.then(renderApp);

View file

@ -0,0 +1,100 @@
import * as Sentry from '@sentry/react';
import type { ReactElement, ReactNode } from 'react';
declare const __BUGSINK_DSN__: string | undefined;
declare const __BUGSINK_ENVIRONMENT__: string | undefined;
declare const __BUGSINK_RELEASE__: string | undefined;
declare const __BUGSINK_SEND_DEFAULT_PII__: boolean | undefined;
interface BugsinkConfig {
dsn?: string;
environment?: string;
release?: string;
sendDefaultPii?: boolean;
}
declare global {
interface Window {
__PERFECT_POSTCODE_BUGSINK__?: BugsinkConfig;
}
}
function nonempty(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function readBuildTimeString(value: unknown): string | undefined {
return nonempty(value);
}
function readBuildTimeBoolean(value: unknown): boolean {
return typeof value === 'boolean' ? value : false;
}
function readRuntimeConfig(): BugsinkConfig {
if (typeof document === 'undefined') {
return {};
}
const element = document.getElementById('perfect-postcode-bugsink-config');
const json = element?.textContent?.trim();
if (!json || json === '__PERFECT_POSTCODE_BUGSINK_CONFIG__') {
return window.__PERFECT_POSTCODE_BUGSINK__ ?? {};
}
try {
const config = JSON.parse(json) as BugsinkConfig;
window.__PERFECT_POSTCODE_BUGSINK__ = config;
return config;
} catch {
return window.__PERFECT_POSTCODE_BUGSINK__ ?? {};
}
}
export function initBugsink(): boolean {
const runtimeConfig = readRuntimeConfig();
const dsn =
nonempty(runtimeConfig.dsn) ??
readBuildTimeString(typeof __BUGSINK_DSN__ === 'string' ? __BUGSINK_DSN__ : undefined);
if (!dsn) {
return false;
}
Sentry.init({
dsn,
environment:
nonempty(runtimeConfig.environment) ??
readBuildTimeString(
typeof __BUGSINK_ENVIRONMENT__ === 'string' ? __BUGSINK_ENVIRONMENT__ : undefined
),
release:
nonempty(runtimeConfig.release) ??
readBuildTimeString(typeof __BUGSINK_RELEASE__ === 'string' ? __BUGSINK_RELEASE__ : undefined),
sendDefaultPii:
runtimeConfig.sendDefaultPii ??
readBuildTimeBoolean(
typeof __BUGSINK_SEND_DEFAULT_PII__ === 'boolean'
? __BUGSINK_SEND_DEFAULT_PII__
: undefined
),
tracesSampleRate: 0,
});
Sentry.setTag('app', 'frontend');
return true;
}
export function BugsinkErrorBoundary({
children,
fallback,
}: {
children: ReactNode;
fallback: ReactElement;
}) {
return <Sentry.ErrorBoundary fallback={fallback}>{children}</Sentry.ErrorBoundary>;
}

View file

@ -16,6 +16,8 @@ const GROUP_ICONS: Record<string, ComponentType<{ className?: string }>> = {
'Property prices': TagIcon,
Transport: RouteIcon,
Education: GraduationCapIcon,
Schools: GraduationCapIcon,
'Defining characteristics': TreeIcon,
'Area development': ChartBarIcon,
Crime: ShieldIcon,
Neighbours: UsersIcon,

View file

@ -197,7 +197,7 @@ export function getSchoolFilterMeta(features: FeatureMeta[]): FeatureMeta {
return {
name: SCHOOL_FILTER_NAME,
type: 'numeric',
group: 'Education',
group: 'Schools',
min: sourceFeature?.min ?? 0,
max: sourceFeature?.max ?? 10,
step: 1,

View file

@ -6,11 +6,36 @@ const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'
const FaviconsWebpackPlugin = require('favicons-webpack-plugin');
const sharp = require('sharp');
const webpack = require('webpack');
const packageJson = require('./package.json');
const HOUSE_IMAGE_WIDTH = 260;
function envString(...names) {
for (const name of names) {
const value = process.env[name];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return undefined;
}
function envBoolean(name, fallback = false) {
const value = process.env[name];
if (typeof value !== 'string' || value.trim().length === 0) {
return fallback;
}
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
}
module.exports = (env, argv) => {
const isProduction = argv.mode === 'production';
const bugsinkEnvironment =
envString('FRONTEND_BUGSINK_ENVIRONMENT', 'BUGSINK_ENVIRONMENT', 'SENTRY_ENVIRONMENT') ||
(isProduction ? 'production' : 'development');
const bugsinkRelease =
envString('FRONTEND_BUGSINK_RELEASE', 'BUGSINK_RELEASE', 'SENTRY_RELEASE') ||
`${packageJson.name}@${packageJson.version}`;
return {
entry: './src/index.tsx',
@ -22,6 +47,7 @@ module.exports = (env, argv) => {
publicPath: '/',
},
devtool: isProduction ? 'hidden-source-map' : 'eval-cheap-module-source-map',
resolve: {
extensions: ['.ts', '.tsx', '.js'],
},
@ -62,6 +88,14 @@ module.exports = (env, argv) => {
plugins: [
new webpack.DefinePlugin({
__DEV__: JSON.stringify(!isProduction),
__BUGSINK_DSN__: JSON.stringify(
envString('FRONTEND_BUGSINK_DSN', 'PUBLIC_BUGSINK_DSN', 'BUGSINK_DSN') || ''
),
__BUGSINK_ENVIRONMENT__: JSON.stringify(bugsinkEnvironment),
__BUGSINK_RELEASE__: JSON.stringify(bugsinkRelease),
__BUGSINK_SEND_DEFAULT_PII__: JSON.stringify(
envBoolean('BUGSINK_SEND_DEFAULT_PII', false)
),
}),
new HtmlWebpackPlugin({
template: './src/index.html',