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

View file

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

View file

@ -6,7 +6,7 @@ import InfoPopup from '../ui/InfoPopup';
import { SearchInput } from '../ui/SearchInput';
import { PillToggle } from '../ui/PillToggle';
import { PillGroup } from '../ui/PillGroup';
import { InfoIcon, ChevronIcon } from '../ui/icons';
import { InfoIcon, ChevronIcon, CloseIcon } from '../ui/icons';
import { IconButton } from '../ui/IconButton';
interface POIPaneProps {
@ -15,6 +15,7 @@ interface POIPaneProps {
onCategoriesChange: (categories: Set<string>) => void;
poiCount: number;
onNavigateToSource?: (slug: string) => void;
onClose?: () => void;
}
export default function POIPane({
@ -23,6 +24,7 @@ export default function POIPane({
onCategoriesChange,
poiCount: _poiCount,
onNavigateToSource,
onClose,
}: POIPaneProps) {
const [searchTerm, setSearchTerm] = useState('');
const [isGroupExpanded, toggleCollapse] = useCollapsibleGroups();
@ -96,7 +98,7 @@ export default function POIPane({
<IconButton onClick={() => setShowInfo(true)} title="Data source info">
<InfoIcon />
</IconButton>
<div className="flex gap-1 ml-auto">
<div className="flex gap-1 ml-auto items-center">
<button
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"
@ -109,6 +111,15 @@ export default function POIPane({
>
None
</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>

View file

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

View file

@ -7,22 +7,23 @@ import subprocess
import sys
import tarfile
import urllib.request
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from io import BytesIO
from pathlib import Path
PROTOMAPS_BASE = "https://build.protomaps.com"
UK_BBOX = "-10.5,49,5,61"
MAX_AGE_DAYS = 14
USER_AGENT = "property-map-tiles/1.0"
def find_latest_build() -> str:
"""Find the most recent available Protomaps daily build."""
today = datetime.utcnow().date()
today = datetime.now(UTC).date()
for i in range(MAX_AGE_DAYS):
d = today - timedelta(days=i)
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:
urllib.request.urlopen(req)
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)| {
let words_match = query_words.iter().all(|word| name_lower.contains(word));
let slug = slugify(&pd.name[idx]);
let slug_match =
slug.contains(&query_slug) || query_slug.contains(&slug);
let slug_match = slug.contains(&query_slug) || query_slug.contains(&slug);
if (words_match || slug_match) && pd.type_rank[idx] == 0 {
Some(pd.name[idx].as_str())
} else {
@ -704,7 +703,7 @@ fn count_matching_rows(
let (pc_interner, pc_keys) = state.data.postcode_parts();
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(
row,
&parsed_filters,
@ -716,12 +715,11 @@ fn count_matching_rows(
}
if has_travel {
let postcode = pc_interner.resolve(&pc_keys[row]);
let postcode = pc_interner.resolve(pc_key);
let mut passes_travel = true;
for (data, fmin, fmax) in &travel_data {
let pass = if let Some(mins) = data.get(postcode).map(|r| r.minutes as f32) {
fmin.map_or(true, |min| mins >= min)
&& fmax.map_or(true, |max| mins <= max)
fmin.is_none_or(|min| mins >= min) && fmax.is_none_or(|max| mins <= max)
} else {
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!({}));
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 {
warn!("Tool call budget exhausted, forcing text output");
@ -929,9 +932,15 @@ pub async fn post_ai_filters(
if text.is_empty() {
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 {
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(json!({
@ -988,7 +997,11 @@ pub async fn post_ai_filters(
// Count matching properties and refine if too restrictive
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 {
refinement_attempts += 1;
@ -1008,7 +1021,10 @@ pub async fn post_ai_filters(
let notes = if notes.is_empty() {
"No properties match these filters. Try relaxing some constraints.".to_string()
} else {
format!("{}. No properties match — try relaxing some constraints.", notes)
format!(
"{}. No properties match — try relaxing some constraints.",
notes
)
};
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
if modes.is_empty() || modes.contains(&listing_type) {
numeric_features
.insert(name, (*min, *max, histogram.min, histogram.max));
numeric_features.insert(name, (*min, *max, histogram.min, histogram.max));
}
}
FeatureInfo::Enum { name, values, .. } => {
@ -1217,11 +1232,10 @@ fn validate_and_convert(raw: &Value, features: &FeaturesResponse, listing_type:
Some(name) => name,
None => continue,
};
let (slider_min, slider_max, data_min, data_max) =
match numeric_features.get(name) {
Some(range) => *range,
None => continue,
};
let (slider_min, slider_max, data_min, data_max) = match numeric_features.get(name) {
Some(range) => *range,
None => continue,
};
let bound = match item.get("bound").and_then(|val| val.as_str()) {
Some(b) => b,
None => continue,

View file

@ -140,10 +140,7 @@ pub async fn get_short_url(
match params {
Some(params) => {
let redirect_url = format!("/dashboard?{params}");
let og_image_url = format!(
"{}/api/screenshot?og=1&{params}",
state.public_url
);
let og_image_url = format!("{}/api/screenshot?og=1&{params}", 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_description = "Explore property prices, energy ratings, crime stats, school ratings, and more across England on one interactive map.";