Small fixes
Some checks failed
CI / Python (lint + test) (push) Failing after 1m42s
CI / Frontend (lint + typecheck) (push) Failing after 1m45s
CI / Rust (lint + test) (push) Successful in 4m45s
Build and publish Docker image / build-and-push (push) Failing after 6m21s

This commit is contained in:
Andras Schmelczer 2026-03-26 07:55:13 +00:00
parent d56b5dedff
commit d93beb9201
7 changed files with 95 additions and 42 deletions

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import MapPage, { type ExportState } from './components/map/MapPage'; import MapPage, { type ExportState } from './components/map/MapPage';
import PricingPage from './components/pricing/PricingPage'; import PricingPage from './components/pricing/PricingPage';
import HomePage from './components/home/HomePage'; import HomePage from './components/home/HomePage';
@ -67,9 +67,14 @@ function pathToPage(pathname: string): { page: Page; inviteCode?: string } | nul
export default function App() { export default function App() {
const urlState = useMemo(() => parseUrlState(), []); const urlState = useMemo(() => parseUrlState(), []);
const [mapUrlState, setMapUrlState] = useState(urlState);
const dashboardSearchRef = useRef(
window.location.pathname === '/dashboard' ? window.location.search : ''
);
const activePageRef = useRef<Page>('home');
const initialViewState = useMemo( const initialViewState = useMemo(
() => urlState.viewState || INITIAL_VIEW_STATE, () => mapUrlState.viewState || INITIAL_VIEW_STATE,
[urlState.viewState] [mapUrlState.viewState]
); );
const isScreenshotMode = useMemo(() => { const isScreenshotMode = useMemo(() => {
@ -179,17 +184,30 @@ export default function App() {
const navigateTo = useCallback( const navigateTo = useCallback(
(page: Page, hash?: string, infoFeature?: string) => { (page: Page, hash?: string, infoFeature?: string) => {
// Save dashboard search params before navigating away
if (activePageRef.current === 'dashboard') {
dashboardSearchRef.current = window.location.search;
}
if (infoFeature) { if (infoFeature) {
window.history.replaceState({ ...window.history.state, infoFeature }, ''); window.history.replaceState({ ...window.history.state, infoFeature }, '');
} }
const path = pageToPath(page, inviteCode ?? undefined); const path = pageToPath(page, inviteCode ?? undefined);
const url = hash ? `${path}#${hash}` : path; // Restore dashboard search params when navigating back
const search = page === 'dashboard' ? dashboardSearchRef.current : '';
const url = hash ? `${path}${search}#${hash}` : `${path}${search}`;
window.history.pushState({ page }, '', url); window.history.pushState({ page }, '', url);
if (page === 'dashboard') {
setMapUrlState(parseUrlState());
}
setActivePage(page); setActivePage(page);
}, },
[inviteCode] [inviteCode]
); );
useEffect(() => {
activePageRef.current = activePage;
}, [activePage]);
useEffect(() => { useEffect(() => {
if (!window.history.state?.page) { if (!window.history.state?.page) {
window.history.replaceState( window.history.replaceState(
@ -199,17 +217,24 @@ export default function App() {
); );
} }
const handlePopState = (e: PopStateEvent) => { const handlePopState = (e: PopStateEvent) => {
let page: Page;
if (e.state?.page) { if (e.state?.page) {
setActivePage(e.state.page); page = e.state.page;
setActivePage(page);
if (e.state.infoFeature) { if (e.state.infoFeature) {
setPendingInfoFeature(e.state.infoFeature); setPendingInfoFeature(e.state.infoFeature);
} }
} else { } else {
// Fall back to deriving page from pathname // Fall back to deriving page from pathname
const parsed = pathToPage(window.location.pathname); const parsed = pathToPage(window.location.pathname);
setActivePage(parsed?.page || 'home'); page = parsed?.page || 'home';
setActivePage(page);
if (parsed?.inviteCode) setInviteCode(parsed.inviteCode); if (parsed?.inviteCode) setInviteCode(parsed.inviteCode);
} }
// Re-parse URL state when returning to dashboard via back/forward
if (page === 'dashboard') {
setMapUrlState(parseUrlState());
}
}; };
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState);
@ -367,10 +392,10 @@ export default function App() {
<MapPage <MapPage
features={features} features={features}
poiCategoryGroups={poiCategoryGroups} poiCategoryGroups={poiCategoryGroups}
initialFilters={urlState.filters || { 'Listing status': ['Historical sale'] }} initialFilters={mapUrlState.filters || { 'Listing status': ['Historical sale'] }}
initialViewState={initialViewState} initialViewState={initialViewState}
initialPOICategories={urlState.poiCategories || new Set()} initialPOICategories={mapUrlState.poiCategories || new Set()}
initialTab={urlState.tab || 'area'} initialTab={mapUrlState.tab || 'area'}
initialLoading={initialLoading} initialLoading={initialLoading}
theme={theme} theme={theme}
pendingInfoFeature={pendingInfoFeature} pendingInfoFeature={pendingInfoFeature}
@ -378,8 +403,8 @@ export default function App() {
onNavigateTo={navigateTo} onNavigateTo={navigateTo}
onExportStateChange={setExportState} onExportStateChange={setExportState}
isMobile={isMobile} isMobile={isMobile}
initialTravelTime={urlState.travelTime} initialTravelTime={mapUrlState.travelTime}
initialPostcode={urlState.postcode} initialPostcode={mapUrlState.postcode}
user={user} user={user}
onLoginClick={() => { onLoginClick={() => {
setAuthModalTab('login'); setAuthModalTab('login');

View file

@ -567,6 +567,7 @@ export default function MapPage({
selectedCategories={selectedPOICategories} selectedCategories={selectedPOICategories}
onCategoriesChange={setSelectedPOICategories} onCategoriesChange={setSelectedPOICategories}
poiCount={pois.length} poiCount={pois.length}
onClose={() => setPoiPaneOpen(false)}
/> />
); );

View file

@ -6,7 +6,7 @@ import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
import { PillToggle } from '../ui/PillToggle'; import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup'; import { PillGroup } from '../ui/PillGroup';
import { InfoIcon, ChevronIcon } from '../ui/icons'; import { InfoIcon, ChevronIcon, CloseIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton'; import { IconButton } from '../ui/IconButton';
interface POIPaneProps { interface POIPaneProps {
@ -15,6 +15,7 @@ interface POIPaneProps {
onCategoriesChange: (categories: Set<string>) => void; onCategoriesChange: (categories: Set<string>) => void;
poiCount: number; poiCount: number;
onNavigateToSource?: (slug: string) => void; onNavigateToSource?: (slug: string) => void;
onClose?: () => void;
} }
export default function POIPane({ export default function POIPane({
@ -23,6 +24,7 @@ export default function POIPane({
onCategoriesChange, onCategoriesChange,
poiCount: _poiCount, poiCount: _poiCount,
onNavigateToSource, onNavigateToSource,
onClose,
}: POIPaneProps) { }: POIPaneProps) {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [isGroupExpanded, toggleCollapse] = useCollapsibleGroups(); const [isGroupExpanded, toggleCollapse] = useCollapsibleGroups();
@ -96,7 +98,7 @@ export default function POIPane({
<IconButton onClick={() => setShowInfo(true)} title="Data source info"> <IconButton onClick={() => setShowInfo(true)} title="Data source info">
<InfoIcon /> <InfoIcon />
</IconButton> </IconButton>
<div className="flex gap-1 ml-auto"> <div className="flex gap-1 ml-auto items-center">
<button <button
onClick={selectAll} onClick={selectAll}
className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700" className="px-2 py-0.5 text-xs rounded border border-warm-300 dark:border-warm-700 text-warm-600 dark:text-warm-400 hover:bg-warm-50 dark:hover:bg-warm-700"
@ -109,6 +111,15 @@ export default function POIPane({
> >
None None
</button> </button>
{onClose && (
<button
onClick={onClose}
className="ml-1 p-0.5 text-warm-400 hover:text-warm-700 dark:hover:text-warm-300"
title="Close"
>
<CloseIcon className="w-4 h-4" />
</button>
)}
</div> </div>
</div> </div>

View file

@ -316,10 +316,12 @@ export function useDeckLayers({
number, number,
]; ];
} }
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor( return getFeatureFillColor(
ttVal as number, ttVal as number,
ttVal as number, ttMin as number,
ttVal as number, ttMax as number,
clr, clr,
fr, fr,
0, 0,
@ -417,10 +419,12 @@ export function useDeckLayers({
number, number,
]; ];
} }
const ttMin = (d[`min_${vf}`] as number) ?? ttVal;
const ttMax = (d[`max_${vf}`] as number) ?? ttVal;
return getFeatureFillColor( return getFeatureFillColor(
ttVal as number, ttVal as number,
ttVal as number, ttMin as number,
ttVal as number, ttMax as number,
clr, clr,
fr, fr,
0, 0,

View file

@ -7,22 +7,23 @@ import subprocess
import sys import sys
import tarfile import tarfile
import urllib.request import urllib.request
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
PROTOMAPS_BASE = "https://build.protomaps.com" PROTOMAPS_BASE = "https://build.protomaps.com"
UK_BBOX = "-10.5,49,5,61" UK_BBOX = "-10.5,49,5,61"
MAX_AGE_DAYS = 14 MAX_AGE_DAYS = 14
USER_AGENT = "property-map-tiles/1.0"
def find_latest_build() -> str: def find_latest_build() -> str:
"""Find the most recent available Protomaps daily build.""" """Find the most recent available Protomaps daily build."""
today = datetime.utcnow().date() today = datetime.now(UTC).date()
for i in range(MAX_AGE_DAYS): for i in range(MAX_AGE_DAYS):
d = today - timedelta(days=i) d = today - timedelta(days=i)
url = f"{PROTOMAPS_BASE}/{d:%Y%m%d}.pmtiles" url = f"{PROTOMAPS_BASE}/{d:%Y%m%d}.pmtiles"
req = urllib.request.Request(url, method="HEAD") req = urllib.request.Request(url, method="HEAD", headers={"User-Agent": USER_AGENT})
try: try:
urllib.request.urlopen(req) urllib.request.urlopen(req)
print(f"Found build: {d:%Y%m%d}") print(f"Found build: {d:%Y%m%d}")

View file

@ -175,8 +175,7 @@ fn execute_destination_search(state: &AppState, query: &str, mode: &str) -> Valu
.find_map(|(idx, name_lower)| { .find_map(|(idx, name_lower)| {
let words_match = query_words.iter().all(|word| name_lower.contains(word)); let words_match = query_words.iter().all(|word| name_lower.contains(word));
let slug = slugify(&pd.name[idx]); let slug = slugify(&pd.name[idx]);
let slug_match = let slug_match = slug.contains(&query_slug) || query_slug.contains(&slug);
slug.contains(&query_slug) || query_slug.contains(&slug);
if (words_match || slug_match) && pd.type_rank[idx] == 0 { if (words_match || slug_match) && pd.type_rank[idx] == 0 {
Some(pd.name[idx].as_str()) Some(pd.name[idx].as_str())
} else { } else {
@ -704,7 +703,7 @@ fn count_matching_rows(
let (pc_interner, pc_keys) = state.data.postcode_parts(); let (pc_interner, pc_keys) = state.data.postcode_parts();
let mut count = 0usize; let mut count = 0usize;
for row in 0..num_rows { for (row, pc_key) in pc_keys.iter().enumerate().take(num_rows) {
if !row_passes_filters( if !row_passes_filters(
row, row,
&parsed_filters, &parsed_filters,
@ -716,12 +715,11 @@ fn count_matching_rows(
} }
if has_travel { if has_travel {
let postcode = pc_interner.resolve(&pc_keys[row]); let postcode = pc_interner.resolve(pc_key);
let mut passes_travel = true; let mut passes_travel = true;
for (data, fmin, fmax) in &travel_data { for (data, fmin, fmax) in &travel_data {
let pass = if let Some(mins) = data.get(postcode).map(|r| r.minutes as f32) { let pass = if let Some(mins) = data.get(postcode).map(|r| r.minutes as f32) {
fmin.map_or(true, |min| mins >= min) fmin.is_none_or(|min| mins >= min) && fmax.is_none_or(|max| mins <= max)
&& fmax.map_or(true, |max| mins <= max)
} else { } else {
false // no travel data → postcode not reachable false // no travel data → postcode not reachable
}; };
@ -880,7 +878,12 @@ pub async fn post_ai_filters(
let fn_args = fc.get("args").cloned().unwrap_or(json!({})); let fn_args = fc.get("args").cloned().unwrap_or(json!({}));
tool_call_count += 1; tool_call_count += 1;
info!(function = fn_name, round = round, tool_call = tool_call_count, "AI called tool"); info!(
function = fn_name,
round = round,
tool_call = tool_call_count,
"AI called tool"
);
if tool_call_count > MAX_TOOL_CALLS { if tool_call_count > MAX_TOOL_CALLS {
warn!("Tool call budget exhausted, forcing text output"); warn!("Tool call budget exhausted, forcing text output");
@ -929,9 +932,15 @@ pub async fn post_ai_filters(
if text.is_empty() { if text.is_empty() {
retry_count += 1; retry_count += 1;
warn!("Gemini returned empty text content (round {}, retry {})", round, retry_count); warn!(
"Gemini returned empty text content (round {}, retry {})",
round, retry_count
);
if retry_count > MAX_RETRIES { if retry_count > MAX_RETRIES {
return Err((StatusCode::BAD_GATEWAY, "AI returned empty responses".into())); return Err((
StatusCode::BAD_GATEWAY,
"AI returned empty responses".into(),
));
} }
contents.push(candidate.clone()); contents.push(candidate.clone());
contents.push(json!({ contents.push(json!({
@ -988,7 +997,11 @@ pub async fn post_ai_filters(
// Count matching properties and refine if too restrictive // Count matching properties and refine if too restrictive
let match_count = count_matching_rows(&state, &filters, &travel_time_filters); let match_count = count_matching_rows(&state, &filters, &travel_time_filters);
info!(match_count = match_count, round = round, "AI filter match count"); info!(
match_count = match_count,
round = round,
"AI filter match count"
);
if match_count == 0 { if match_count == 0 {
refinement_attempts += 1; refinement_attempts += 1;
@ -1008,7 +1021,10 @@ pub async fn post_ai_filters(
let notes = if notes.is_empty() { let notes = if notes.is_empty() {
"No properties match these filters. Try relaxing some constraints.".to_string() "No properties match these filters. Try relaxing some constraints.".to_string()
} else { } else {
format!("{}. No properties match — try relaxing some constraints.", notes) format!(
"{}. No properties match — try relaxing some constraints.",
notes
)
}; };
return Ok(Json(AiFiltersResponse { return Ok(Json(AiFiltersResponse {
@ -1193,8 +1209,7 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse, listing_type:
} => { } => {
// Only include features valid for the chosen listing mode // Only include features valid for the chosen listing mode
if modes.is_empty() || modes.contains(&listing_type) { if modes.is_empty() || modes.contains(&listing_type) {
numeric_features numeric_features.insert(name, (*min, *max, histogram.min, histogram.max));
.insert(name, (*min, *max, histogram.min, histogram.max));
} }
} }
FeatureInfo::Enum { name, values, .. } => { FeatureInfo::Enum { name, values, .. } => {
@ -1217,8 +1232,7 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse, listing_type:
Some(name) => name, Some(name) => name,
None => continue, None => continue,
}; };
let (slider_min, slider_max, data_min, data_max) = let (slider_min, slider_max, data_min, data_max) = match numeric_features.get(name) {
match numeric_features.get(name) {
Some(range) => *range, Some(range) => *range,
None => continue, None => continue,
}; };

View file

@ -140,10 +140,7 @@ pub async fn get_short_url(
match params { match params {
Some(params) => { Some(params) => {
let redirect_url = format!("/dashboard?{params}"); let redirect_url = format!("/dashboard?{params}");
let og_image_url = format!( let og_image_url = format!("{}/api/screenshot?og=1&{params}", state.public_url);
"{}/api/screenshot?og=1&{params}",
state.public_url
);
let og_url = format!("{}/s/{code}", state.public_url); let og_url = format!("{}/s/{code}", state.public_url);
let og_title = "Perfect Postcode \u{2014} Every neighbourhood in England"; let og_title = "Perfect Postcode \u{2014} Every neighbourhood in England";
let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map."; let og_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";