From e8345cbdc1e2880eae6f19576a2c8059b6111833 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 31 May 2026 20:20:41 +0100 Subject: [PATCH] improve --- Makefile.data | 12 +- frontend/src/App.tsx | 28 +- .../src/components/account/AccountPage.tsx | 29 +- .../components/map/LocationSearch.test.tsx | 92 ++- .../src/components/map/LocationSearch.tsx | 2 + frontend/src/components/map/MapPage.tsx | 22 +- frontend/src/components/map/POIPane.tsx | 2 +- .../map/map-page/DesktopMapPage.tsx | 5 +- .../components/map/map-page/MobileMapPage.tsx | 12 +- frontend/src/components/map/map-page/types.ts | 1 + frontend/src/components/ui/AuthModal.tsx | 21 +- frontend/src/components/ui/Header.tsx | 43 +- frontend/src/components/ui/MobileMenu.tsx | 14 +- frontend/src/components/ui/PillGroup.tsx | 9 +- .../src/components/ui/PlaceSearchInput.tsx | 126 ++-- frontend/src/components/ui/SearchInput.tsx | 13 +- frontend/src/hooks/useListingLayers.ts | 2 + frontend/src/hooks/useLocationSearch.ts | 29 +- frontend/src/i18n/locales/de.ts | 1 + frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/fr.ts | 1 + frontend/src/i18n/locales/hi.ts | 1 + frontend/src/i18n/locales/hu.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + pipeline/test_validate_outputs.py | 119 +++- pipeline/transform/crime_hotspot_tiles.py | 34 +- pipeline/transform/crime_spatial.py | 142 ++++- pipeline/transform/merge.py | 518 +++++++++------- .../transform/postcode_boundaries/output.py | 88 +-- .../test_postcode_boundaries.py | 97 ++- .../transform/postcode_boundaries/uprn.py | 41 +- .../transform/test_crime_hotspot_tiles.py | 52 ++ pipeline/transform/test_crime_spatial.py | 138 ++++- pipeline/transform/test_merge.py | 123 +++- pipeline/transform/test_tree_density.py | 262 +++++--- pipeline/transform/tree_density.py | 559 ++++++------------ pipeline/transform/tree_overlay_tiles.py | 7 +- pipeline/utils/postcode_mapping.py | 6 +- pipeline/validate_outputs.py | 204 ++++++- server-rs/src/routes/stats.rs | 26 +- 40 files changed, 1980 insertions(+), 904 deletions(-) create mode 100644 pipeline/transform/test_crime_hotspot_tiles.py diff --git a/Makefile.data b/Makefile.data index 4d41cbd..7152451 100644 --- a/Makefile.data +++ b/Makefile.data @@ -64,8 +64,6 @@ PBF := $(DATA_DIR)/england-latest.osm.pbf FR_TOW := $(DATA_DIR)/FR_TOW_V1_ALL.zip NFI := $(DATA_DIR)/NFI_WOODLAND_ENGLAND.zip TREE_DENSITY_PC := $(DATA_DIR)/tree_density_by_postcode.parquet -TREE_DENSITY_STREETS := $(DATA_DIR)/tree_density_by_street.parquet -TREE_DENSITY_ADDR := $(DATA_DIR)/tree_density_by_address.parquet OFS_REGISTER := $(DATA_DIR)/ofs_register.xlsx PLACES := $(DATA_DIR)/places.parquet MEDIAN_AGE := $(DATA_DIR)/median_age.parquet @@ -183,6 +181,7 @@ $(PC_BOUNDARIES_STAMP): $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP) $(ARCGI --oa-boundaries $(OA_BOUNDARIES) \ --inspire $(INSPIRE_DIR) \ --output $(PC_BOUNDARIES) + $(VALIDATE_OUTPUTS) --active-postcode-boundary-match "$(ARCGIS)::$(PC_BOUNDARIES)" @touch $@ generate-travel-times: $(ARCGIS) $(PLACES) $(PBF) download-transit-network @if [ -f "$(R5_NETWORK_CACHE)" ] && { [ "$(PBF)" -nt "$(R5_NETWORK_CACHE)" ] || [ "$(TRANSIT_STAMP)" -nt "$(R5_NETWORK_CACHE)" ]; }; then \ @@ -358,7 +357,7 @@ $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(OFSTE $(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@ -$(CRIME) $(CRIME_BY_YEAR) &: $(CRIME_STAMP) $(PC_BOUNDARIES) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py +$(CRIME) $(CRIME_BY_YEAR) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py $(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv" uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR) @@ -368,15 +367,12 @@ $(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DE $(SCHOOL_PROX): $(OFSTED) $(ARCGIS) pipeline/transform/school_proximity.py pipeline/utils/poi_counts.py uv run python -m pipeline.transform.school_proximity --ofsted $(OFSTED) --arcgis $(ARCGIS) --output $@ -$(TREE_DENSITY_PC): $(FR_TOW) $(NFI) $(ARCGIS) $(PRICE_PAID) $(TREE_DENSITY_DEPS) +$(TREE_DENSITY_PC): $(FR_TOW) $(NFI) $(ARCGIS) $(TREE_DENSITY_DEPS) uv run python -m pipeline.transform.tree_density \ --tow-zip $(FR_TOW) \ --nfi-zip $(NFI) \ --arcgis $(ARCGIS) \ - --price-paid $(PRICE_PAID) \ - --output-postcodes $(TREE_DENSITY_PC) \ - --output-streets $(TREE_DENSITY_STREETS) \ - --output-addresses $(TREE_DENSITY_ADDR) + --output-postcodes $(TREE_DENSITY_PC) # Postcode boundaries require manual generation — fail with instructions $(PC_BOUNDARIES): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6865f07..b3a23be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -81,6 +81,15 @@ function isProtectedPage(page: Page): boolean { return page === 'account' || page === 'saved'; } +function isSharedDashboardUrl(): boolean { + const share = new URLSearchParams(window.location.search).get('share'); + return !!share && /^[a-z0-9]{1,20}$/i.test(share); +} + +function isAuthRequiredRoute(page: Page): boolean { + return isProtectedPage(page) || (page === 'dashboard' && !isSharedDashboardUrl()); +} + function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string { const normalizedHash = normalizeHash(hash); return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`; @@ -235,6 +244,7 @@ export default function App() { const postAuthCheckoutReturnPathRef = useRef(null); const authCompletedRef = useRef(false); const [licenseSuccessStatus, setLicenseSuccessStatus] = useState('hidden'); + const [dashboardReady, setDashboardReady] = useState(false); // 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. @@ -266,7 +276,7 @@ export default function App() { if (!completed) { setPostAuthIntent(null); postAuthCheckoutReturnPathRef.current = null; - if (isProtectedPage(activePageRef.current)) { + if (isAuthRequiredRoute(activePageRef.current)) { window.history.replaceState({ page: 'home', hash: '' }, '', '/'); setRouteHash(''); setActivePage('home'); @@ -517,7 +527,10 @@ export default function App() { } }, [activePage, fetchSearches]); - const isAuthRequiredPage = activePage === 'account' || activePage === 'saved'; + const isAuthRequiredPage = + activePage === 'account' || + activePage === 'saved' || + (activePage === 'dashboard' && !mapUrlState.share); useEffect(() => { if (authLoading) return; if (isAuthRequiredPage && !user) { @@ -530,6 +543,13 @@ export default function App() { const [exportState, setExportState] = useState(null); + useEffect(() => { + if (activePage !== 'dashboard' || !user) { + setDashboardReady(false); + setExportState(null); + } + }, [activePage, user]); + if ((isScreenshotMode || isOgMode) && inviteCode) { return ( }> @@ -584,8 +604,9 @@ export default function App() { onPageChange={navigateTo} theme={theme} onToggleTheme={toggleTheme} - exportState={activePage === 'dashboard' ? exportState : null} + exportState={activePage === 'dashboard' && user ? exportState : null} dashboardParams={activePage === 'dashboard' ? dashboardParams : ''} + dashboardActionsDisabled={activePage === 'dashboard' && !dashboardReady} onSaveSearch={ activePage === 'dashboard' && user ? editingSearch @@ -675,6 +696,7 @@ export default function App() { onNavigateTo={navigateTo} onExportStateChange={setExportState} onDashboardParamsChange={setDashboardParams} + onDashboardReadyChange={setDashboardReady} isMobile={isMobile} initialTravelTime={mapUrlState.travelTime} initialPostcode={mapUrlState.postcode} diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index a14113e..0c18e12 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -461,6 +461,24 @@ interface ShareLinkListItem { created: string; } +function latestPendingInviteUrls(invites: InviteListItem[]): Record { + const latestByType: Record = {}; + + for (const invite of invites) { + if (invite.used || !invite.url) continue; + + const createdMs = Date.parse(invite.created) || 0; + const existing = latestByType[invite.invite_type]; + if (!existing || createdMs > existing.createdMs) { + latestByType[invite.invite_type] = { url: invite.url, createdMs }; + } + } + + return Object.fromEntries( + Object.entries(latestByType).map(([type, invite]) => [type, invite.url]) + ); +} + function InviteTable({ invites, loading, @@ -673,7 +691,16 @@ function InviteSection({ user }: { user: AuthUser }) { const res = await fetch(apiUrl('invites'), authHeaders()); assertOk(res, 'Fetch invites'); const data = await res.json(); - setInviteHistory(data.invites); + const invites: InviteListItem[] = Array.isArray(data.invites) ? data.invites : []; + setInviteHistory(invites); + const pendingInviteUrls = latestPendingInviteUrls(invites); + setInviteUrl((prev) => { + const next = { ...prev }; + for (const [type, url] of Object.entries(pendingInviteUrls)) { + if (!next[type]) next[type] = url; + } + return next; + }); } catch { // Silent — non-critical } finally { diff --git a/frontend/src/components/map/LocationSearch.test.tsx b/frontend/src/components/map/LocationSearch.test.tsx index 5f871a3..0c45f17 100644 --- a/frontend/src/components/map/LocationSearch.test.tsx +++ b/frontend/src/components/map/LocationSearch.test.tsx @@ -8,8 +8,11 @@ const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => - key === 'locationSearch.placeholder' ? 'Search places or postcodes...' : key, + t: (key: string) => { + if (key === 'locationSearch.placeholder') return 'Search places or postcodes...'; + if (key === 'locationSearch.noResults') return 'No matching places or postcodes'; + return key; + }, }), })); @@ -226,6 +229,91 @@ describe('LocationSearch', () => { ); }); + it('selects the first place suggestion with Enter when none is highlighted', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: string | URL | Request) => { + const url = new URL(String(input), 'http://localhost'); + if (url.pathname === '/api/places') { + return Promise.resolve( + jsonResponse({ + places: [ + { + type: 'place', + name: 'London', + slug: 'london', + place_type: 'city', + lat: 51.5074, + lon: -0.1278, + }, + ], + postcodes: [], + addresses: [], + }) + ); + } + if (url.pathname === '/api/nearest-postcode') { + return Promise.resolve( + jsonResponse({ + postcode: 'SW1A 1AA', + latitude: 51.501, + longitude: -0.141, + geometry: postcodeGeometry, + }) + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) + ); + + const onFlyTo = vi.fn(); + const onLocationSearched = vi.fn(); + render(); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'London' } }); + + await screen.findByRole('button', { name: 'London' }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(onLocationSearched).toHaveBeenCalledTimes(1); + }); + expect(onFlyTo).toHaveBeenCalledWith(51.5074, -0.1278, 10); + expect(onLocationSearched).toHaveBeenCalledWith({ + postcode: 'SW1A 1AA', + geometry: postcodeGeometry, + latitude: 51.501, + longitude: -0.141, + zoom: 10, + markerLatitude: 51.5074, + markerLongitude: -0.1278, + }); + }); + + it('shows an empty state for invalid place queries', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + jsonResponse({ + places: [], + postcodes: [], + addresses: [], + }) + ) + ) + ); + + render(); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: '!!!!zzzzzz!!!!' } }); + + await waitFor(() => { + expect(screen.getByText('No matching places or postcodes')).toBeTruthy(); + }); + }); + it('keeps only the three most recent local searches', async () => { vi.stubGlobal( 'fetch', diff --git a/frontend/src/components/map/LocationSearch.tsx b/frontend/src/components/map/LocationSearch.tsx index d8adbd3..e5a3c16 100644 --- a/frontend/src/components/map/LocationSearch.tsx +++ b/frontend/src/components/map/LocationSearch.tsx @@ -333,6 +333,8 @@ export default function LocationSearch({ onSelect={selectResult} loading={loading} placeholder={t('locationSearch.placeholder')} + ariaLabel={t('locationSearch.searchLabel')} + name="location-search" size="sm" inputClassName={ inputClassName ?? diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index ca43d52..3d7df16 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -91,6 +91,7 @@ export default function MapPage({ onNavigateTo, onExportStateChange, onDashboardParamsChange, + onDashboardReadyChange, screenshotMode, ogMode, isMobile = false, @@ -642,6 +643,23 @@ export default function MapPage({ onDashboardParamsChange?.(dashboardParams); }, [dashboardParams, onDashboardParamsChange]); + const dashboardReady = + !initialLoading && + !mapData.loading && + !mapData.licenseRequired && + mapData.bounds != null && + mapData.currentView != null; + + useEffect(() => { + onDashboardReadyChange?.(dashboardReady); + }, [dashboardReady, onDashboardReadyChange]); + + useEffect(() => { + return () => { + onDashboardReadyChange?.(false); + }; + }, [onDashboardReadyChange]); + useEffect(() => { if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown'); }, [mapData.licenseRequired]); @@ -830,8 +848,8 @@ export default function MapPage({ diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx index 472aa03..b4ecc2f 100644 --- a/frontend/src/components/map/POIPane.tsx +++ b/frontend/src/components/map/POIPane.tsx @@ -186,7 +186,7 @@ export default function POIPane({ {!isCollapsed && (
- + {group.categories.map((category) => { const logo = getPoiCategoryLogoUrl(category); return ( diff --git a/frontend/src/components/map/map-page/DesktopMapPage.tsx b/frontend/src/components/map/map-page/DesktopMapPage.tsx index 4f37e3a..3f6bc08 100644 --- a/frontend/src/components/map/map-page/DesktopMapPage.tsx +++ b/frontend/src/components/map/map-page/DesktopMapPage.tsx @@ -269,7 +269,10 @@ export function DesktopMapPage({
)} {poiPaneOpen && ( -
+
{poiPane}
)} diff --git a/frontend/src/components/map/map-page/MobileMapPage.tsx b/frontend/src/components/map/map-page/MobileMapPage.tsx index 845f531..40b8114 100644 --- a/frontend/src/components/map/map-page/MobileMapPage.tsx +++ b/frontend/src/components/map/map-page/MobileMapPage.tsx @@ -132,6 +132,10 @@ export function MobileMapPage({ upgradeModal, editingBar, }: MobileMapPageProps) { + const floatingPaneAvailableHeight = `max(12rem, calc(100dvh - ${Math.ceil( + bottomScreenInset + )}px - 7rem))`; + return (
@@ -219,7 +223,13 @@ export function MobileMapPage({ )} {poiPaneOpen && ( -
+
{poiPane}
)} diff --git a/frontend/src/components/map/map-page/types.ts b/frontend/src/components/map/map-page/types.ts index 23a841a..51e0335 100644 --- a/frontend/src/components/map/map-page/types.ts +++ b/frontend/src/components/map/map-page/types.ts @@ -39,6 +39,7 @@ export interface MapPageProps { onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void; onExportStateChange?: (state: ExportState) => void; onDashboardParamsChange?: (params: string) => void; + onDashboardReadyChange?: (ready: boolean) => void; screenshotMode?: boolean; ogMode?: boolean; isMobile?: boolean; diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx index 2c8577c..b4ce606 100644 --- a/frontend/src/components/ui/AuthModal.tsx +++ b/frontend/src/components/ui/AuthModal.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useId } from 'react'; import { useTranslation } from 'react-i18next'; import { CloseIcon } from './icons/CloseIcon'; import { GoogleIcon } from './icons/GoogleIcon'; @@ -36,6 +36,9 @@ export default function AuthModal({ const [password, setPassword] = useState(''); const [resetSent, setResetSent] = useState(false); const dialogRef = useModalA11y(); + const fieldId = useId(); + const emailInputId = `${fieldId}-email`; + const passwordInputId = `${fieldId}-password`; useEffect(() => { trackEvent('Auth Modal Open', { tab: initialTab }); @@ -194,14 +197,20 @@ export default function AuthModal({ {/* Email form */}
-