lgtm
This commit is contained in:
parent
084117cea8
commit
a8de0a614d
36 changed files with 1329 additions and 522 deletions
|
|
@ -1,7 +1,14 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import type { FeatureMeta, FeatureFilters, Bounds } from '../types';
|
||||
import { apiUrl, buildFilterString, logNonAbortError, authHeaders, isAbortError } from '../lib/api';
|
||||
import {
|
||||
apiUrl,
|
||||
buildFilterString,
|
||||
logNonAbortError,
|
||||
authHeaders,
|
||||
isAbortError,
|
||||
} from '../lib/api';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import { buildTravelParam } from '../lib/travel-params';
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
|
||||
|
|
@ -18,32 +25,34 @@ export function useFilterCounts(
|
|||
filters: FeatureFilters,
|
||||
features: FeatureMeta[],
|
||||
bounds: Bounds | null,
|
||||
travelTimeEntries: TravelTimeEntry[]
|
||||
travelTimeEntries: TravelTimeEntry[],
|
||||
shareCode?: string
|
||||
) {
|
||||
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('|');
|
||||
return buildTravelParam(travelTimeEntries);
|
||||
}, [travelTimeEntries]);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!bounds) return;
|
||||
requestIdRef.current += 1;
|
||||
const requestId = requestIdRef.current;
|
||||
|
||||
if (!bounds) {
|
||||
abortRef.current?.abort();
|
||||
setImpacts({});
|
||||
setTotal(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const filterCount = Object.keys(filters).length;
|
||||
const hasTravelFilters = travelTimeEntries.some((e) => e.slug && e.timeRange);
|
||||
if (filterCount === 0 && !hasTravelFilters) {
|
||||
abortRef.current?.abort();
|
||||
setImpacts({});
|
||||
setTotal(0);
|
||||
return;
|
||||
|
|
@ -61,6 +70,7 @@ export function useFilterCounts(
|
|||
const params = new URLSearchParams({ bounds: boundsStr });
|
||||
if (filtersStr) params.set('filters', filtersStr);
|
||||
if (travelParam) params.set('travel', travelParam);
|
||||
if (shareCode) params.set('share', shareCode);
|
||||
|
||||
const res = await fetch(
|
||||
apiUrl('filter-counts', params),
|
||||
|
|
@ -68,6 +78,7 @@ export function useFilterCounts(
|
|||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json: FilterCountsResponse = await res.json();
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setImpacts(json.impacts);
|
||||
setTotal(json.total);
|
||||
} catch (err) {
|
||||
|
|
@ -79,8 +90,9 @@ export function useFilterCounts(
|
|||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [filters, features, bounds, travelParam, travelTimeEntries]);
|
||||
}, [filters, features, bounds, travelParam, travelTimeEntries, shareCode]);
|
||||
|
||||
// Cancel in-flight on unmount
|
||||
useEffect(() => {
|
||||
|
|
|
|||
58
frontend/src/hooks/useFilters.test.ts
Normal file
58
frontend/src/hooks/useFilters.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useFilters } from './useFilters';
|
||||
import type { FeatureMeta } from '../types';
|
||||
|
||||
vi.mock('../lib/analytics', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useFilters', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{
|
||||
name: 'price',
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
];
|
||||
|
||||
it('activates slider preview on pointer down without committing unchanged clicks', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: { price: [0, 100] },
|
||||
features,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragStart('price');
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBe('price');
|
||||
expect(result.current.viewSource).toBe('drag');
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd();
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBeNull();
|
||||
expect(result.current.filters.price).toEqual([0, 100]);
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragStart('price');
|
||||
result.current.handleDragChange([10, 90]);
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBe('price');
|
||||
expect(result.current.viewSource).toBe('drag');
|
||||
|
||||
act(() => {
|
||||
result.current.handleDragEnd();
|
||||
});
|
||||
|
||||
expect(result.current.activeFeature).toBeNull();
|
||||
expect(result.current.filters.price).toEqual([10, 90]);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,8 @@ import { act, renderHook } from '@testing-library/react';
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useMapData } from './useMapData';
|
||||
import type { ApiResponse, Bounds, ViewChangeParams } from '../types';
|
||||
import type { TravelTimeEntry } from './useTravelTime';
|
||||
import type { ApiResponse, Bounds, FeatureMeta, ViewChangeParams } from '../types';
|
||||
|
||||
vi.mock('../lib/pocketbase', () => ({
|
||||
default: { authStore: { isValid: false, token: '' } },
|
||||
|
|
@ -32,6 +33,7 @@ async function flushPromises() {
|
|||
|
||||
describe('useMapData', () => {
|
||||
const requests: Array<{ url: string; resolve: (response: Response) => void }> = [];
|
||||
const noTravelTimeEntries: TravelTimeEntry[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -59,7 +61,7 @@ describe('useMapData', () => {
|
|||
viewFeature: null,
|
||||
activeFeature: null,
|
||||
pinnedFeature: null,
|
||||
travelTimeEntries: [],
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
@ -93,4 +95,264 @@ describe('useMapData', () => {
|
|||
|
||||
expect(result.current.data).toEqual([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }]);
|
||||
});
|
||||
|
||||
it('stores the visible map center separately from the rendered map center', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useMapData({
|
||||
filters: {},
|
||||
features: [],
|
||||
viewFeature: null,
|
||||
activeFeature: null,
|
||||
pinnedFeature: null,
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleViewChange({
|
||||
...viewChange({ south: 1, west: 1, north: 2, east: 2 }),
|
||||
latitude: 51.5,
|
||||
longitude: -0.1,
|
||||
visibleLatitude: 51.6,
|
||||
visibleLongitude: -0.2,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.currentView).toEqual({ latitude: 51.5, longitude: -0.1, zoom: 10 });
|
||||
expect(result.current.currentVisibleView).toEqual({
|
||||
latitude: 51.6,
|
||||
longitude: -0.2,
|
||||
zoom: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('resets the colour range to drag preview data while a slider is active', async () => {
|
||||
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
||||
const features: FeatureMeta[] = [
|
||||
{
|
||||
name: 'price',
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
];
|
||||
const filters = { price: [20, 80] as [number, number] };
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeFeature }: { activeFeature: string | null }) =>
|
||||
useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature: 'price',
|
||||
activeFeature,
|
||||
pinnedFeature: null,
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
}),
|
||||
{ initialProps: { 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: 'committed-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
|
||||
{ h3: 'committed-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);
|
||||
|
||||
await act(async () => {
|
||||
rerender({ activeFeature: 'price' });
|
||||
await flushPromises();
|
||||
});
|
||||
expect(requests).toHaveLength(2);
|
||||
|
||||
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 },
|
||||
])
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
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[] = [
|
||||
{
|
||||
name: 'price',
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
];
|
||||
const committedData = [
|
||||
{ h3: 'committed-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
|
||||
{ h3: 'committed-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 },
|
||||
];
|
||||
const previewData = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
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: [20, 80] 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(committedData));
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
rerender({
|
||||
filters: { price: [20, 80] },
|
||||
activeFeature: 'price',
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
requests[1].resolve(response(previewData));
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.current.data).toEqual(previewData);
|
||||
|
||||
await act(async () => {
|
||||
rerender({
|
||||
filters: { price: [10, 90] },
|
||||
activeFeature: 'price',
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(committedData);
|
||||
});
|
||||
|
||||
it('resets a pinned colour range after a slider commits a new filter', async () => {
|
||||
const bounds = { south: 1, west: 1, north: 2, east: 2 };
|
||||
const features: FeatureMeta[] = [
|
||||
{
|
||||
name: 'price',
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({
|
||||
filters,
|
||||
activeFeature,
|
||||
}: {
|
||||
filters: Record<string, [number, number]>;
|
||||
activeFeature: string | null;
|
||||
}) =>
|
||||
useMapData({
|
||||
filters,
|
||||
features,
|
||||
viewFeature: 'price',
|
||||
activeFeature,
|
||||
pinnedFeature: 'price',
|
||||
travelTimeEntries: noTravelTimeEntries,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
filters: { price: [20, 80] 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: 'old-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 },
|
||||
{ h3: 'old-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);
|
||||
|
||||
await act(async () => {
|
||||
rerender({
|
||||
filters: { price: [20, 80] },
|
||||
activeFeature: 'price',
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
rerender({
|
||||
filters: { price: [10, 90] },
|
||||
activeFeature: null,
|
||||
});
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
const committedRequest = requests[requests.length - 1];
|
||||
await act(async () => {
|
||||
committedRequest.resolve(
|
||||
response([
|
||||
{ h3: 'new-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 },
|
||||
{ h3: 'new-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 },
|
||||
])
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.colorRange?.[0]).toBeCloseTo(5);
|
||||
expect(result.current.colorRange?.[1]).toBeCloseTo(95);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function useTutorial(initialLoading: boolean, isMobile: boolean, blocked
|
|||
skipBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="map-anchor"]',
|
||||
target: '[data-tutorial="map"]',
|
||||
title: t('tutorial.step3Title'),
|
||||
content: t('tutorial.step3Content'),
|
||||
placement: 'top' as const,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue