Lots of improvements
This commit is contained in:
parent
3853b5dce7
commit
b94cf17d75
33 changed files with 2587 additions and 1866 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
93
frontend/src/hooks/useFilterCounts.ts
Normal file
93
frontend/src/hooks/useFilterCounts.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue