This commit is contained in:
Andras Schmelczer 2026-05-17 10:16:30 +01:00
parent 47d89f6fad
commit 017902b8e6
82 changed files with 331466 additions and 54841 deletions

View file

@ -126,7 +126,7 @@ describe('useMapData', () => {
});
});
it('resets the colour range to drag preview data while a slider is active', async () => {
it('resets the colour range to visible drag preview data while a slider is active', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
@ -139,16 +139,28 @@ describe('useMapData', () => {
const filters = { price: [20, 80] as [number, number] };
const { result, rerender } = renderHook(
({ activeFeature }: { activeFeature: string | null }) =>
({
activeFeature,
filterRange,
}: {
activeFeature: string | null;
filterRange: [number, number] | null;
}) =>
useMapData({
filters,
features,
viewFeature: 'price',
activeFeature,
pinnedFeature: null,
filterRange,
travelTimeEntries: noTravelTimeEntries,
}),
{ initialProps: { activeFeature: null as string | null } }
{
initialProps: {
activeFeature: null as string | null,
filterRange: filters.price,
},
}
);
await act(async () => {
@ -171,27 +183,58 @@ describe('useMapData', () => {
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
await act(async () => {
rerender({ activeFeature: 'price' });
rerender({ activeFeature: 'price', filterRange: filters.price });
await flushPromises();
});
expect(requests).toHaveLength(2);
const previewData = [
{
h3: 'preview-outside-low',
count: 1,
lat: 1.1,
lon: 1.1,
min_price: 0,
max_price: 10,
avg_price: 5,
},
{
h3: 'preview-low',
count: 1,
lat: 1.25,
lon: 1.25,
min_price: 20,
max_price: 20,
avg_price: 20,
},
{
h3: 'preview-high',
count: 1,
lat: 1.75,
lon: 1.75,
min_price: 80,
max_price: 80,
avg_price: 80,
},
{
h3: 'preview-outside-high',
count: 1,
lat: 1.9,
lon: 1.9,
min_price: 90,
max_price: 100,
avg_price: 95,
},
];
await act(async () => {
requests[1].resolve(
response([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
])
);
requests[1].resolve(response(previewData));
await flushPromises();
});
expect(result.current.data).toEqual([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
]);
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
expect(result.current.data).toEqual(previewData);
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
});
it('does not use metadata min/max while slider preview colour data is loading', async () => {
@ -270,6 +313,82 @@ describe('useMapData', () => {
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
});
it('does not use stale committed feature data while slider preview colour data is loading', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [
{
name: 'price',
type: 'numeric',
min: 0,
max: 1_000,
},
];
const { result, rerender } = renderHook(
({
filters,
activeFeature,
}: {
filters: Record<string, [number, number]>;
activeFeature: string | null;
}) =>
useMapData({
filters,
features,
viewFeature: 'price',
activeFeature,
pinnedFeature: null,
travelTimeEntries: noTravelTimeEntries,
}),
{
initialProps: {
filters: { price: [0, 1_000] as [number, number] },
activeFeature: null as string | null,
},
}
);
await act(async () => {
result.current.handleViewChange(viewChange(bounds));
});
await act(async () => {
vi.advanceTimersByTime(150);
});
await act(async () => {
requests[0].resolve(
response([
{ h3: 'stale-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
{ h3: 'stale-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 1_000 },
])
);
await flushPromises();
});
expect(result.current.colorRange?.[1]).toBeCloseTo(950);
await act(async () => {
rerender({
filters: { price: [20, 80] },
activeFeature: 'price',
});
await flushPromises();
});
expect(result.current.colorRange).toBeNull();
await act(async () => {
requests[1].resolve(
response([
{ h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
{ h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
])
);
await flushPromises();
});
expect(result.current.colorRange?.[0]).toBeCloseTo(23);
expect(result.current.colorRange?.[1]).toBeCloseTo(77);
});
it('does not reuse cached drag preview data when the drag request changes', async () => {
const bounds = { south: 1, west: 1, north: 2, east: 2 };
const features: FeatureMeta[] = [

View file

@ -45,18 +45,38 @@ interface UseMapDataOptions {
viewFeature: string | null;
activeFeature: string | null;
pinnedFeature: string | null;
filterRange?: [number, number] | null;
travelTimeEntries: TravelTimeEntry[];
/** Share-link code from the URL; appended to data fetches so the backend
* grants bbox-scoped access for unlicensed recipients. */
shareCode?: string;
}
function getFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function valueInVisibleRange(
value: number,
minValue: number | null,
maxValue: number | null,
visibleRange: [number, number] | null
): number | null {
if (!visibleRange) return value;
const itemMin = minValue ?? value;
const itemMax = maxValue ?? value;
if (itemMax < visibleRange[0] || itemMin > visibleRange[1]) return null;
return Math.max(visibleRange[0], Math.min(visibleRange[1], value));
}
export function useMapData({
filters,
features,
viewFeature,
activeFeature,
pinnedFeature,
filterRange = null,
travelTimeEntries,
shareCode,
}: UseMapDataOptions) {
@ -487,8 +507,15 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lng < bounds.west || lng > bounds.east)
continue;
}
const val = feat.properties[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
const val = getFiniteNumber(feat.properties[`avg_${dataViewFeature}`]);
if (val == null) continue;
const visibleValue = valueInVisibleRange(
val,
getFiniteNumber(feat.properties[`min_${dataViewFeature}`]),
getFiniteNumber(feat.properties[`max_${dataViewFeature}`]),
filterRange
);
if (visibleValue != null) vals.push(visibleValue);
}
} else {
if (data.length === 0) return null;
@ -498,8 +525,15 @@ export function useMapData({
if (lat < bounds.south || lat > bounds.north || lon < bounds.west || lon > bounds.east)
continue;
}
const val = item[`avg_${dataViewFeature}`];
if (typeof val === 'number' && !isNaN(val)) vals.push(val);
const val = getFiniteNumber(item[`avg_${dataViewFeature}`]);
if (val == null) continue;
const visibleValue = valueInVisibleRange(
val,
getFiniteNumber(item[`min_${dataViewFeature}`]),
getFiniteNumber(item[`max_${dataViewFeature}`]),
filterRange
);
if (visibleValue != null) vals.push(visibleValue);
}
}
@ -515,6 +549,7 @@ export function useMapData({
dataViewFeature,
effectivePostcodeData,
features,
filterRange,
hasCurrentRangeData,
usePostcodeView,
]);

View file

@ -176,6 +176,46 @@ export function useSavedSearches(userId: string | null) {
}
}, []);
const updateSearchParams = useCallback(
async (id: string, params: string) => {
if (!userId) return;
setSaving(true);
setError(null);
try {
const record = await pb.collection('saved_searches').update(id, { params });
trackEvent('Search Update');
setSearches((prev) =>
prev.map((s) => (s.id === id ? { ...s, params, screenshotUrl: '' } : s))
);
// Refresh screenshot in the background
const screenshotParams = new URLSearchParams(params);
const screenshotUrl = apiUrl('screenshot', screenshotParams);
fetch(screenshotUrl, authHeaders())
.then((res) => {
if (!res.ok) throw new Error(`Screenshot ${res.status}`);
return res.blob();
})
.then((blob) => {
const patch = new FormData();
patch.append('screenshot', blob, 'screenshot.jpg');
return pb.collection('saved_searches').update(record.id, patch);
})
.then(() => fetchSearches())
.catch((err) => {
console.warn('Background screenshot failed:', err);
});
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to update search';
setError(msg);
throw err;
} finally {
setSaving(false);
}
},
[userId, fetchSearches]
);
return {
searches,
loading,
@ -186,5 +226,6 @@ export function useSavedSearches(userId: string | null) {
deleteSearch,
updateSearchNotes,
updateSearchName,
updateSearchParams,
};
}