new demo mode & tenure
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 8m43s
CI / Check (push) Failing after 8m49s

This commit is contained in:
Andras Schmelczer 2026-06-17 07:54:30 +01:00
parent 7656f24544
commit 4a0f00f2a4
64 changed files with 2875 additions and 338 deletions

View file

@ -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({

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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,

View file

@ -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);