Lots of improvements
Some checks failed
CI / Python (lint + test) (push) Failing after 1m39s
CI / Frontend (lint + typecheck) (push) Failing after 1m49s
CI / Rust (lint + test) (push) Failing after 1m50s
Build and publish Docker image / build-and-push (push) Failing after 3m9s

This commit is contained in:
Andras Schmelczer 2026-04-04 10:45:48 +01:00
parent 3853b5dce7
commit b94cf17d75
33 changed files with 2587 additions and 1866 deletions

View file

@ -17,8 +17,6 @@ export interface AiFiltersResult {
notes: string;
/** Human-readable summary of what was set */
summary: string;
/** The listing mode used (historical/buy/rent) */
listingType: string;
/** Number of properties matching the proposed filters (excludes travel time) */
matchCount: number;
}
@ -34,8 +32,7 @@ export interface AiFiltersContext {
interface UseAiFiltersResult {
fetchAiFilters: (
query: string,
context?: AiFiltersContext,
listingType?: string
context?: AiFiltersContext
) => Promise<AiFiltersResult | null>;
loading: boolean;
error: string | null;
@ -88,8 +85,7 @@ export function useAiFilters(): UseAiFiltersResult {
const fetchAiFilters = useCallback(
async (
query: string,
context?: AiFiltersContext,
listingType?: string
context?: AiFiltersContext
): Promise<AiFiltersResult | null> => {
abortRef.current?.abort();
const controller = new AbortController();
@ -104,7 +100,6 @@ export function useAiFilters(): UseAiFiltersResult {
try {
const url = apiUrl('ai-filters');
const bodyObj: Record<string, unknown> = { query };
if (listingType) bodyObj.listing_type = listingType;
if (context) {
bodyObj.context = {
filters: context.filters,
@ -155,7 +150,6 @@ export function useAiFilters(): UseAiFiltersResult {
travelTimeFilters,
notes: json.notes || '',
summary: summaryText,
listingType: json.listing_type || 'historical',
matchCount,
};
setNotes(result.notes || null);

View file

@ -126,8 +126,10 @@ export function useAuth() {
const result = await pb.collection('users').authRefresh();
setUser(recordToUser(result.record));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Auth refresh failed';
setError(msg);
// Token is invalid/expired — clear auth state but don't set error,
// since this is a background refresh, not a user-initiated action
pb.authStore.clear();
setUser(null);
throw err;
} finally {
setLoading(false);

View file

@ -0,0 +1,93 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
import type { TravelTimeEntry } from './useTravelTime';
const DEBOUNCE_MS = 400;
interface FilterCountsResponse {
total: number;
impacts: Record<string, number>;
}
/**
* Fetches per-filter marginal impact counts: for each active filter,
* how many more properties would be visible if that filter were removed.
*/
export function useFilterCounts(
filters: FeatureFilters,
features: FeatureMeta[],
bounds: Bounds | null,
travelTimeEntries: TravelTimeEntry[]
) {
const [impacts, setImpacts] = useState<Record<string, number>>({});
const [total, setTotal] = useState<number>(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Build the travel param string (same format as useMapData)
const travelParam = useMemo(() => {
const segments: string[] = [];
for (const entry of travelTimeEntries) {
if (!entry.slug) continue;
let seg = `${entry.mode}:${entry.slug}`;
if (entry.useBest) seg += ':best';
if (entry.timeRange) seg += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
segments.push(seg);
}
return segments.join('|');
}, [travelTimeEntries]);
useEffect(() => {
if (!bounds) return;
const filterCount = Object.keys(filters).length;
const hasTravelFilters = travelTimeEntries.some((e) => e.slug && e.timeRange);
if (filterCount === 0 && !hasTravelFilters) {
setImpacts({});
setTotal(0);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(async () => {
if (abortRef.current) abortRef.current.abort();
abortRef.current = new AbortController();
try {
const boundsStr = `${bounds.south},${bounds.west},${bounds.north},${bounds.east}`;
const filtersStr = buildFilterString(filters, features);
const params = new URLSearchParams({ bounds: boundsStr });
if (filtersStr) params.set('filters', filtersStr);
if (travelParam) params.set('travel', travelParam);
const res = await fetch(
apiUrl('filter-counts', params),
authHeaders({ signal: abortRef.current!.signal })
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: FilterCountsResponse = await res.json();
setImpacts(json.impacts);
setTotal(json.total);
} catch (err) {
if (!isAbortError(err)) {
logNonAbortError('Failed to fetch filter counts', err);
}
}
}, DEBOUNCE_MS);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [filters, features, bounds, travelParam, travelTimeEntries]);
// Cancel in-flight on unmount
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
return { impacts, total };
}

View file

@ -266,6 +266,14 @@ export function useHexagonSelection({
setSelectedPostcodeGeometry(null);
} else {
setAreaStats(stats);
// Re-fetch properties if the properties tab is active
if (rightPaneTab === 'properties') {
if (selectedHexagon.type === 'postcode') {
fetchPostcodeProperties(selectedHexagon.id, 0);
} else {
fetchHexagonProperties(selectedHexagon.id, selectedHexagon.resolution, 0);
}
}
}
})
.catch((error) => {
@ -279,7 +287,7 @@ export function useHexagonSelection({
return () => {
cancelled = true;
};
}, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats]);
}, [filterStr, selectedHexagon, fetchHexagonStats, fetchPostcodeStats, rightPaneTab, fetchHexagonProperties, fetchPostcodeProperties]);
const handleLocationSearch = useCallback(
(postcode: string, geometry: PostcodeGeometry) => {

View file

@ -2,10 +2,12 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import type { PlaceResult } from '../types';
import { authHeaders, logNonAbortError } from '../lib/api';
const POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d?[A-Z]{0,2}$/i;
/** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA").
* Outcodes like "E14" or "SW1A" intentionally do NOT match they go through /api/places instead. */
const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i;
function looksLikePostcode(s: string) {
return POSTCODE_RE.test(s.trim());
return FULL_POSTCODE_RE.test(s.trim());
}
/** Normalize a UK postcode: uppercase, strip spaces, insert canonical space before inward code. */
@ -83,7 +85,7 @@ export function useLocationSearch(mode?: string) {
place_type: p.place_type,
lat: p.lat,
lon: p.lon,
city: p.city,
city: p.city === 'City of London' ? 'London' : p.city,
}));
setResults(placeResults);
setOpen(placeResults.length > 0);

View file

@ -7,43 +7,87 @@ interface PaneResizeHandlers {
}
export function usePaneResize(
initialWidth: number,
minWidth: number,
maxWidth: number,
side: 'left' | 'right'
): [number, PaneResizeHandlers] {
const [width, setWidth] = useState(initialWidth);
initialSize: number,
minSize: number,
maxSize: number,
side: 'left' | 'right' | 'top' | 'bottom'
): [number, PaneResizeHandlers, React.RefCallback<HTMLElement>] {
const [size, setSize] = useState(initialSize);
const draggingRef = useRef(false);
const liveSizeRef = useRef(initialSize);
const targetRef = useRef<HTMLElement | null>(null);
const containerOffsetRef = useRef(0);
const containerSizeRef = useRef(0);
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = true;
const isVertical = side === 'top' || side === 'bottom';
const styleProp = isVertical ? 'height' : 'width';
const targetCallbackRef = useCallback((el: HTMLElement | null) => {
targetRef.current = el;
}, []);
const computeSize = useCallback(
(e: React.PointerEvent): number => {
if (isVertical) {
const total = containerSizeRef.current || window.innerHeight;
const resolvedMax = maxSize <= 1 ? total * maxSize : maxSize;
const pos = e.clientY - containerOffsetRef.current;
return side === 'top'
? Math.min(resolvedMax, Math.max(minSize, pos))
: Math.min(resolvedMax, Math.max(minSize, total - pos));
} else {
const resolvedMax = maxSize <= 1 ? window.innerWidth * maxSize : maxSize;
return side === 'left'
? Math.min(resolvedMax, Math.max(minSize, e.clientX))
: Math.min(resolvedMax, Math.max(minSize, window.innerWidth - e.clientX));
}
},
[side, isVertical, minSize, maxSize]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
draggingRef.current = true;
if (isVertical) {
const container = (e.currentTarget as HTMLElement).parentElement;
if (container) {
const rect = container.getBoundingClientRect();
containerOffsetRef.current = rect.top;
containerSizeRef.current = rect.height;
}
}
},
[isVertical]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!draggingRef.current) return;
const resolvedMax = maxWidth <= 1 ? window.innerWidth * maxWidth : maxWidth;
const newWidth =
side === 'left'
? Math.min(resolvedMax, Math.max(minWidth, e.clientX))
: Math.min(resolvedMax, Math.max(minWidth, window.innerWidth - e.clientX));
setWidth(newWidth);
const newSize = computeSize(e);
liveSizeRef.current = newSize;
if (targetRef.current) {
targetRef.current.style[styleProp] = `${newSize}px`;
} else {
setSize(newSize);
}
},
[side, minWidth, maxWidth]
[computeSize, styleProp]
);
const handlePointerUp = useCallback(() => {
draggingRef.current = false;
setSize(liveSizeRef.current);
}, []);
return [
width,
size,
{
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
},
targetCallbackRef,
];
}