all good
This commit is contained in:
parent
47d89f6fad
commit
017902b8e6
82 changed files with 331466 additions and 54841 deletions
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue