new demo mode & tenure
This commit is contained in:
parent
7656f24544
commit
4a0f00f2a4
64 changed files with 2875 additions and 338 deletions
|
|
@ -59,6 +59,101 @@ describe('useFilters', () => {
|
|||
expect(result.current.filters.price).toEqual([10, 90]);
|
||||
});
|
||||
|
||||
it('caps the number of filters for demo users and reports when the limit is hit', () => {
|
||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
||||
name,
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
}));
|
||||
const onFilterLimitReached = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: {},
|
||||
features: sixFeatures,
|
||||
filterLimit: 5,
|
||||
onFilterLimitReached,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
['a', 'b', 'c', 'd', 'e'].forEach((name) => result.current.handleAddFilter(name));
|
||||
});
|
||||
expect(Object.keys(result.current.filters)).toHaveLength(5);
|
||||
expect(onFilterLimitReached).not.toHaveBeenCalled();
|
||||
|
||||
// The 6th distinct filter is blocked and reported.
|
||||
act(() => {
|
||||
result.current.handleAddFilter('f');
|
||||
});
|
||||
expect(Object.keys(result.current.filters)).toHaveLength(5);
|
||||
expect(result.current.filters.f).toBeUndefined();
|
||||
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Re-adding an already-present feature is allowed (it replaces, no new key).
|
||||
act(() => {
|
||||
result.current.handleAddFilter('a');
|
||||
});
|
||||
expect(Object.keys(result.current.filters)).toHaveLength(5);
|
||||
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('caps bulk handleSetFilters (e.g. AI results) to the demo limit and reports it', () => {
|
||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
||||
name,
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
}));
|
||||
const onFilterLimitReached = vi.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: {},
|
||||
features: sixFeatures,
|
||||
filterLimit: 5,
|
||||
onFilterLimitReached,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetFilters({
|
||||
a: [0, 1],
|
||||
b: [0, 1],
|
||||
c: [0, 1],
|
||||
d: [0, 1],
|
||||
e: [0, 1],
|
||||
f: [0, 1],
|
||||
});
|
||||
});
|
||||
|
||||
expect(Object.keys(result.current.filters)).toHaveLength(5);
|
||||
expect(onFilterLimitReached).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not cap filters when filterLimit is null (licensed users)', () => {
|
||||
const sixFeatures: FeatureMeta[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((name) => ({
|
||||
name,
|
||||
type: 'numeric',
|
||||
min: 0,
|
||||
max: 100,
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
initialFilters: {},
|
||||
features: sixFeatures,
|
||||
filterLimit: null,
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
['a', 'b', 'c', 'd', 'e', 'f'].forEach((name) => result.current.handleAddFilter(name));
|
||||
});
|
||||
expect(Object.keys(result.current.filters)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('uses the provided initial range for drag-only feature keys', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFilters({
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ import {
|
|||
interface UseFiltersOptions {
|
||||
initialFilters: FeatureFilters;
|
||||
features: FeatureMeta[];
|
||||
/** Max simultaneous filters for the current user; `null`/`undefined` = unlimited. */
|
||||
filterLimit?: number | null;
|
||||
/** Called when an add is blocked because `filterLimit` is already reached. */
|
||||
onFilterLimitReached?: () => void;
|
||||
}
|
||||
|
||||
function normalizeFilters(filters: FeatureFilters): FeatureFilters {
|
||||
|
|
@ -104,13 +108,25 @@ function getNextNumericKeyId(
|
|||
return max + 1;
|
||||
}
|
||||
|
||||
export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
||||
export function useFilters({
|
||||
initialFilters,
|
||||
features,
|
||||
filterLimit,
|
||||
onFilterLimitReached,
|
||||
}: UseFiltersOptions) {
|
||||
const initialFiltersRef = useRef<FeatureFilters | null>(null);
|
||||
if (!initialFiltersRef.current) {
|
||||
initialFiltersRef.current = normalizeFilters(initialFilters);
|
||||
}
|
||||
|
||||
const [filters, setFilters] = useState<FeatureFilters>(() => initialFiltersRef.current!);
|
||||
// Mirrors used by handleAddFilter's limit check so its identity stays stable.
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
const filterLimitRef = useRef(filterLimit);
|
||||
filterLimitRef.current = filterLimit;
|
||||
const onFilterLimitReachedRef = useRef(onFilterLimitReached);
|
||||
onFilterLimitReachedRef.current = onFilterLimitReached;
|
||||
const [activeFeature, setActiveFeature] = useState<string | null>(null);
|
||||
const [dragValue, setDragValue] = useState<[number, number] | null>(null);
|
||||
const [pinnedFeature, setPinnedFeature] = useState<string | null>(null);
|
||||
|
|
@ -175,6 +191,26 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Demo (unlicensed) users are capped at `filterLimit` simultaneous filters.
|
||||
// Re-applying an existing regular feature replaces it (no new key), so it's
|
||||
// always allowed; the special/generated filters always add a new key.
|
||||
const limit = filterLimitRef.current;
|
||||
if (limit != null) {
|
||||
const current = filtersRef.current;
|
||||
const addsNewKey =
|
||||
name === SCHOOL_FILTER_NAME ||
|
||||
name === SPECIFIC_CRIMES_FILTER_NAME ||
|
||||
name === ELECTION_VOTE_SHARE_FILTER_NAME ||
|
||||
name === ETHNICITIES_FILTER_NAME ||
|
||||
POI_FILTER_NAMES.includes(name as PoiFilterName) ||
|
||||
!(name in current);
|
||||
if (addsNewKey && Object.keys(current).length >= limit) {
|
||||
onFilterLimitReachedRef.current?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
trackEvent('Filter Add', { feature: name });
|
||||
setFilters((prev) => {
|
||||
undoStackRef.current.push(prev);
|
||||
|
|
@ -470,7 +506,16 @@ export function useFilters({ initialFilters, features }: UseFiltersOptions) {
|
|||
}, []);
|
||||
|
||||
const handleSetFilters = useCallback((newFilters: FeatureFilters) => {
|
||||
setFilters(normalizeFilters(newFilters));
|
||||
// Enforce the demo cap on bulk sets too (e.g. AI filters, which bypass the
|
||||
// per-add path). Without this, an unlicensed user could end up with >5 filters,
|
||||
// which the server then rejects (400), breaking the map.
|
||||
let next = newFilters;
|
||||
const limit = filterLimitRef.current;
|
||||
if (limit != null && Object.keys(next).length > limit) {
|
||||
next = Object.fromEntries(Object.entries(next).slice(0, limit));
|
||||
onFilterLimitReachedRef.current?.();
|
||||
}
|
||||
setFilters(normalizeFilters(next));
|
||||
setActiveFeature(null);
|
||||
setDragValue(null);
|
||||
setPinnedFeature(null);
|
||||
|
|
|
|||
|
|
@ -80,6 +80,18 @@ describe('useHexagonSelection', () => {
|
|||
return Promise.resolve(jsonResponse(stats(12)));
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/postcode/')) {
|
||||
const postcode = decodeURIComponent(url.pathname.slice('/api/postcode/'.length));
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
postcode,
|
||||
latitude: 51.5,
|
||||
longitude: -0.115,
|
||||
geometry: postcodeGeometry,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/postcode-properties') {
|
||||
return Promise.resolve(
|
||||
jsonResponse({ properties: [], total: 0, offset: 0, truncated: false })
|
||||
|
|
@ -428,4 +440,60 @@ describe('useHexagonSelection', () => {
|
|||
expect(allPropertiesParams.has('filters')).toBe(false);
|
||||
expect(allPropertiesParams.has('travel')).toBe(false);
|
||||
});
|
||||
|
||||
it('restores the original clicked postcode after zooming out to a hexagon and back', async () => {
|
||||
// Stable references: the sync effect synchronously resets properties when it
|
||||
// runs, so unstable filters/hexagonData/travel props (new identity each
|
||||
// render) would re-trigger it forever. In the app these come from memoised
|
||||
// hooks; mirror that here.
|
||||
const stableFilters = {};
|
||||
const stableHexagonData: never[] = [];
|
||||
const stableTravel: TravelTimeEntry[] = [];
|
||||
const { result, rerender } = renderHook(
|
||||
({ usePostcodeView }: { usePostcodeView: boolean }) =>
|
||||
useHexagonSelection({
|
||||
filters: stableFilters,
|
||||
features,
|
||||
hexagonData: stableHexagonData,
|
||||
resolution: 9,
|
||||
usePostcodeView,
|
||||
travelTimeEntries: stableTravel,
|
||||
}),
|
||||
{ initialProps: { usePostcodeView: true } }
|
||||
);
|
||||
|
||||
// Click a postcode whose neighbouring hexagon reports a *different*
|
||||
// central_postcode ('SW1A 1AA', see stats()).
|
||||
act(() => {
|
||||
result.current.handleHexagonClick('AB1 2CD', true, postcodeGeometry);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedHexagon?.id).toBe('AB1 2CD');
|
||||
expect(result.current.selectedHexagon?.type).toBe('postcode');
|
||||
});
|
||||
expect(result.current.selectedHexagon?.anchorPostcode?.id).toBe('AB1 2CD');
|
||||
|
||||
// Zoom out past the postcode threshold: the selection follows down to a hexagon.
|
||||
act(() => {
|
||||
rerender({ usePostcodeView: false });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedHexagon?.type).toBe('hexagon');
|
||||
});
|
||||
expect(result.current.selectedHexagon?.anchorPostcode?.id).toBe('AB1 2CD');
|
||||
|
||||
// Zoom back in: the original postcode must be restored, not the hexagon's
|
||||
// central_postcode ('SW1A 1AA').
|
||||
act(() => {
|
||||
rerender({ usePostcodeView: true });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedHexagon?.type).toBe('postcode');
|
||||
});
|
||||
expect(result.current.selectedHexagon?.id).toBe('AB1 2CD');
|
||||
expect(result.current.selectedPostcodeGeometry).toBe(postcodeGeometry);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,14 @@ interface SelectedHexagon {
|
|||
type: 'hexagon' | 'postcode';
|
||||
resolution: number;
|
||||
lockedResolution?: boolean;
|
||||
/**
|
||||
* The postcode the user is really tracking. Set when a postcode is selected
|
||||
* (or first derived from a hexagon) and carried through zoom-driven hexagon
|
||||
* conversions, so that zooming out and back in restores the same postcode
|
||||
* instead of drifting to a neighbour whose centre happens to fall in the same
|
||||
* cell. Cleared implicitly on every explicit (re)selection.
|
||||
*/
|
||||
anchorPostcode?: { id: string; geometry: PostcodeGeometry | null };
|
||||
}
|
||||
|
||||
interface JourneyDest {
|
||||
|
|
@ -331,7 +339,12 @@ export function useHexagonSelection({
|
|||
setSelectedPostcodeGeometry(null);
|
||||
} else {
|
||||
const type: SelectedHexagon['type'] = isPostcode ? 'postcode' : 'hexagon';
|
||||
const selection = { id, type, resolution };
|
||||
const selection: SelectedHexagon = {
|
||||
id,
|
||||
type,
|
||||
resolution,
|
||||
...(isPostcode ? { anchorPostcode: { id, geometry: geometry ?? null } } : {}),
|
||||
};
|
||||
const requestId = invalidateAreaRequests();
|
||||
invalidatePropertyRequests();
|
||||
trackEvent('Hexagon Click', { type });
|
||||
|
|
@ -445,7 +458,7 @@ export function useHexagonSelection({
|
|||
(usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
!selection.lockedResolution &&
|
||||
areaStats?.central_postcode != null) ||
|
||||
(selection.anchorPostcode != null || areaStats?.central_postcode != null)) ||
|
||||
(!usePostcodeView && selection.type === 'postcode' && !selection.lockedResolution) ||
|
||||
(!usePostcodeView &&
|
||||
selection.type === 'hexagon' &&
|
||||
|
|
@ -483,19 +496,29 @@ export function useHexagonSelection({
|
|||
let nextStats: HexagonStatsResponse | null = null;
|
||||
|
||||
if (usePostcodeView && selection.type === 'hexagon' && !selection.lockedResolution) {
|
||||
if (!areaStats?.central_postcode) return;
|
||||
const lookup = await fetchPostcodeLookup(areaStats.central_postcode, controller.signal);
|
||||
nextSelection = { id: lookup.postcode, type: 'postcode', resolution };
|
||||
nextGeometry = lookup.geometry;
|
||||
nextStats = await fetchPostcodeStats(
|
||||
lookup.postcode,
|
||||
controller.signal,
|
||||
areaStatsUseFilters
|
||||
);
|
||||
// Prefer the anchored postcode (the one the user originally tracked) so
|
||||
// a zoom-out/zoom-in round-trip restores it exactly, rather than the
|
||||
// hexagon's current central_postcode, which may be a neighbour.
|
||||
const anchor = selection.anchorPostcode;
|
||||
const postcode = anchor?.id ?? areaStats?.central_postcode;
|
||||
if (!postcode) return;
|
||||
let geometry = anchor?.geometry ?? null;
|
||||
if (!geometry) {
|
||||
geometry = (await fetchPostcodeLookup(postcode, controller.signal)).geometry;
|
||||
}
|
||||
nextSelection = {
|
||||
id: postcode,
|
||||
type: 'postcode',
|
||||
resolution,
|
||||
anchorPostcode: { id: postcode, geometry },
|
||||
};
|
||||
nextGeometry = geometry;
|
||||
nextStats = await fetchPostcodeStats(postcode, controller.signal, areaStatsUseFilters);
|
||||
} else if (!usePostcodeView && selection.type === 'postcode') {
|
||||
const lookup = await fetchPostcodeLookup(selection.id, controller.signal);
|
||||
const nextId = latLngToCell(lookup.latitude, lookup.longitude, resolution);
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
const anchor = selection.anchorPostcode ?? { id: selection.id, geometry: lookup.geometry };
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution, anchorPostcode: anchor };
|
||||
nextStats = await fetchHexagonStats(
|
||||
nextId,
|
||||
resolution,
|
||||
|
|
@ -514,7 +537,12 @@ export function useHexagonSelection({
|
|||
? cellToParent(selection.id, resolution)
|
||||
: overlappingHexagon?.h3;
|
||||
if (!nextId) return;
|
||||
nextSelection = { id: nextId, type: 'hexagon', resolution };
|
||||
nextSelection = {
|
||||
id: nextId,
|
||||
type: 'hexagon',
|
||||
resolution,
|
||||
anchorPostcode: selection.anchorPostcode,
|
||||
};
|
||||
nextStats = await fetchHexagonStats(
|
||||
nextId,
|
||||
resolution,
|
||||
|
|
|
|||
|
|
@ -457,6 +457,10 @@ export function useMapData({
|
|||
if (requestKey === latestDataRequestKeyRef.current && !isAbortError(err)) {
|
||||
logNonAbortError('Failed to fetch data', err);
|
||||
setLoading(false);
|
||||
// Mark this request settled so a terminal error (e.g. 400 filter_limit or
|
||||
// 429 rate_limited) clears the loading spinner instead of hanging it; the
|
||||
// last successful data stays visible.
|
||||
setLoadedDataKey(requestKey);
|
||||
}
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue