From 920119ff48a872b849a29eec675d87570b25477e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 12 Jul 2026 15:10:26 +0100 Subject: [PATCH] Boring changes --- frontend/src/hooks/useActualListings.ts | 2 +- frontend/src/hooks/useAuth.ts | 70 +++++-- frontend/src/hooks/useClickedListings.test.ts | 2 +- frontend/src/hooks/useClickedListings.ts | 6 +- .../src/hooks/useDeckLayers.collision.test.ts | 103 ++++++++++ frontend/src/hooks/useDeckLayers.ts | 16 +- frontend/src/hooks/useDevelopmentLayers.ts | 4 +- frontend/src/hooks/useDevelopments.ts | 2 +- frontend/src/hooks/useFilterCounts.ts | 4 +- frontend/src/hooks/useFilters.ts | 73 +++++-- frontend/src/hooks/useHexagonSelection.ts | 4 +- frontend/src/hooks/useListingLayers.ts | 23 ++- frontend/src/hooks/useLocationSearch.ts | 4 +- frontend/src/hooks/useMapData.ts | 10 +- frontend/src/hooks/usePaneResize.ts | 2 +- frontend/src/hooks/usePoiLayers.test.ts | 2 +- frontend/src/hooks/usePoiLayers.ts | 2 +- frontend/src/hooks/useSavedSearches.ts | 2 +- frontend/src/hooks/useUrlSync.ts | 8 +- frontend/src/i18n/descriptions.ts | 20 +- frontend/src/i18n/details.ts | 52 +++-- frontend/src/i18n/index.test.ts | 37 ++++ frontend/src/i18n/index.ts | 38 +++- frontend/src/lib/api.test.ts | 18 ++ frontend/src/lib/api.ts | 2 + frontend/src/lib/auth-errors.test.ts | 46 +++++ frontend/src/lib/auth-errors.ts | 79 ++++++++ frontend/src/lib/council-filter.test.ts | 113 +++++++++++ frontend/src/lib/council-filter.ts | 187 ++++++++++++++++++ frontend/src/lib/nearby-stations.ts | 2 +- frontend/src/lib/overlays.ts | 8 +- frontend/src/lib/url-state.ts | 59 +++++- 32 files changed, 912 insertions(+), 88 deletions(-) create mode 100644 frontend/src/hooks/useDeckLayers.collision.test.ts create mode 100644 frontend/src/i18n/index.test.ts create mode 100644 frontend/src/lib/auth-errors.test.ts create mode 100644 frontend/src/lib/auth-errors.ts create mode 100644 frontend/src/lib/council-filter.test.ts create mode 100644 frontend/src/lib/council-filter.ts diff --git a/frontend/src/hooks/useActualListings.ts b/frontend/src/hooks/useActualListings.ts index 813ad5d..7af8552 100644 --- a/frontend/src/hooks/useActualListings.ts +++ b/frontend/src/hooks/useActualListings.ts @@ -70,7 +70,7 @@ export function useActualListings( if (debounceRef.current) clearTimeout(debounceRef.current); abortControllerRef.current?.abort(); }; - // listings intentionally excluded — it's internal state, not an input. + // listings intentionally excluded: it's internal state, not an input. // eslint-disable-next-line react-hooks/exhaustive-deps }, [bounds, filterParam, travelParam, shareCode]); diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 263f3cf..c684692 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import pb from '../lib/pocketbase'; import { trackEvent } from '../lib/analytics'; +import { resolveAuthError, type AuthErrorAction } from '../lib/auth-errors'; export interface AuthUser { id: string; @@ -41,6 +42,7 @@ export function useAuth() { }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [errorAction, setErrorAction] = useState(null); // Sync with authStore changes (cross-tab, external updates) useEffect(() => { @@ -58,13 +60,15 @@ export function useAuth() { async (email: string, password: string) => { setLoading(true); setError(null); + setErrorAction(null); try { const result = await pb.collection('users').authWithPassword(email, password); setUser(recordToUser(result.record)); trackEvent('Login', { method: 'email' }); } catch (err) { - const msg = err instanceof Error ? err.message : t('auth.loginFailed'); - setError(msg); + const { message, action } = resolveAuthError(err, 'login', t); + setError(message); + setErrorAction(action); throw err; } finally { setLoading(false); @@ -77,21 +81,41 @@ export function useAuth() { async (email: string, password: string) => { setLoading(true); setError(null); + setErrorAction(null); try { - await pb.collection('users').create({ - email, - password, - passwordConfirm: password, - newsletter: true, - }); - // Auto-login after registration - const result = await pb.collection('users').authWithPassword(email, password); - setUser(recordToUser(result.record)); - trackEvent('Register'); - } catch (err) { - const msg = err instanceof Error ? err.message : t('auth.registrationFailed'); - setError(msg); - throw err; + // Step 1: create the account. A failure here means nothing was created, + // so surface a friendly, field-aware message (e.g. "email already exists"). + try { + await pb.collection('users').create({ + email, + password, + passwordConfirm: password, + newsletter: true, + }); + } catch (err) { + const { message, action } = resolveAuthError(err, 'register', t); + setError(message); + setErrorAction(action); + throw err; + } + // Step 2: the account now EXISTS. If auto-login fails (commonly a 429 + // rate limit from the two back-to-back writes) the account is NOT + // orphaned: tell the user it was created and let them log in with the + // same credentials, instead of a misleading "registration failed". + try { + const result = await pb.collection('users').authWithPassword(email, password); + setUser(recordToUser(result.record)); + trackEvent('Register'); + } catch (err) { + const status = (err as { status?: number } | null)?.status; + setError( + status === 429 + ? t('auth.accountCreatedRateLimited') + : t('auth.accountCreatedPleaseLogIn') + ); + setErrorAction('switchToLogin'); + throw err; + } } finally { setLoading(false); } @@ -103,6 +127,7 @@ export function useAuth() { async (provider: string) => { setLoading(true); setError(null); + setErrorAction(null); try { const result = await pb.collection('users').authWithOAuth2({ provider, @@ -111,8 +136,9 @@ export function useAuth() { setUser(recordToUser(result.record)); trackEvent('Login', { method: provider }); } catch (err) { - const msg = err instanceof Error ? err.message : t('auth.oauthFailed'); - setError(msg); + const { message, action } = resolveAuthError(err, 'oauth', t); + setError(message); + setErrorAction(action); throw err; } finally { setLoading(false); @@ -131,11 +157,13 @@ export function useAuth() { async (email: string) => { setLoading(true); setError(null); + setErrorAction(null); try { await pb.collection('users').requestPasswordReset(email); } catch (err) { - const msg = err instanceof Error ? err.message : t('auth.passwordResetFailed'); - setError(msg); + const { message, action } = resolveAuthError(err, 'reset', t); + setError(message); + setErrorAction(action); throw err; } finally { setLoading(false); @@ -167,12 +195,14 @@ export function useAuth() { const clearError = useCallback(() => { setError(null); + setErrorAction(null); }, []); return { user, loading, error, + errorAction, login, register, loginWithOAuth, diff --git a/frontend/src/hooks/useClickedListings.test.ts b/frontend/src/hooks/useClickedListings.test.ts index 78ff75e..d4e68f3 100644 --- a/frontend/src/hooks/useClickedListings.test.ts +++ b/frontend/src/hooks/useClickedListings.test.ts @@ -45,7 +45,7 @@ describe('useClickedListings', () => { act(() => result.current.markClicked('https://example.com/b')); - // Visible immediately — no need to await the network round-trip. + // Visible immediately. No need to await the network round-trip. expect(result.current.clickedUrls.has('https://example.com/b')).toBe(true); expect(mocks.create).toHaveBeenCalledWith({ user: 'user-1', diff --git a/frontend/src/hooks/useClickedListings.ts b/frontend/src/hooks/useClickedListings.ts index ae28fd0..6ca81f6 100644 --- a/frontend/src/hooks/useClickedListings.ts +++ b/frontend/src/hooks/useClickedListings.ts @@ -12,7 +12,7 @@ function currentUserId(): string | null { /** Tracks which listings the signed-in user has opened. * - * Reads the user from the shared PocketBase authStore so it stays self-contained — no need to + * Reads the user from the shared PocketBase authStore so it stays self-contained, with no need to * thread a user id down through the map layer tree. Returns a Set for O(1) membership checks in * the map's colour accessor; a fresh Set instance is produced on every change so it can double * as a deck.gl `updateTrigger` (identity-compared). @@ -27,7 +27,7 @@ export function useClickedListings() { const userIdRef = useRef(userId); userIdRef.current = userId; // Mirrors the latest committed set so markClicked can decide (synchronously) whether a url is - // new — without depending on the async state updater having run yet. + // new, without depending on the async state updater having run yet. const clickedUrlsRef = useRef(clickedUrls); clickedUrlsRef.current = clickedUrls; @@ -73,7 +73,7 @@ export function useClickedListings() { pb.collection(CLICKED_LISTINGS_COLLECTION) .create({ user: uid, url }) .catch(() => { - // Ignore duplicate-index conflicts and transient failures — local state already + // Ignore duplicate-index conflicts and transient failures; local state already // reflects the click, and a missed write only means it won't persist to the next visit. }); }, []); diff --git a/frontend/src/hooks/useDeckLayers.collision.test.ts b/frontend/src/hooks/useDeckLayers.collision.test.ts new file mode 100644 index 0000000..e171843 --- /dev/null +++ b/frontend/src/hooks/useDeckLayers.collision.test.ts @@ -0,0 +1,103 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { CollisionFilterExtension } from '@deck.gl/extensions'; + +import type { PostcodeFeature } from '../types'; +import { useDeckLayers } from './useDeckLayers'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('../lib/analytics', () => ({ + trackEvent: vi.fn(), +})); + +const SQUARE: [number, number][] = [ + [0, 0], + [0, 0.001], + [0.001, 0.001], + [0.001, 0], + [0, 0], +]; + +function postcode(code: string, count: number, centroid: [number, number]): PostcodeFeature { + return { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [SQUARE] }, + properties: { postcode: code, count, centroid }, + }; +} + +function layerById(layers: readonly unknown[], id: string) { + const layer = layers.find((item) => (item as { id?: string }).id === id); + if (!layer) throw new Error(`Layer ${id} not found`); + return layer as { props: Record }; +} + +describe('useDeckLayers postcode label decluttering', () => { + it('attaches the collision filter extension to postcode labels at z16', () => { + const { result } = renderHook(() => + useDeckLayers({ + data: [], + postcodeData: [ + postcode('SW1A 1AA', 9, [-0.1, 51.5]), + postcode('SW1A 2AA', 3, [-0.1001, 51.5001]), + ], + usePostcodeView: true, + zoom: 16, + pois: [], + actualListings: [], + developments: [], + viewFeature: null, + colorRange: null, + filterRange: null, + features: [], + selectedHexagonId: null, + hoveredHexagonId: null, + onHexagonClick: () => {}, + onHexagonHover: () => {}, + theme: 'light', + mapDataBeforeId: '', + }) + ); + + const labels = layerById(result.current.layers, 'postcode-labels'); + expect(labels.props.collisionEnabled).toBe(true); + expect(labels.props.collisionGroup).toBe('postcode-labels'); + expect(labels.props.collisionTestProps).toEqual({ sizeScale: 2 }); + expect( + (labels.props.extensions as unknown[]).some((ext) => ext instanceof CollisionFilterExtension) + ).toBe(true); + + const getPriority = labels.props.getCollisionPriority as (f: PostcodeFeature) => number; + expect(getPriority(postcode('A', 9, [0, 0]))).toBe(9); + expect(getPriority(postcode('B', 5000, [0, 0]))).toBe(1000); + }); + + it('omits postcode labels below z16', () => { + const { result } = renderHook(() => + useDeckLayers({ + data: [], + postcodeData: [postcode('SW1A 1AA', 9, [-0.1, 51.5])], + usePostcodeView: true, + zoom: 15, + pois: [], + actualListings: [], + developments: [], + viewFeature: null, + colorRange: null, + filterRange: null, + features: [], + selectedHexagonId: null, + hoveredHexagonId: null, + onHexagonClick: () => {}, + onHexagonHover: () => {}, + theme: 'light', + mapDataBeforeId: '', + }) + ); + const ids = result.current.layers.map((l: unknown) => (l as { id?: string }).id); + expect(ids).not.toContain('postcode-labels'); + }); +}); diff --git a/frontend/src/hooks/useDeckLayers.ts b/frontend/src/hooks/useDeckLayers.ts index 25a3640..fa62a48 100644 --- a/frontend/src/hooks/useDeckLayers.ts +++ b/frontend/src/hooks/useDeckLayers.ts @@ -1,6 +1,8 @@ import { useCallback, useRef, useState, useMemo, useEffect } from 'react'; import { H3HexagonLayer } from '@deck.gl/geo-layers'; import { GeoJsonLayer, TextLayer, ScatterplotLayer } from '@deck.gl/layers'; +import { CollisionFilterExtension } from '@deck.gl/extensions'; +import type { CollisionFilterExtensionProps } from '@deck.gl/extensions'; import { cellToBoundary, isValidCell } from 'h3-js'; import type { PickingInfo } from '@deck.gl/core'; import type { @@ -644,7 +646,7 @@ export function useDeckLayers({ const postcodeLabelsLayer = useMemo( () => - new TextLayer({ + new TextLayer>({ id: 'postcode-labels', data: labeledPostcodeData, getPosition: (f) => f.properties.centroid, @@ -662,6 +664,18 @@ export function useDeckLayers({ sizeMaxPixels: 14, billboard: false, pickable: false, + // Declutter overlapping postcode labels in dense areas (z16+). The + // CollisionFilterExtension renders an offscreen collision map and hides + // any label whose (enlarged) footprint overlaps a higher-priority one, + // so adjacent postcodes stop smearing into an unreadable cluster. + extensions: [new CollisionFilterExtension()], + collisionEnabled: true, + collisionGroup: 'postcode-labels', + // Enlarge each label's footprint while testing for collisions so the + // labels that survive keep visible breathing room (acts like padding). + collisionTestProps: { sizeScale: 2 }, + // When two labels collide, keep the postcode covering more properties. + getCollisionPriority: (f) => Math.max(-1000, Math.min(1000, f.properties.count)), }), [labeledPostcodeData, theme] ); diff --git a/frontend/src/hooks/useDevelopmentLayers.ts b/frontend/src/hooks/useDevelopmentLayers.ts index 8275c71..f1833d8 100644 --- a/frontend/src/hooks/useDevelopmentLayers.ts +++ b/frontend/src/hooks/useDevelopmentLayers.ts @@ -33,8 +33,8 @@ function radiusForDwellings(d: Development): number { /** * Renders development sites as blue point markers (a soft shadow, a pin, and a - * dwelling-count label at higher zoom). Sparse data — the endpoint caps the - * viewport at a few thousand points — so no clustering is needed, unlike the + * dwelling-count label at higher zoom). Sparse data (the endpoint caps the + * viewport at a few thousand points) so no clustering is needed, unlike the * listings layer. Hover shows a popup; click opens the planning record. */ export function useDevelopmentLayers({ developments, zoom, isDark }: UseDevelopmentLayersProps) { diff --git a/frontend/src/hooks/useDevelopments.ts b/frontend/src/hooks/useDevelopments.ts index 0b2c768..c97f3b3 100644 --- a/frontend/src/hooks/useDevelopments.ts +++ b/frontend/src/hooks/useDevelopments.ts @@ -73,7 +73,7 @@ export function useDevelopments( if (debounceRef.current) clearTimeout(debounceRef.current); abortControllerRef.current?.abort(); }; - // developments intentionally excluded — it's internal state, not an input. + // developments intentionally excluded: it's internal state, not an input. // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, bounds, shareCode]); diff --git a/frontend/src/hooks/useFilterCounts.ts b/frontend/src/hooks/useFilterCounts.ts index 9e55259..c75750f 100644 --- a/frontend/src/hooks/useFilterCounts.ts +++ b/frontend/src/hooks/useFilterCounts.ts @@ -15,7 +15,7 @@ interface FilterCountsResponse { * Single source of truth for the in-view property count. Returns: * - `total`: exact count of in-bounds properties passing the active filters * (or the full in-bounds total when there are none); null until first load. - * - `impacts`: per-filter rejection counts — how many in-bounds properties + * - `impacts`: per-filter rejection counts, how many in-bounds properties * each active filter removes. */ export function useFilterCounts( @@ -28,7 +28,7 @@ export function useFilterCounts( const [impacts, setImpacts] = useState>({}); // null = not loaded yet for the current bounds (hide the counter until the // first response). The number is the exact count of in-bounds properties - // passing the active filters — and, with no filters, the total in bounds. + // passing the active filters (and, with no filters, the total in bounds). const [total, setTotal] = useState(null); const debounceRef = useRef | null>(null); const abortRef = useRef(null); diff --git a/frontend/src/hooks/useFilters.ts b/frontend/src/hooks/useFilters.ts index 478d724..edaf034 100644 --- a/frontend/src/hooks/useFilters.ts +++ b/frontend/src/hooks/useFilters.ts @@ -58,6 +58,14 @@ import { getTenureFilterKeyId, normalizeTenureFilters, } from '../lib/tenure-filter'; +import { + COUNCIL_FILTER_NAME, + createCouncilFilterKey, + getCouncilFeatureName, + getCouncilFilterKeyId, + getDefaultCouncilFeatureName, + normalizeCouncilFilters, +} from '../lib/council-filter'; import { POI_FILTER_NAMES, createPoiFilterKey, @@ -87,12 +95,15 @@ interface UseFiltersOptions { function normalizeFilters(filters: FeatureFilters): FeatureFilters { return normalizePoiDistanceFilters( - normalizeTenureFilters( - normalizeQualificationFilters( - normalizeEthnicityFilters( - normalizeElectionVoteShareFilters( - normalizeCrimeSeverityFilters( - normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters)) + // Council folds AFTER tenure so a bare "% Social rent" is claimed by tenure. + normalizeCouncilFilters( + normalizeTenureFilters( + normalizeQualificationFilters( + normalizeEthnicityFilters( + normalizeElectionVoteShareFilters( + normalizeCrimeSeverityFilters( + normalizeSpecificCrimeFilters(normalizeSchoolFilters(filters)) + ) ) ) ) @@ -110,6 +121,7 @@ function getBackendFeatureName(name: string): string { getEthnicityFeatureName(name) ?? getQualificationFeatureName(name) ?? getTenureFeatureName(name) ?? + getCouncilFeatureName(name) ?? getPoiDistanceFeatureName(name) ?? name ); @@ -195,8 +207,8 @@ export function useFilters({ const originalNormalizedRef = useRef(null); if (!initialFiltersRef.current) { // Clamp an incoming filter set (e.g. from a bookmarked or shared URL) to the - // user's remaining budget — the cap minus slots already taken by travel-time - // filters — so a non-paying user never starts above the cap, which the server + // user's remaining budget (the cap minus slots already taken by travel-time + // filters) so a non-paying user never starts above the cap, which the server // would reject (400), blanking the map on first load. Unknown keys are dropped // first WHEN the feature list is already loaded (in-app navigation); on a cold load // features arrive after this runs, so the prune effect below re-derives from source. @@ -246,6 +258,9 @@ export function useFilters({ const tenureFilterIdRef = useRef( getNextNumericKeyId(initialFiltersRef.current!, getTenureFilterKeyId) ); + const councilFilterIdRef = useRef( + getNextNumericKeyId(initialFiltersRef.current!, getCouncilFilterKeyId) + ); const poiDistanceFilterIdRef = useRef( getNextNumericKeyId(initialFiltersRef.current!, getPoiDistanceFilterKeyId) ); @@ -318,6 +333,7 @@ export function useFilters({ name !== ETHNICITIES_FILTER_NAME && name !== QUALIFICATIONS_FILTER_NAME && name !== TENURE_FILTER_NAME && + name !== COUNCIL_FILTER_NAME && !POI_FILTER_NAMES.includes(name as PoiFilterName) && !meta ) { @@ -329,7 +345,7 @@ export function useFilters({ // 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. // `lockAdds` (a non-paying user on a shared link) blocks every new key - // regardless of count — they can adjust the shared set but not build on it. + // regardless of count: they can adjust the shared set but not build on it. const limit = filterLimitRef.current; if (limit != null || lockAddsRef.current) { const current = filtersRef.current; @@ -341,6 +357,7 @@ export function useFilters({ name === ETHNICITIES_FILTER_NAME || name === QUALIFICATIONS_FILTER_NAME || name === TENURE_FILTER_NAME || + name === COUNCIL_FILTER_NAME || POI_FILTER_NAMES.includes(name as PoiFilterName) || !(name in current); const overLimit = @@ -479,6 +496,21 @@ export function useFilters({ ], }; } + if (name === COUNCIL_FILTER_NAME) { + const defaultCouncilFeatureName = getDefaultCouncilFeatureName(features); + const defaultCouncilFeature = defaultCouncilFeatureName + ? features.find((feature) => feature.name === defaultCouncilFeatureName) + : undefined; + if (!defaultCouncilFeatureName) return prev; + + return { + ...prev, + [createCouncilFilterKey(defaultCouncilFeatureName, councilFilterIdRef.current++)]: [ + defaultCouncilFeature?.histogram?.min ?? defaultCouncilFeature?.min ?? 0, + defaultCouncilFeature?.histogram?.max ?? defaultCouncilFeature?.max ?? 100, + ], + }; + } if (POI_FILTER_NAMES.includes(name as PoiFilterName)) { const poiFilterName = name as PoiFilterName; const defaultPoiFeatureName = getDefaultPoiFilterFeatureName(features, poiFilterName); @@ -647,6 +679,23 @@ export function useFilters({ if (replaced) return normalizeFilters(next); } + const councilKeyId = getCouncilFilterKeyId(name); + if (councilKeyId != null) { + let replaced = false; + const next: FeatureFilters = {}; + for (const [existingName, existingValue] of Object.entries(prev)) { + if (getCouncilFilterKeyId(existingName) === councilKeyId) { + if (!replaced) { + next[name] = value; + replaced = true; + } + continue; + } + next[existingName] = existingValue; + } + if (replaced) return normalizeFilters(next); + } + const electionVoteShareKeyId = getElectionVoteShareFilterKeyId(name); if (electionVoteShareKeyId != null) { let replaced = false; @@ -719,7 +768,7 @@ export function useFilters({ const handleDragEnd = useCallback(() => { if (pendingDragRef.current) { - // Click without drag — no filter value was changed, just clear preview state. + // Click without drag: no filter value was changed, just clear preview state. pendingDragRef.current = null; setActiveFeature(null); setDragValue(null); @@ -737,7 +786,7 @@ export function useFilters({ dragValueRef.current = null; }, []); - /** End drag without committing to filters — caller handles the commit (e.g. travel time). */ + /** End drag without committing to filters; caller handles the commit (e.g. travel time). */ const handleDragEndNoCommit = useCallback((): [number, number] | null => { if (pendingDragRef.current) { pendingDragRef.current = null; @@ -760,7 +809,7 @@ export function useFilters({ // which the server then rejects (400), breaking the map. let next = newFilters; // On a shared link (lockAdds), a non-paying user may adjust the shared filters - // but not introduce new keys — enforce that on bulk sets too, not just in the + // but not introduce new keys: enforce that on bulk sets too, not just in the // per-add path, so the AI/bulk path can't be used to build a fresh set on a // shared link. Keep only keys already present; signal if any were dropped. if (lockAddsRef.current) { diff --git a/frontend/src/hooks/useHexagonSelection.ts b/frontend/src/hooks/useHexagonSelection.ts index d73607b..527df9a 100644 --- a/frontend/src/hooks/useHexagonSelection.ts +++ b/frontend/src/hooks/useHexagonSelection.ts @@ -59,7 +59,7 @@ interface UseHexagonSelectionOptions { travelTimeEntries: TravelTimeEntry[]; areaStatsFields?: string[]; shareCode?: string; - /** First transit destination — used to pick the best central_postcode for journey display. */ + /** First transit destination, used to pick the best central_postcode for journey display. */ journeyDest?: JourneyDest | null; } @@ -336,7 +336,7 @@ export function useHexagonSelection({ // Lazily fetch a page of individual crimes for the current selection. The // records are an attribute of the area (filter-independent), so this carries - // no filter string — only the selection identity and share code. + // no filter string, only the selection identity and share code. const loadCrimeRecords = useCallback( (offset: number) => { if (!selectedHexagon) { diff --git a/frontend/src/hooks/useListingLayers.ts b/frontend/src/hooks/useListingLayers.ts index 25c49ab..581974d 100644 --- a/frontend/src/hooks/useListingLayers.ts +++ b/frontend/src/hooks/useListingLayers.ts @@ -84,10 +84,19 @@ function getClusterListings( clusterId: number, limit: number ): ActualListing[] { - return index - .getLeaves(clusterId, limit, 0) - .map((feature) => feature.properties) - .sort(compareListingsForDisplay); + // A background refetch (every pan/zoom rebuilds the index) can leave a picked + // cluster object — or a still-selected cluster in state — holding a clusterId + // that was minted by a now-superseded index. Supercluster decodes an origin + // id/zoom from the id and throws "No cluster with the specified id." when it + // doesn't resolve against this index. The fresh clusters are already on screen, + // so degrade to an empty result instead of crashing to the error boundary. + let leaves: Supercluster.PointFeature[]; + try { + leaves = index.getLeaves(clusterId, limit, 0); + } catch { + return []; + } + return leaves.map((feature) => feature.properties).sort(compareListingsForDisplay); } function offsetLngLat( @@ -124,7 +133,7 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro useEffect(() => { // Each refetch returns a fresh listings array and rebuilds the cluster index, so a - // previously selected cluster's id is no longer valid (getLeaves would throw) — + // previously selected cluster's id is no longer valid (getLeaves would throw), so // always collapse the spiderfy. A locked popup, however, holds a self-contained // snapshot of the listings captured at click time, so preserve it across background // refetches instead of dismissing it (mirrors clearUnlockedPopup). @@ -313,6 +322,10 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro cluster.clusterId, LISTING_CLUSTER_POPUP_LIMIT ); + // Stale clusterId against a rebuilt index: ignore the click rather than + // opening an empty popup with a phantom count. The user's next click lands + // on a cluster from the current index. + if (clusterListings.length === 0) return; setSelectedCluster(cluster); setPopupInfo({ mode: 'cluster', diff --git a/frontend/src/hooks/useLocationSearch.ts b/frontend/src/hooks/useLocationSearch.ts index ddb4e26..de5875c 100644 --- a/frontend/src/hooks/useLocationSearch.ts +++ b/frontend/src/hooks/useLocationSearch.ts @@ -6,7 +6,7 @@ const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent'; const RECENT_SEARCH_LIMIT = 10; /** Matches a full UK postcode with complete inward code (e.g. "E14 2DG", "SW1A1AA"). - * Outcodes like "E14" or "SW1A" intentionally do NOT match — they go through /api/places instead. */ + * Outcodes like "E14" or "SW1A" intentionally do NOT match; they go through /api/places instead. */ const FULL_POSTCODE_RE = /^[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}$/i; function looksLikePostcode(s: string) { @@ -331,7 +331,7 @@ export function useLocationSearch(mode?: string, getViewportCenter?: () => Viewp return; } lastResultsRef.current = combinedResults; - // Trust the server's unified ranking — re-filtering here previously dropped valid + // Trust the server's unified ranking: re-filtering here previously dropped valid // alias and partial-postcode matches. The optimistic pre-fetch path still filters. setResults(combinedResults); setOpen(true); diff --git a/frontend/src/hooks/useMapData.ts b/frontend/src/hooks/useMapData.ts index bddb90f..5d2207b 100644 --- a/frontend/src/hooks/useMapData.ts +++ b/frontend/src/hooks/useMapData.ts @@ -23,6 +23,7 @@ import { getElectionVoteShareFeatureName } from '../lib/election-filter'; import { getEthnicityFeatureName } from '../lib/ethnicity-filter'; import { getQualificationFeatureName } from '../lib/qualification-filter'; import { getTenureFeatureName } from '../lib/tenure-filter'; +import { getCouncilFeatureName } from '../lib/council-filter'; import { getPoiDistanceFeatureName } from '../lib/poi-distance-filter'; import { POSTCODE_ZOOM_THRESHOLD } from '../lib/consts'; import { COLOR_RANGE_LOW_PERCENTILE, COLOR_RANGE_HIGH_PERCENTILE } from '../lib/consts'; @@ -32,7 +33,7 @@ import pb from '../lib/pocketbase'; /** When a data request is rejected with the filter cap while we still hold a token, the * server didn't accept our session (deleted/banned account, rotated key) and applied the - * lower anonymous cap. Surface that so the caller can re-validate auth — which, if the + * lower anonymous cap. Surface that so the caller can re-validate auth, which, if the * session is gone, drops the user, lowers the cap, and re-clamps the filters, recovering * instead of looping on an empty map. A logged-in client only ever sends ≤ its own cap, * so a filter-cap rejection while authed is a reliable desync signal. */ @@ -72,7 +73,7 @@ interface UseMapDataOptions { * longer grants any special access (the region lock was removed). */ shareCode?: string; /** Called when the server rejects a data request with the filter cap while the client - * still holds a (locally-valid) token — i.e. a client/server session desync. */ + * still holds a (locally-valid) token, i.e. a client/server session desync. */ onSessionRejected?: () => void; } @@ -149,6 +150,7 @@ export function useMapData({ getEthnicityFeatureName(name) ?? getQualificationFeatureName(name) ?? getTenureFeatureName(name) ?? + getCouncilFeatureName(name) ?? getPoiDistanceFeatureName(name) ?? name, [] @@ -568,7 +570,7 @@ export function useMapData({ if (vals.length === 0) return null; // Typed-array sort uses the engine's optimized numeric sort with no - // per-element comparator call — measurably faster than `vals.sort((a,b)=>a-b)` + // per-element comparator call, measurably faster than `vals.sort((a,b)=>a-b)` // for the 5k–10k samples a busy viewport produces. const sorted = Float64Array.from(vals); sorted.sort(); @@ -733,7 +735,7 @@ export function useMapData({ ); // Treat the map as loading whenever the rendered hexagons don't match the - // current request — covers the brief window between a slider release and + // current request, covering the brief window between a slider release and // the main fetch effect actually firing setLoading(true). const isLoading = loading || (bounds != null && loadedDataKey !== dataRequestKey); diff --git a/frontend/src/hooks/usePaneResize.ts b/frontend/src/hooks/usePaneResize.ts index 4786391..d9c5572 100644 --- a/frontend/src/hooks/usePaneResize.ts +++ b/frontend/src/hooks/usePaneResize.ts @@ -83,7 +83,7 @@ export function usePaneResize( if (!draggingRef.current) return; liveSizeRef.current = computeSize(e); if (targetRef.current) { - // Batch DOM updates to once per animation frame — on mobile, pointermove + // Batch DOM updates to once per animation frame: on mobile, pointermove // can fire multiple times per frame, and each direct style.height write // forces a synchronous reflow that desynchronises MapLibre and deck.gl. if (rafRef.current == null) { diff --git a/frontend/src/hooks/usePoiLayers.test.ts b/frontend/src/hooks/usePoiLayers.test.ts index c90fc37..40a5d1f 100644 --- a/frontend/src/hooks/usePoiLayers.test.ts +++ b/frontend/src/hooks/usePoiLayers.test.ts @@ -212,7 +212,7 @@ describe('usePoiLayers', () => { }); expect(result.current.popupInfo?.id).toBe(supermarket.id); - // Disabling POIs empties the list — the popup must not stay stuck on screen. + // Disabling POIs empties the list, so the popup must not stay stuck on screen. rerender({ pois: [] }); expect(result.current.popupInfo).toBeNull(); }); diff --git a/frontend/src/hooks/usePoiLayers.ts b/frontend/src/hooks/usePoiLayers.ts index b0a3ebb..3595253 100644 --- a/frontend/src/hooks/usePoiLayers.ts +++ b/frontend/src/hooks/usePoiLayers.ts @@ -69,7 +69,7 @@ export function usePoiLayers({ pois, zoom, isDark }: UsePoiLayersProps) { const { t } = useTranslation(); const [popupInfo, setPopupInfo] = useState(null); - // Dismiss a lingering hover/cluster popup once the POIs behind it are gone — e.g. + // Dismiss a lingering hover/cluster popup once the POIs behind it are gone, e.g. // after the user clears the POI categories. Without this the card stays stuck on // screen because it is only otherwise cleared on mouse-leave or the close button. useEffect(() => { diff --git a/frontend/src/hooks/useSavedSearches.ts b/frontend/src/hooks/useSavedSearches.ts index 8743b3c..4c0dc30 100644 --- a/frontend/src/hooks/useSavedSearches.ts +++ b/frontend/src/hooks/useSavedSearches.ts @@ -104,7 +104,7 @@ export function useSavedSearches(userId: string | null) { if (!mapped.some((s) => !s.screenshotUrl)) return; scheduleNext(); } catch { - // Silent — background poll errors don't surface to UI; keep trying. + // Silent: background poll errors don't surface to UI; keep trying. if (isMountedRef.current) scheduleNext(); } finally { pollInFlightRef.current = false; diff --git a/frontend/src/hooks/useUrlSync.ts b/frontend/src/hooks/useUrlSync.ts index 2e7ac63..44143eb 100644 --- a/frontend/src/hooks/useUrlSync.ts +++ b/frontend/src/hooks/useUrlSync.ts @@ -3,6 +3,7 @@ import type { FeatureMeta, FeatureFilters } from '../types'; import { stateToParams } from '../lib/url-state'; import type { OverlayId } from '../lib/overlays'; import type { BasemapId } from '../lib/basemaps'; +import type { ListingsMode } from '../lib/listings'; import type { TravelTimeEntry } from './useTravelTime'; const URL_DEBOUNCE_MS = 300; @@ -19,7 +20,8 @@ export function useUrlSync( basemap?: BasemapId, selectedCrimeTypes?: Set, selectedPostcode?: string, - colorOpacity?: number + colorOpacity?: number, + listingsMode?: ListingsMode ) { const urlDebounceRef = useRef | null>(null); @@ -40,7 +42,8 @@ export function useUrlSync( basemap, selectedCrimeTypes, selectedPostcode, - colorOpacity + colorOpacity, + listingsMode ); const search = params.toString(); const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname; @@ -63,5 +66,6 @@ export function useUrlSync( selectedCrimeTypes, selectedPostcode, colorOpacity, + listingsMode, ]); } diff --git a/frontend/src/i18n/descriptions.ts b/frontend/src/i18n/descriptions.ts index ef88fa3..474d21e 100644 --- a/frontend/src/i18n/descriptions.ts +++ b/frontend/src/i18n/descriptions.ts @@ -4,7 +4,7 @@ import { details } from './details'; /** * Feature description translations, keyed by feature name. * - * English descriptions are NOT here — the server is the single source of truth + * English descriptions are NOT here. The server is the single source of truth * for English. Fix a typo in features.rs and it propagates automatically. * * Non-English translations are keyed by the stable feature name, so they’re @@ -95,6 +95,9 @@ const descriptions: Record> = { '% Social rent': "Part des ménages locataires auprès d'une collectivité locale ou d'un bailleur social", '% Private rent': 'Part des ménages locataires dans le privé ou logés à titre gratuit', + '% Council housing': + 'Part des logements du code postal déjà enregistrés comme logement municipal ou social', + '% Ex-council': 'Part des logements du code postal autrefois municipaux mais qui ne le sont plus', '% White': 'Part de la population s’identifiant comme blanche', '% South Asian': 'Part de la population s’identifiant comme sud-asiatique', '% Black': 'Part de la population s’identifiant comme noire', @@ -204,6 +207,10 @@ const descriptions: Record> = { '% Social rent': 'Anteil der Haushalte, die von einer Kommune oder Wohnungsbaugesellschaft mieten', '% Private rent': 'Anteil der Haushalte, die privat mieten oder mietfrei wohnen', + '% Council housing': + 'Anteil der Wohnungen im Postcode, die jemals als Kommunal- oder Sozialwohnung erfasst wurden', + '% Ex-council': + 'Anteil der Wohnungen im Postcode, die früher Kommunalwohnungen waren, es aber nicht mehr sind', '% White': 'Anteil der Personen, die sich als weiß identifizieren', '% South Asian': 'Anteil der Personen, die sich als südasiatisch identifizieren', '% Black': 'Anteil der Personen, die sich als schwarz identifizieren', @@ -289,6 +296,8 @@ const descriptions: Record> = { '% Owner occupied': '拥有自住房(全款或按揭)的家庭比例', '% Social rent': '向地方政府或住房协会租房的家庭比例', '% Private rent': '私人租房或免租居住的家庭比例', + '% Council housing': '邮编内曾被记录为公共住房或社会住房的房屋比例', + '% Ex-council': '邮编内曾是公共住房但现在不再是的房屋比例', '% White': '白人人口比例', '% South Asian': '南亚裔人口比例', '% Black': '黑人人口比例', @@ -380,6 +389,9 @@ const descriptions: Record> = { '% Owner occupied': 'अपने घर के स्वामी परिवारों का हिस्सा, चाहे पूर्ण रूप से या बंधक के साथ', '% Social rent': 'काउंसिल या हाउसिंग एसोसिएशन से किराए पर रहने वाले परिवारों का हिस्सा', '% Private rent': 'निजी रूप से किराए पर या बिना किराए के रहने वाले परिवारों का हिस्सा', + '% Council housing': + 'पोस्टकोड के उन घरों का हिस्सा जिन्हें कभी न कभी काउंसिल या सामाजिक आवास के रूप में दर्ज किया गया', + '% Ex-council': 'पोस्टकोड के उन घरों का हिस्सा जो पहले काउंसिल आवास थे लेकिन अब नहीं हैं', '% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत', '% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत', '% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत', @@ -481,6 +493,10 @@ const descriptions: Record> = { 'Azon háztartások aránya, amelyek önkormányzattól vagy lakásszövetkezettől bérelnek', '% Private rent': 'Azon háztartások aránya, amelyek magánbérleményben élnek vagy lakbér nélkül laknak', + '% Council housing': + 'Az irányítószámon belüli lakások aránya, amelyeket valaha önkormányzati vagy szociális lakásként rögzítettek', + '% Ex-council': + 'Az irányítószámon belüli lakások aránya, amelyek korábban önkormányzati lakások voltak, de már nem azok', '% White': 'A fehérként azonosított lakosság aránya', '% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya', '% Black': 'A feketeként azonosított lakosság aránya', @@ -514,7 +530,7 @@ const descriptions: Record> = { /** * The crime features carry a window suffix ("Burglary (/yr, 7y)"), but the * per-locale description/detail/icon maps are keyed by the bare type's legacy - * "(avg/yr)" name — and the text is the same for both windows. Map a crime + * "(avg/yr)" name, and the text is the same for both windows. Map a crime * feature name back to that legacy key so the existing translations resolve * without duplicating every entry per window. */ diff --git a/frontend/src/i18n/details.ts b/frontend/src/i18n/details.ts index d62cf3a..7c161aa 100644 --- a/frontend/src/i18n/details.ts +++ b/frontend/src/i18n/details.ts @@ -1,7 +1,7 @@ /** * Feature detail translations (the longer explanatory paragraph in the info card). * Same structure as descriptions: keyed by language, then by feature name. - * English details come from the server — NOT duplicated here. + * English details come from the server (not duplicated here). */ export const details: Record> = { fr: { @@ -104,17 +104,21 @@ export const details: Record> = { '% Apprenticeship': "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est un apprentissage.", '% A-levels': - "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est le A-levels, AS-levels, T-levels, un apprentissage avancé ou équivalent — généralement étudié après 16 ans et avant un diplôme universitaire.", + "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est le A-levels, AS-levels, T-levels, un apprentissage avancé ou équivalent, généralement étudié après 16 ans et avant un diplôme universitaire.", '% Degree or higher': - "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est de niveau universitaire ou supérieur — licence, master ou doctorat, foundation degree, HNC/HND, NVQ 4–5 ou qualification professionnelle supérieure. Le recensement ne distingue pas les diplômes de premier cycle de ceux de troisième cycle.", + "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est de niveau universitaire ou supérieur : licence, master ou doctorat, foundation degree, HNC/HND, NVQ 4–5 ou qualification professionnelle supérieure. Le recensement ne distingue pas les diplômes de premier cycle de ceux de troisième cycle.", '% Other qualifications': - "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est classé « autre » — qualifications professionnelles ou techniques non rattachées à un niveau britannique, et diplômes obtenus hors du Royaume-Uni.", + "D'après le recensement de 2021 (TS067). Le diplôme le plus élevé est classé « autre » : qualifications professionnelles ou techniques non rattachées à un niveau britannique, et diplômes obtenus hors du Royaume-Uni.", '% Owner occupied': "D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) qui possèdent leur logement sans crédit, le possèdent avec un emprunt ou un crédit, ou le détiennent en propriété partagée.", '% Social rent': "D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'une collectivité ou d'une autorité locale, ou d'un bailleur social ou autre bailleur à vocation sociale.", '% Private rent': "D'après le recensement de 2021 (TS054). Pourcentage des ménages du quartier (LSOA) locataires auprès d'un propriétaire privé ou d'une agence de location, plus la faible part logée à titre gratuit.", + '% Council housing': + "Estimé à partir des données d'occupation EPC au sein du code postal : la part de ses logements dont l'historique du certificat de performance énergétique indique que le bien a été à un moment un logement municipal ou social, qu'il soit encore un logement social aujourd'hui ou qu'il ait depuis été vendu (par exemple dans le cadre du Right to Buy). Les logements sans EPC sont comptés comme non municipaux, et un code postal compte peu de logements ; il s'agit donc d'une borne inférieure approximative qui reflète la couverture EPC et n'est pas directement comparable à la part de logements sociaux du recensement.", + '% Ex-council': + "Estimé à partir des données d'occupation EPC au sein du code postal : la part de ses logements autrefois enregistrés comme logement municipal ou social dont le certificat de performance énergétique le plus récent indique une autre forme d'occupation, généralement des logements vendus dans le cadre du Right to Buy. Les logements sans EPC sont comptés comme non municipaux, et un code postal compte peu de logements ; il s'agit donc d'une borne inférieure approximative qui reflète la couverture EPC et n'est pas directement comparable à la part de logements sociaux du recensement.", '% White': "Provient du Census 2021. Pourcentage de la population du quartier (LSOA) s'identifiant comme Blanc (anglais, gallois, écossais, nord-irlandais, britannique, irlandais, Gitan ou Voyageur irlandais, Rom, ou tout autre origine blanche).", '% South Asian': @@ -263,17 +267,21 @@ export const details: Record> = { 'Aus dem Census 2021 (TS067). Der höchste Abschluss entspricht etwa 5 oder mehr GCSEs mit den Noten 9–4 (A*–C), einer mittleren Ausbildung oder Gleichwertigem.', '% Apprenticeship': 'Aus dem Census 2021 (TS067). Der höchste Abschluss ist eine Ausbildung.', '% A-levels': - 'Aus dem Census 2021 (TS067). Der höchste Abschluss sind A-levels, AS-levels, T-levels, eine weiterführende Ausbildung oder Gleichwertiges — meist nach dem 16. Lebensjahr und vor einem Studienabschluss erworben.', + 'Aus dem Census 2021 (TS067). Der höchste Abschluss sind A-levels, AS-levels, T-levels, eine weiterführende Ausbildung oder Gleichwertiges, meist nach dem 16. Lebensjahr und vor einem Studienabschluss erworben.', '% Degree or higher': - 'Aus dem Census 2021 (TS067). Der höchste Abschluss liegt auf Hochschulniveau oder darüber — Bachelor, Master oder Doktortitel, Foundation Degree, HNC/HND, NVQ 4–5 oder höhere berufliche Qualifikation. Der Census unterscheidet nicht zwischen Bachelor- und Masterabschlüssen.', + 'Aus dem Census 2021 (TS067). Der höchste Abschluss liegt auf Hochschulniveau oder darüber: Bachelor, Master oder Doktortitel, Foundation Degree, HNC/HND, NVQ 4–5 oder höhere berufliche Qualifikation. Der Census unterscheidet nicht zwischen Bachelor- und Masterabschlüssen.', '% Other qualifications': - 'Aus dem Census 2021 (TS067). Der höchste Abschluss wird als „sonstiger“ eingestuft — berufliche oder fachliche Qualifikationen, die keinem britischen Niveau zugeordnet sind, sowie außerhalb des Vereinigten Königreichs erworbene Abschlüsse.', + 'Aus dem Census 2021 (TS067). Der höchste Abschluss wird als „sonstiger“ eingestuft: berufliche oder fachliche Qualifikationen, die keinem britischen Niveau zugeordnet sind, sowie außerhalb des Vereinigten Königreichs erworbene Abschlüsse.', '% Owner occupied': 'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die ihre Wohnung schuldenfrei besitzen, sie mit Hypothek oder Darlehen besitzen oder sie über gemeinschaftliches Eigentum (Shared Ownership) halten.', '% Social rent': 'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einer Kommune oder Gemeindeverwaltung oder von einer Wohnungsbaugesellschaft oder einem anderen sozialen Vermieter mieten.', '% Private rent': 'Aus dem Census 2021 (TS054). Prozentsatz der Haushalte in der Nachbarschaft (LSOA), die von einem privaten Vermieter oder einer Vermittlungsagentur mieten, zuzüglich des kleinen Anteils, der mietfrei wohnt.', + '% Council housing': + 'Geschätzt aus EPC-Mietverhältnisdaten innerhalb der Postleitzahl: der Prozentsatz ihrer Wohnungen, deren Energieausweis-Verlauf zeigt, dass die Wohnung irgendwann eine Kommunal- oder Sozialwohnung war, unabhängig davon, ob sie heute noch eine Sozialwohnung ist oder inzwischen verkauft wurde (zum Beispiel über Right to Buy). Wohnungen ohne EPC werden als nicht kommunal gezählt, und eine Postleitzahl umfasst nur wenige Wohnungen, daher ist dies eine grobe Untergrenze, die die EPC-Abdeckung widerspiegelt und nicht direkt mit dem Sozialmietanteil aus dem Census vergleichbar ist.', + '% Ex-council': + 'Geschätzt aus EPC-Mietverhältnisdaten innerhalb der Postleitzahl: der Prozentsatz ihrer Wohnungen, die einst als Kommunal- oder Sozialwohnung erfasst wurden und deren neuester Energieausweis eine andere Wohnform ausweist, typischerweise über Right to Buy verkaufte Wohnungen. Wohnungen ohne EPC werden als nicht kommunal gezählt, und eine Postleitzahl umfasst nur wenige Wohnungen, daher ist dies eine grobe Untergrenze, die die EPC-Abdeckung widerspiegelt und nicht direkt mit dem Sozialmietanteil aus dem Census vergleichbar ist.', '% White': 'Aus dem Census 2021. Prozentsatz der Bevölkerung der Nachbarschaft (LSOA), die sich als Weiß identifiziert (Englisch, Walisisch, Schottisch, Nordirisch, Britisch, Irisch, Sinti und Roma, Roma oder sonstiger weißer Hintergrund).', '% South Asian': @@ -420,17 +428,21 @@ export const details: Record> = { '数据来自 2021 年 Census(TS067)。最高学历约为 5 门或以上成绩为 9–4(A*–C)的 GCSE、中级学徒制或同等资格。', '% Apprenticeship': '数据来自 2021 年 Census(TS067)。最高学历为学徒制。', '% A-levels': - '数据来自 2021 年 Census(TS067)。最高学历为 A-levels、AS-levels、T-levels、高级学徒制或同等资格——通常在 16 岁之后、获得学位之前修读。', + '数据来自 2021 年 Census(TS067)。最高学历为 A-levels、AS-levels、T-levels、高级学徒制或同等资格,通常在 16 岁之后、获得学位之前修读。', '% Degree or higher': - '数据来自 2021 年 Census(TS067)。最高学历为学位及以上——学士、硕士或博士、预科学位、HNC/HND、NVQ 4–5,或更高级别的职业资格。该 Census 不区分本科与研究生学位。', + '数据来自 2021 年 Census(TS067)。最高学历为学位及以上:学士、硕士或博士、预科学位、HNC/HND、NVQ 4–5,或更高级别的职业资格。该 Census 不区分本科与研究生学位。', '% Other qualifications': - '数据来自 2021 年 Census(TS067)。最高学历被归为“其他”——未对应到英国级别的职业或专业资格,以及在英国境外取得的学历。', + '数据来自 2021 年 Census(TS067)。最高学历被归为“其他”:未对应到英国级别的职业或专业资格,以及在英国境外取得的学历。', '% Owner occupied': '数据来自 2021 年 Census(TS054)。本地社区(LSOA)中全款拥有自住房、以按揭或贷款拥有自住房,或通过共享产权持有住房的家庭百分比。', '% Social rent': '数据来自 2021 年 Census(TS054)。本地社区(LSOA)中向地方议会或地方政府,或向住房协会及其他社会房东租房的家庭百分比。', '% Private rent': '数据来自 2021 年 Census(TS054)。本地社区(LSOA)中向私人房东或租赁中介租房的家庭百分比,外加少量免租居住的家庭。', + '% Council housing': + '根据邮编内的 EPC 产权记录估算:该邮编中其能源性能证书(EPC)历史显示该房屋曾经是公共住房或社会住房的房屋百分比,无论它今天是否仍是社会住房,还是此后已被售出(例如通过 Right to Buy)。没有 EPC 的房屋被视为非公共住房,且一个邮编包含的房屋很少,因此这是一个粗略的下限,反映的是 EPC 覆盖范围,无法与 Census 的社会租赁占比直接比较。', + '% Ex-council': + '根据邮编内的 EPC 产权记录估算:该邮编中曾被记录为公共住房或社会住房、但其最新能源性能证书(EPC)显示为不同产权的房屋百分比,通常是通过 Right to Buy 出售的房屋。没有 EPC 的房屋被视为非公共住房,且一个邮编包含的房屋很少,因此这是一个粗略的下限,反映的是 EPC 覆盖范围,无法与 Census 的社会租赁占比直接比较。', '% White': '来自2021年Census。本地社区(LSOA)人口中认同为白人(英格兰人、威尔士人、苏格兰人、北爱尔兰人、英国人、爱尔兰人、吉普赛人或爱尔兰旅行者、罗姆人或其他白人背景)的百分比。', '% South Asian': @@ -574,17 +586,21 @@ export const details: Record> = { '2021 की Census (TS067) से। सर्वोच्च योग्यता लगभग 5 या अधिक GCSE है जिनके ग्रेड 9–4 (A*–C) हैं, एक इंटरमीडिएट अप्रेंटिसशिप, या समकक्ष।', '% Apprenticeship': '2021 की Census (TS067) से। सर्वोच्च योग्यता एक अप्रेंटिसशिप है।', '% A-levels': - '2021 की Census (TS067) से। सर्वोच्च योग्यता A-levels, AS-levels, T-levels, एक एडवांस्ड अप्रेंटिसशिप, या समकक्ष है — आमतौर पर 16 वर्ष की उम्र के बाद और डिग्री से पहले पढ़ी जाती है।', + '2021 की Census (TS067) से। सर्वोच्च योग्यता A-levels, AS-levels, T-levels, एक एडवांस्ड अप्रेंटिसशिप, या समकक्ष है, आमतौर पर 16 वर्ष की उम्र के बाद और डिग्री से पहले पढ़ी जाती है।', '% Degree or higher': - '2021 की Census (TS067) से। सर्वोच्च योग्यता डिग्री स्तर या उससे ऊपर है — बैचलर, मास्टर या PhD, फाउंडेशन डिग्री, HNC/HND, NVQ 4–5, या उच्च व्यावसायिक योग्यता। Census स्नातक और स्नातकोत्तर डिग्री में अंतर नहीं करती।', + '2021 की Census (TS067) से। सर्वोच्च योग्यता डिग्री स्तर या उससे ऊपर है: बैचलर, मास्टर या PhD, फाउंडेशन डिग्री, HNC/HND, NVQ 4–5, या उच्च व्यावसायिक योग्यता। Census स्नातक और स्नातकोत्तर डिग्री में अंतर नहीं करती।', '% Other qualifications': - "2021 की Census (TS067) से। सर्वोच्च योग्यता 'अन्य' श्रेणी में आती है — ऐसी व्यावसायिक या पेशेवर योग्यताएं जो किसी UK स्तर से मेल नहीं खातीं, और UK के बाहर प्राप्त योग्यताएं।", + "2021 की Census (TS067) से। सर्वोच्च योग्यता 'अन्य' श्रेणी में आती है: ऐसी व्यावसायिक या पेशेवर योग्यताएं जो किसी UK स्तर से मेल नहीं खातीं, और UK के बाहर प्राप्त योग्यताएं।", '% Owner occupied': '2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो अपना घर पूर्ण रूप से अपने पास रखते हैं, बंधक या ऋण के साथ रखते हैं, या साझा स्वामित्व के माध्यम से रखते हैं।', '% Social rent': '2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो स्थानीय काउंसिल या स्थानीय प्राधिकरण से, या किसी हाउसिंग एसोसिएशन या अन्य सामाजिक मकान-मालिक से किराए पर रहते हैं।', '% Private rent': '2021 की Census (TS054) से। पड़ोस (LSOA) में उन परिवारों का प्रतिशत जो किसी निजी मकान-मालिक या किराया एजेंसी से किराए पर रहते हैं, साथ ही बिना किराए के रहने वाला छोटा हिस्सा।', + '% Council housing': + 'पोस्टकोड के भीतर EPC स्वामित्व रिकॉर्ड से अनुमानित: उस पोस्टकोड के उन घरों का प्रतिशत जिनके ऊर्जा प्रदर्शन प्रमाणपत्र (EPC) के इतिहास से पता चलता है कि वह घर किसी समय काउंसिल या सामाजिक आवास था, चाहे वह आज भी सामाजिक आवास हो या तब से बेचा जा चुका हो (उदाहरण के लिए Right to Buy के तहत)। जिन घरों का कोई EPC नहीं है उन्हें गैर-काउंसिल माना जाता है, और एक पोस्टकोड में कुछ ही घर होते हैं, इसलिए यह एक मोटी निचली सीमा है जो EPC कवरेज को दर्शाती है और Census के सामाजिक-किराया हिस्से से सीधे तुलनीय नहीं है।', + '% Ex-council': + 'पोस्टकोड के भीतर EPC स्वामित्व रिकॉर्ड से अनुमानित: उस पोस्टकोड के उन घरों का प्रतिशत जिन्हें कभी काउंसिल या सामाजिक आवास के रूप में दर्ज किया गया था, लेकिन जिनका सबसे हालिया ऊर्जा प्रदर्शन प्रमाणपत्र (EPC) एक अलग स्वामित्व दिखाता है, आमतौर पर Right to Buy के तहत बेचे गए घर। जिन घरों का कोई EPC नहीं है उन्हें गैर-काउंसिल माना जाता है, और एक पोस्टकोड में कुछ ही घर होते हैं, इसलिए यह एक मोटी निचली सीमा है जो EPC कवरेज को दर्शाती है और Census के सामाजिक-किराया हिस्से से सीधे तुलनीय नहीं है।', '% White': 'Census 2021 से. स्थानीय पड़ोस (LSOA) की आबादी का प्रतिशत जो खुद को श्वेत (अंग्रेज़, वेल्श, स्कॉटिश, उत्तरी आयरिश, ब्रिटिश, आयरिश, जिप्सी या आयरिश ट्रैवलर, रोमा या किसी अन्य श्वेत पृष्ठभूमि) के रूप में पहचानता है.', '% South Asian': @@ -733,17 +749,21 @@ export const details: Record> = { 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség körülbelül 5 vagy több GCSE 9–4 (A*–C) osztályzattal, középszintű tanoncképzés vagy ezzel egyenértékű.', '% Apprenticeship': 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség tanoncképzés.', '% A-levels': - 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség A-levels, AS-levels, T-levels, emelt szintű tanoncképzés vagy ezzel egyenértékű — jellemzően 16 éves kor után és a diploma előtt szerezhető meg.', + 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség A-levels, AS-levels, T-levels, emelt szintű tanoncképzés vagy ezzel egyenértékű, jellemzően 16 éves kor után és a diploma előtt szerezhető meg.', '% Degree or higher': - 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség diploma szintű vagy afölötti — alapdiploma, mesterdiploma vagy PhD, foundation degree, HNC/HND, NVQ 4–5, vagy magasabb szakmai képesítés. A Census nem különbözteti meg az alap- és mesterszintű diplomákat.', + 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség diploma szintű vagy afölötti: alapdiploma, mesterdiploma vagy PhD, foundation degree, HNC/HND, NVQ 4–5, vagy magasabb szakmai képesítés. A Census nem különbözteti meg az alap- és mesterszintű diplomákat.', '% Other qualifications': - 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség „egyéb“ besorolású — olyan szakmai vagy hivatásbeli képesítések, amelyek nem feleltethetők meg egy brit szintnek, valamint az Egyesült Királyságon kívül szerzett képesítések.', + 'A 2021-es Census (TS067) alapján. A legmagasabb végzettség „egyéb“ besorolású: olyan szakmai vagy hivatásbeli képesítések, amelyek nem feleltethetők meg egy brit szintnek, valamint az Egyesült Királyságon kívül szerzett képesítések.', '% Owner occupied': 'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek tehermentesen birtokolják lakásukat, jelzáloggal vagy kölcsönnel birtokolják, vagy résztulajdon (shared ownership) keretében tartják.', '% Social rent': 'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek helyi önkormányzattól vagy hatóságtól, illetve lakásszövetkezettől vagy más szociális bérbeadótól bérelnek.', '% Private rent': 'A 2021-es Census (TS054) alapján. A környéken (LSOA) azon háztartások százaléka, amelyek magán bérbeadótól vagy ingatlanügynökségtől bérelnek, kiegészülve a lakbér nélkül lakók kis arányával.', + '% Council housing': + 'Az irányítószámon belüli EPC-bérlettörténeti adatokból becsülve: azon lakásainak százaléka, amelyek energiatanúsítványának (EPC) előzményei szerint a lakás valamikor önkormányzati vagy szociális lakás volt, függetlenül attól, hogy ma is szociális lakás-e vagy azóta eladták (például a Right to Buy keretében). Az EPC nélküli lakások nem önkormányzatinak számítanak, és egy irányítószám kevés lakást foglal magában, így ez egy durva alsó becslés, amely az EPC-lefedettséget tükrözi, és nem hasonlítható közvetlenül a Census szociális bérlakás-arányához.', + '% Ex-council': + 'Az irányítószámon belüli EPC-bérlettörténeti adatokból becsülve: azon lakásainak százaléka, amelyeket egykor önkormányzati vagy szociális lakásként rögzítettek, de amelyek legutóbbi energiatanúsítványa (EPC) más lakhatási formát mutat, jellemzően a Right to Buy keretében eladott lakások. Az EPC nélküli lakások nem önkormányzatinak számítanak, és egy irányítószám kevés lakást foglal magában, így ez egy durva alsó becslés, amely az EPC-lefedettséget tükrözi, és nem hasonlítható közvetlenül a Census szociális bérlakás-arányához.', '% White': 'A 2021-es Census alapján. A helyi környéken (LSOA) fehérként (angol, walesi, skót, észak-ír, brit, ír, cigány vagy ír vándor, roma, vagy bármely más fehér háttér) azonosított népesség százaléka.', '% South Asian': diff --git a/frontend/src/i18n/index.test.ts b/frontend/src/i18n/index.test.ts new file mode 100644 index 0000000..40dcc75 --- /dev/null +++ b/frontend/src/i18n/index.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +describe('language init from ?lang param', () => { + beforeEach(() => { + vi.resetModules(); + localStorage.clear(); + window.history.replaceState({}, '', '/'); + }); + + it('applies, persists, and strips a ?lang param', async () => { + window.history.replaceState({}, '', '/?lang=de&foo=1'); + const mod = await import('./index'); + expect(mod.INITIAL_LANGUAGE).toBe('de'); + expect(localStorage.getItem('language')).toBe('de'); + expect(window.location.search).toBe('?foo=1'); + }); + + it('lets a later stored choice win once ?lang has been stripped', async () => { + window.history.replaceState({}, '', '/?lang=de'); + let mod = await import('./index'); + expect(mod.INITIAL_LANGUAGE).toBe('de'); + // user switches in-app -> dropdown writes localStorage + localStorage.setItem('language', 'en'); + // reload (param was stripped, so URL has no lang) + vi.resetModules(); + window.history.replaceState({}, '', '/'); + mod = await import('./index'); + expect(mod.INITIAL_LANGUAGE).toBe('en'); + }); + + it('falls back to stored language when no ?lang is present', async () => { + localStorage.setItem('language', 'fr'); + const mod = await import('./index'); + expect(mod.INITIAL_LANGUAGE).toBe('fr'); + }); +}); diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index d490d1b..6a3ad75 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -44,15 +44,49 @@ function getUrlLanguage(): LanguageCode | null { } } +function setStoredLanguage(code: LanguageCode): void { + try { + localStorage.setItem('language', code); + } catch { + // Storage unavailable (private mode / disabled cookies); ignore. + } +} + +// A ?lang= param is a one-time initializer (shared links, screenshot/OG image +// URLs). Strip it after consuming so a stale value can't override a later +// in-app language switch on reload. +function clearUrlLanguageParam(): void { + try { + if (typeof window === 'undefined' || !window.history?.replaceState) return; + const url = new URL(window.location.href); + if (!url.searchParams.has('lang')) return; + url.searchParams.delete('lang'); + const search = url.searchParams.toString(); + window.history.replaceState( + window.history.state, + '', + `${url.pathname}${search ? `?${search}` : ''}${url.hash}` + ); + } catch { + // Malformed URL or unsupported history API; ignore. + } +} + function getBrowserLanguages(): readonly string[] { if (typeof navigator === 'undefined') return []; return navigator.languages?.length ? navigator.languages : [navigator.language]; } function detectLanguage(): LanguageCode { - // 1. Explicit URL language, used by generated screenshot/OG image URLs. + // 1. Explicit URL language (shared links, screenshot/OG image URLs). + // Consume once: persist as the user's choice and strip from the URL so a + // stale ?lang can't override a later in-app language switch on reload. const urlLanguage = getUrlLanguage(); - if (urlLanguage) return urlLanguage; + if (urlLanguage) { + setStoredLanguage(urlLanguage); + clearUrlLanguageParam(); + return urlLanguage; + } // 2. Explicit user choice (persisted from the language dropdown) const stored = getStoredLanguage(); diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index f9c0d9d..8624a16 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -8,6 +8,7 @@ import { createElectionVoteShareFilterKey } from './election-filter'; import { createEthnicityFilterKey } from './ethnicity-filter'; import { createQualificationFilterKey } from './qualification-filter'; import { createTenureFilterKey } from './tenure-filter'; +import { createCouncilFilterKey } from './council-filter'; import { POI_COUNT_2KM_FILTER_NAME, TRANSPORT_DISTANCE_FILTER_NAME, @@ -180,6 +181,23 @@ describe('api utilities', () => { ).toBe('% Owner occupied:20:60;;% Private rent:0:25'); }); + it('serializes council filters, incl. the shared social-rent "current" column', () => { + const features: FeatureMeta[] = [ + { name: '% Social rent', type: 'numeric', min: 0, max: 100 }, + { name: '% Ex-council', type: 'numeric', min: 0, max: 100 }, + ]; + + expect( + buildFilterString( + { + [createCouncilFilterKey('% Social rent', 1)]: [30, 100], + [createCouncilFilterKey('% Ex-council', 2)]: [10, 90], + }, + features + ) + ).toBe('% Social rent:30:100;;% Ex-council:10:90'); + }); + it('serializes amenity distance filters using their selected backend feature', () => { const features: FeatureMeta[] = [ { name: 'Distance to nearest amenity (Park) (km)', type: 'numeric', min: 0, max: 2 }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e22a5b0..f3af4b0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -8,6 +8,7 @@ import { getElectionVoteShareFeatureName } from './election-filter'; import { getEthnicityFeatureName } from './ethnicity-filter'; import { getQualificationFeatureName } from './qualification-filter'; import { getTenureFeatureName } from './tenure-filter'; +import { getCouncilFeatureName } from './council-filter'; import { getPoiDistanceFeatureName } from './poi-distance-filter'; const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']); @@ -159,6 +160,7 @@ export function buildFilterString( getEthnicityFeatureName(name) ?? getQualificationFeatureName(name) ?? getTenureFeatureName(name) ?? + getCouncilFeatureName(name) ?? getPoiDistanceFeatureName(name) ?? name; const prev = merged.get(backendName); diff --git a/frontend/src/lib/auth-errors.test.ts b/frontend/src/lib/auth-errors.test.ts new file mode 100644 index 0000000..5d23a29 --- /dev/null +++ b/frontend/src/lib/auth-errors.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import type { TFunction } from 'i18next'; +import { resolveAuthError } from './auth-errors'; + +// Echo the key so we can assert which generic fallback path ran. +const t = ((key: string) => key) as unknown as TFunction; + +// All copy resolves through t(); the mock echoes the key, so we assert on keys. +describe('resolveAuthError', () => { + it('maps 429 to a rate-limit message on every flow', () => { + const r = resolveAuthError({ status: 429 }, 'login', t); + expect(r.message).toBe('auth.errorRateLimited'); + expect(r.action).toBeNull(); + }); + + it('maps a duplicate-email signup to "email taken" with a log-in affordance', () => { + const err = { status: 400, response: { data: { email: { code: 'validation_not_unique' } } } }; + const r = resolveAuthError(err, 'register', t); + expect(r.message).toBe('auth.errorEmailTaken'); + expect(r.action).toBe('switchToLogin'); + }); + + it('maps a 400 login failure to invalid credentials (not a raw PB string)', () => { + const r = resolveAuthError({ status: 400 }, 'login', t); + expect(r.message).toBe('auth.errorInvalidCredentials'); + expect(r.message).not.toMatch(/Failed to authenticate/); + }); + + it('maps a weak-password signup', () => { + const err = { + status: 400, + response: { data: { password: { code: 'validation_length_out_of_range' } } }, + }; + const r = resolveAuthError(err, 'register', t); + expect(r.message).toBe('auth.errorPasswordWeak'); + }); + + it('falls back to the existing generic key for unknown login errors', () => { + expect(resolveAuthError({ status: 500 }, 'login', t).message).toBe('auth.loginFailed'); + }); + + it('treats a missing status as a network error', () => { + const r = resolveAuthError(new Error('boom'), 'login', t); + expect(r.message).toBe('auth.errorNetwork'); + }); +}); diff --git a/frontend/src/lib/auth-errors.ts b/frontend/src/lib/auth-errors.ts new file mode 100644 index 0000000..75e10cf --- /dev/null +++ b/frontend/src/lib/auth-errors.ts @@ -0,0 +1,79 @@ +import type { TFunction } from 'i18next'; + +/** + * Affordance the auth modal can render next to an error message. + * `switchToLogin` is used both when an email already exists AND after a + * successful signup whose auto-login failed (the account is real, so the right + * recovery is to log in, not to register again). + */ +export type AuthErrorAction = 'switchToLogin' | null; + +export type AuthContext = 'login' | 'register' | 'oauth' | 'reset'; + +export interface ResolvedAuthError { + /** User-facing message (already resolved to a string). */ + message: string; + /** Optional affordance, e.g. a "Log in instead" button. */ + action: AuthErrorAction; +} + +interface PbErrorLike { + status?: number; + response?: { + data?: Record; + }; +} + +function fieldCode(err: PbErrorLike, field: string): string | undefined { + return err.response?.data?.[field]?.code; +} + +/** + * Translates a PocketBase auth failure into a friendly, localized message + * (+ optional affordance) instead of leaking raw strings like "Failed to create + * record.". All copy resolves through the passed-in t(). + */ +export function resolveAuthError( + err: unknown, + context: AuthContext, + t: TFunction +): ResolvedAuthError { + const e = (err && typeof err === 'object' ? err : {}) as PbErrorLike; + const status = typeof e.status === 'number' ? e.status : 0; + + // Rate limiting applies to every flow. + if (status === 429) { + return { message: t('auth.errorRateLimited'), action: null }; + } + + // No HTTP status → request never reached the server (offline / DNS / CORS). + if (status === 0) { + return { message: t('auth.errorNetwork'), action: null }; + } + + if (context === 'register') { + if (fieldCode(e, 'email') === 'validation_not_unique') { + return { message: t('auth.errorEmailTaken'), action: 'switchToLogin' }; + } + if (fieldCode(e, 'password')) { + return { message: t('auth.errorPasswordWeak'), action: null }; + } + if (fieldCode(e, 'email')) { + return { message: t('auth.errorEmailInvalid'), action: null }; + } + return { message: t('auth.registrationFailed'), action: null }; + } + + if (context === 'login') { + if (status === 400 || status === 401 || status === 403) { + return { message: t('auth.errorInvalidCredentials'), action: null }; + } + return { message: t('auth.loginFailed'), action: null }; + } + + if (context === 'oauth') { + return { message: t('auth.oauthFailed'), action: null }; + } + + return { message: t('auth.passwordResetFailed'), action: null }; +} diff --git a/frontend/src/lib/council-filter.test.ts b/frontend/src/lib/council-filter.test.ts new file mode 100644 index 0000000..ee4f5b6 --- /dev/null +++ b/frontend/src/lib/council-filter.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import type { FeatureFilters, FeatureMeta } from '../types'; +import { + COUNCIL_FILTER_NAME, + COUNCIL_VARIANT_CONFIG, + createCouncilFilterKey, + getCouncilFeatureName, + getCouncilFilterKeyId, + getCouncilWindow, + getDefaultCouncilFeatureName, + isCouncilFeatureName, + isCouncilFilterName, + normalizeCouncilFilters, + parseCouncilFilterKey, + replaceCouncilFilterKeySelection, + withCouncilWindow, +} from './council-filter'; + +const numeric = (name: string): FeatureMeta => ({ name, type: 'numeric', min: 0, max: 100 }); +const ALL_FEATURES = [ + numeric('% Social rent'), + numeric('% Ex-council'), + numeric('% Council housing'), +]; + +describe('council feature recognition', () => { + it('owns the two EPC columns for bare folding, but NOT the shared Census column', () => { + expect(isCouncilFeatureName('% Ex-council')).toBe(true); + expect(isCouncilFeatureName('% Council housing')).toBe(true); + // "% Social rent" belongs to Tenure when bare; council reaches it only via a key. + expect(isCouncilFeatureName('% Social rent')).toBe(false); + expect(isCouncilFeatureName('% Private rent')).toBe(false); + }); + + it('treats prefixed keys (including the current/Census one) as council filters', () => { + expect(isCouncilFilterName('Council housing:%25%20Ex-council:0')).toBe(true); + expect(isCouncilFilterName('Council housing:%25%20Social%20rent:0')).toBe(true); + expect(isCouncilFilterName('% Ex-council')).toBe(true); + expect(isCouncilFilterName('% Social rent')).toBe(false); + expect(isCouncilFilterName('Tenure:%25%20Social%20rent:0')).toBe(false); + }); +}); + +describe('council pill (window) mapping', () => { + it('maps each column to its pill id and back', () => { + expect(getCouncilWindow('% Social rent')).toBe('current'); + expect(getCouncilWindow('% Ex-council')).toBe('ex'); + expect(getCouncilWindow('% Council housing')).toBe('both'); + expect(getCouncilWindow('% Private rent')).toBeNull(); + + expect(withCouncilWindow('% Council housing', 'current')).toBe('% Social rent'); + expect(withCouncilWindow('% Social rent', 'ex')).toBe('% Ex-council'); + expect(withCouncilWindow('% Ex-council', 'both')).toBe('% Council housing'); + }); +}); + +describe('council filter keys', () => { + it('round-trips the key, including the shared Census column', () => { + const exKey = createCouncilFilterKey('% Ex-council', 2); + expect(getCouncilFilterKeyId(exKey)).toBe('2'); + expect(parseCouncilFilterKey(exKey)).toBe('% Ex-council'); + expect(getCouncilFeatureName(exKey)).toBe('% Ex-council'); + + const currentKey = createCouncilFilterKey('% Social rent', 0); + expect(parseCouncilFilterKey(currentKey)).toBe('% Social rent'); + expect(getCouncilFeatureName(currentKey)).toBe('% Social rent'); + }); + + it('does not resolve a bare "% Social rent" (leaves it for Tenure)', () => { + expect(getCouncilFeatureName('% Social rent')).toBeNull(); + expect(getCouncilFeatureName('% Ex-council')).toBe('% Ex-council'); + }); + + it('re-points a key to a different pill column while keeping its id', () => { + const key = createCouncilFilterKey('% Council housing', 5); + const repointed = replaceCouncilFilterKeySelection(key, '% Social rent'); + expect(getCouncilFilterKeyId(repointed)).toBe('5'); + expect(getCouncilFeatureName(repointed)).toBe('% Social rent'); + }); +}); + +describe('normalizeCouncilFilters', () => { + it('folds bare EPC columns into keys but leaves "% Social rent" untouched', () => { + const input: FeatureFilters = { + '% Ex-council': [10, 90], + '% Social rent': [0, 100], + }; + const out = normalizeCouncilFilters(input); + // The Census column is preserved verbatim (Tenure will fold it elsewhere). + expect(out['% Social rent']).toEqual([0, 100]); + expect('% Ex-council' in out).toBe(false); + const folded = Object.keys(out).find((k) => k.startsWith('Council housing:')); + expect(folded).toBeTruthy(); + expect(getCouncilFeatureName(folded!)).toBe('% Ex-council'); + }); +}); + +describe('council variant config', () => { + it('defaults to the "both" column and hides the dropdown (single variant)', () => { + expect(getDefaultCouncilFeatureName(ALL_FEATURES)).toBe('% Council housing'); + expect(COUNCIL_VARIANT_CONFIG.featureNames).toEqual(['% Council housing']); + expect(COUNCIL_VARIANT_CONFIG.filterName).toBe(COUNCIL_FILTER_NAME); + expect(COUNCIL_VARIANT_CONFIG.window?.options.map((o) => o.id)).toEqual([ + 'current', + 'ex', + 'both', + ]); + }); + + it('is not addable when the EPC columns are absent', () => { + expect(getDefaultCouncilFeatureName([numeric('% Social rent')])).toBeNull(); + }); +}); diff --git a/frontend/src/lib/council-filter.ts b/frontend/src/lib/council-filter.ts new file mode 100644 index 0000000..07e5522 --- /dev/null +++ b/frontend/src/lib/council-filter.ts @@ -0,0 +1,187 @@ +import type { FeatureFilters, FeatureMeta } from '../types'; +import type { VariantFilterConfig } from './variant-filter'; + +export const COUNCIL_FILTER_NAME = 'Council housing'; +export const COUNCIL_FILTER_KEY_PREFIX = `${COUNCIL_FILTER_NAME}:`; + +/** + * The "Council housing" filter folds three neighbourhood percentages into one + * card whose pill toggle picks current / ex / both: + * - current -> "% Social rent" (Census TS054, the share renting socially now) + * - ex -> "% Ex-council" (EPC: dwellings once council, since sold off) + * - both -> "% Council housing" (EPC: dwellings ever recorded as council) + * + * The pill toggle is implemented as a single-variant window config (so the + * dropdown stays hidden, see VariantFilterCard) whose three "windows" re-point + * the filter key to a different backend column. + * + * "% Social rent" is ALSO owned by the Tenure filter. To keep the two filters + * disjoint we recognise it only when it appears inside an explicit + * `Council housing:` key (COUNCIL_COLUMN_SET), never as a bare name to fold + * (COUNCIL_OWNED_FEATURE_NAMES). A bare "% Social rent" always belongs to + * Tenure. The Census column is reached under this card solely via the toggle. + */ +export const COUNCIL_WINDOW_CURRENT = 'current'; +export const COUNCIL_WINDOW_EX = 'ex'; +export const COUNCIL_WINDOW_BOTH = 'both'; + +const COUNCIL_CURRENT_FEATURE = '% Social rent'; +const COUNCIL_EX_FEATURE = '% Ex-council'; +const COUNCIL_BOTH_FEATURE = '% Council housing'; + +/** The canonical (default-window) variant: "both", the most inclusive view. */ +export const COUNCIL_FEATURE_NAMES = [COUNCIL_BOTH_FEATURE] as const; + +/** Every column the pill toggle can point at (incl. the shared Census column). */ +const COUNCIL_COLUMN_SET = new Set([ + COUNCIL_CURRENT_FEATURE, + COUNCIL_EX_FEATURE, + COUNCIL_BOTH_FEATURE, +]); + +/** + * Columns the Council filter OWNS for bare-name folding: the two EPC columns + * only. "% Social rent" is deliberately excluded so it folds into Tenure. + */ +const COUNCIL_OWNED_FEATURE_NAMES = new Set([COUNCIL_EX_FEATURE, COUNCIL_BOTH_FEATURE]); + +const COUNCIL_WINDOW_BY_FEATURE: Record = { + [COUNCIL_CURRENT_FEATURE]: COUNCIL_WINDOW_CURRENT, + [COUNCIL_EX_FEATURE]: COUNCIL_WINDOW_EX, + [COUNCIL_BOTH_FEATURE]: COUNCIL_WINDOW_BOTH, +}; + +const COUNCIL_FEATURE_BY_WINDOW: Record = { + [COUNCIL_WINDOW_CURRENT]: COUNCIL_CURRENT_FEATURE, + [COUNCIL_WINDOW_EX]: COUNCIL_EX_FEATURE, + [COUNCIL_WINDOW_BOTH]: COUNCIL_BOTH_FEATURE, +}; + +/** A bare column owned by the Council filter (for folding / browser placement). */ +export function isCouncilFeatureName(name: string): boolean { + return COUNCIL_OWNED_FEATURE_NAMES.has(name); +} + +export function isCouncilFilterName(name: string): boolean { + return isCouncilFeatureName(name) || name.startsWith(COUNCIL_FILTER_KEY_PREFIX); +} + +export function createCouncilFilterKey(featureName: string, id: number | string): string { + return `${COUNCIL_FILTER_KEY_PREFIX}${encodeURIComponent(featureName)}:${id}`; +} + +export function getCouncilFilterKeyId(name: string): string | null { + if (!name.startsWith(COUNCIL_FILTER_KEY_PREFIX)) return null; + const rest = name.substring(COUNCIL_FILTER_KEY_PREFIX.length); + const lastColon = rest.lastIndexOf(':'); + return lastColon === -1 ? null : rest.substring(lastColon + 1); +} + +export function parseCouncilFilterKey(name: string): string | null { + if (!name.startsWith(COUNCIL_FILTER_KEY_PREFIX)) return null; + const rest = name.substring(COUNCIL_FILTER_KEY_PREFIX.length); + const lastColon = rest.lastIndexOf(':'); + if (lastColon === -1) return null; + + const decoded = decodeURIComponent(rest.substring(0, lastColon)); + // Accept any of the three pill columns here, including the shared Census one. + return COUNCIL_COLUMN_SET.has(decoded) ? decoded : null; +} + +export function getCouncilFeatureName(name: string): string | null { + const fromKey = parseCouncilFilterKey(name); + if (fromKey != null) return fromKey; + // Bare names: resolve only the owned EPC columns. A bare "% Social rent" + // returns null so it falls through to the Tenure resolver. + return isCouncilFeatureName(name) ? name : null; +} + +export function replaceCouncilFilterKeySelection(key: string, featureName: string): string { + const id = getCouncilFilterKeyId(key) ?? '0'; + return createCouncilFilterKey(featureName, id); +} + +/** Window id (current/ex/both) for a backend column, or null if unrecognised. */ +export function getCouncilWindow(featureName: string): string | null { + return COUNCIL_WINDOW_BY_FEATURE[featureName] ?? null; +} + +/** The backend column for a given pill (window), independent of the input. */ +export function withCouncilWindow(_featureName: string, windowId: string): string { + return COUNCIL_FEATURE_BY_WINDOW[windowId] ?? COUNCIL_BOTH_FEATURE; +} + +export function getDefaultCouncilFeatureName(features: FeatureMeta[]): string | null { + return ( + COUNCIL_FEATURE_NAMES.find((name) => features.some((feature) => feature.name === name)) ?? null + ); +} + +export function normalizeCouncilFilters(filters: FeatureFilters): FeatureFilters { + let changed = false; + const next: FeatureFilters = {}; + + for (const [name, value] of Object.entries(filters)) { + if (isCouncilFeatureName(name)) { + next[createCouncilFilterKey(name, Object.keys(next).length)] = value; + changed = true; + continue; + } + next[name] = value; + } + + return changed ? next : filters; +} + +export function getCouncilFilterMeta(features: FeatureMeta[]): FeatureMeta { + const sourceFeatureName = getDefaultCouncilFeatureName(features); + const sourceFeature = sourceFeatureName + ? features.find((feature) => feature.name === sourceFeatureName) + : undefined; + + return { + name: COUNCIL_FILTER_NAME, + type: 'numeric', + group: 'Neighbours', + min: sourceFeature?.min ?? 0, + max: sourceFeature?.max ?? 100, + step: 1, + description: 'Share of homes that are current or former council housing', + detail: + "Filter by an area's council-housing footprint. Toggle between homes currently rented socially (Census, neighbourhood level), homes that were council but have since been sold (ex-council, EPC records per postcode), or both combined.", + source: 'epc', + suffix: '%', + }; +} + +export function clampCouncilRange( + value: [number, number], + feature?: FeatureMeta +): [number, number] { + const min = feature?.histogram?.min ?? feature?.min ?? 0; + const max = feature?.histogram?.max ?? feature?.max ?? Math.max(1, value[1]); + return [Math.max(min, Math.min(value[0], max)), Math.max(min, Math.min(value[1], max))]; +} + +export const COUNCIL_VARIANT_CONFIG: VariantFilterConfig = { + filterName: COUNCIL_FILTER_NAME, + // One canonical variant (default window "both"); the dropdown stays hidden and + // the three pills below re-point the filter key to a different column. + featureNames: COUNCIL_FEATURE_NAMES, + dropdownLabelKey: 'filters.councilType', + getFilterMeta: getCouncilFilterMeta, + getDefaultFeatureName: getDefaultCouncilFeatureName, + getFeatureName: getCouncilFeatureName, + replaceFilterKeySelection: replaceCouncilFilterKeySelection, + clampRange: clampCouncilRange, + window: { + labelKey: 'filters.councilStatus', + options: [ + { id: COUNCIL_WINDOW_CURRENT, labelKey: 'filters.councilCurrent' }, + { id: COUNCIL_WINDOW_EX, labelKey: 'filters.councilEx' }, + { id: COUNCIL_WINDOW_BOTH, labelKey: 'filters.councilBoth' }, + ], + getWindow: getCouncilWindow, + withWindow: withCouncilWindow, + }, +}; diff --git a/frontend/src/lib/nearby-stations.ts b/frontend/src/lib/nearby-stations.ts index 179c523..2ba2ac6 100644 --- a/frontend/src/lib/nearby-stations.ts +++ b/frontend/src/lib/nearby-stations.ts @@ -77,7 +77,7 @@ export function formatStationDistance(distanceKmValue: number): string { * * At Tube/DLR interchanges (Bank, Canning Town, Stratford, …) NaPTAN merges the * Underground and DLR nodes into one POI whose category resolves to "Tube - * station" but whose name was taken from the DLR node — so the raw name "Bank + * station" but whose name was taken from the DLR node, so the raw name "Bank * DLR Station" would otherwise sit under a "Tube station" label. Dropping the * mode word removes both that contradiction and the everyday redundancy of * "Beckton DLR Station" shown above a "DLR station" subtitle. diff --git a/frontend/src/lib/overlays.ts b/frontend/src/lib/overlays.ts index da03abd..328ac88 100644 --- a/frontend/src/lib/overlays.ts +++ b/frontend/src/lib/overlays.ts @@ -37,7 +37,7 @@ export const OVERLAYS: OverlayDefinition[] = [ label: 'Trees & woodland', description: 'Tree canopy and woodland polygons', detail: - 'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons — lone trees and groups of trees — combined with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.', + 'Forest Research Trees Outside Woodland (TOW) v1 canopy polygons (lone trees and groups of trees) combined with National Forest Inventory (NFI) woodland blocks (≥0.5 ha) that TOW deliberately excludes. Together they cover both street trees and actual woods. Polygon opacity scales with canopy area.', defaultEnabled: true, }, { @@ -45,7 +45,7 @@ export const OVERLAYS: OverlayDefinition[] = [ label: 'Property borders', description: 'Individual freehold property/land-parcel boundaries', detail: - 'HM Land Registry INSPIRE Index Polygons — the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are “general boundaries” for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 85–90% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.', + 'HM Land Registry INSPIRE Index Polygons: the position and indicative extent of freehold registered property in England & Wales, drawn as outlines at street level. These are “general boundaries” for guidance only, not the precise legal boundary of a property, and they exclude leasehold-only interests and unregistered land (roughly 85–90% of freehold land is covered). This information is subject to Crown copyright and database rights 2026 and is reproduced with the permission of HM Land Registry. The polygons (including the associated geometry, namely x, y co-ordinates) are subject to Crown copyright and database rights 2026 Ordnance Survey AC0000851063. Licensed under the Open Government Licence v3.0.', defaultEnabled: true, }, { @@ -53,7 +53,7 @@ export const OVERLAYS: OverlayDefinition[] = [ label: 'New developments', description: 'Planned new-home sites (brownfield register + Homes England)', detail: - 'A pipeline of new housing. Blue markers show sites on the statutory MHCLG Brownfield Land registers — each carrying an estimated net-dwelling capacity and planning-permission status — together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.', + 'A pipeline of new housing. Blue markers show sites on the statutory MHCLG Brownfield Land registers (each carrying an estimated net-dwelling capacity and planning-permission status) together with Homes England Land Hub disposal sites. These show where new homes are planned, often years before they appear in EPC or sale records. Dwelling figures are capacity estimates, not commitments, and a site on a register is an opportunity rather than a guarantee of construction. Licensed under the Open Government Licence v3.0.', defaultEnabled: true, }, ]; @@ -72,7 +72,7 @@ export function isOverlayId(value: string): value is OverlayId { /** * Lowest zoom at which each overlay's tiles are generated by the pipeline * (see the `--min-zoom` defaults in pipeline/transform/*_tiles.py). Used as - * the tile source's `minzoom` so we don't request tiles that don't exist — + * the tile source's `minzoom` so we don't request tiles that don't exist: * this is a data-availability floor, NOT the visibility limit. The limit at * which overlays (and postcodes) start showing is the shared * POSTCODE_ZOOM_THRESHOLD. diff --git a/frontend/src/lib/url-state.ts b/frontend/src/lib/url-state.ts index ae3fd89..2b47c08 100644 --- a/frontend/src/lib/url-state.ts +++ b/frontend/src/lib/url-state.ts @@ -57,6 +57,14 @@ import { isTenureFeatureName, isTenureFilterName, } from './tenure-filter'; +import { + COUNCIL_FILTER_NAME, + createCouncilFilterKey, + getCouncilFeatureName, + getCouncilWindow, + isCouncilFeatureName, + isCouncilFilterName, +} from './council-filter'; import { POI_DISTANCE_FILTER_NAME, TRANSPORT_DISTANCE_FILTER_NAME, @@ -77,6 +85,7 @@ import { colorOpacityToPercent, normalizeColorOpacity, } from './color-opacity'; +import { DEFAULT_LISTINGS_MODE, LISTINGS_MODES, type ListingsMode } from './listings'; const POI_NONE_PARAM = '__none'; const CRIME_TYPES_NONE_PARAM = '__none'; @@ -94,6 +103,8 @@ export interface UrlState { crimeTypes?: Set; basemap: BasemapId; colorOpacity: number; + /** Which slice of the live for-sale listings to render (new-build only, etc.). */ + listings: ListingsMode; tab: 'properties' | 'area'; travelTime?: TravelTimeInitial; postcode?: string; @@ -109,6 +120,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters { const ethnicityParams = params.getAll('ethnicity'); const qualificationParams = params.getAll('qualification'); const tenureParams = params.getAll('tenure'); + const councilParams = params.getAll('council'); const amenityDistanceParams = params.getAll('amenityDistance'); const transportDistanceParams = params.getAll('transportDistance'); const amenityCount2KmParams = params.getAll('amenityCount2km'); @@ -122,6 +134,7 @@ function parseFilters(params: URLSearchParams): FeatureFilters { ethnicityParams.length === 0 && qualificationParams.length === 0 && tenureParams.length === 0 && + councilParams.length === 0 && amenityDistanceParams.length === 0 && transportDistanceParams.length === 0 && amenityCount2KmParams.length === 0 && @@ -243,6 +256,20 @@ function parseFilters(params: URLSearchParams): FeatureFilters { filters[createTenureFilterKey(featureName, index)] = [min, max]; }); + councilParams.forEach((entry, index) => { + const parts = entry.split(':'); + if (parts.length < 3) return; + const featureName = parts.slice(0, -2).join(':'); + const min = Number(parts[parts.length - 2]); + const max = Number(parts[parts.length - 1]); + // getCouncilWindow recognises all three pill columns, incl. the shared + // "% Social rent" (which isCouncilFeatureName excludes from bare folding). + if (getCouncilWindow(featureName) == null || isNaN(min) || isNaN(max)) { + return; + } + filters[createCouncilFilterKey(featureName, index)] = [min, max]; + }); + const parsePoiParams = (entries: string[], filterName: PoiFilterName, startIndex: number) => { entries.forEach((entry, index) => { const parts = entry.split(':'); @@ -295,6 +322,7 @@ export function parseUrlState(): UrlState { overlays: new Set(DEFAULT_OVERLAY_IDS), basemap: 'standard', colorOpacity: DEFAULT_COLOR_OPACITY, + listings: DEFAULT_LISTINGS_MODE, tab: 'area', }; @@ -363,6 +391,13 @@ export function parseUrlState(): UrlState { } } + // Listings mode: which slice of the for-sale listings is shown. Absent means + // the default ('all'); an explicit value survives a reload. + const listings = params.get('listings'); + if (listings && (LISTINGS_MODES as readonly string[]).includes(listings)) { + result.listings = listings as ListingsMode; + } + // Tab: full name const tab = params.get('tab'); if (tab === 'properties' || tab === 'area') { @@ -436,7 +471,8 @@ export function stateToParams( basemap?: BasemapId, selectedCrimeTypes?: Set, selectedPostcode?: string, - colorOpacity?: number + colorOpacity?: number, + listingsMode?: ListingsMode ): URLSearchParams { const params = new URLSearchParams(); @@ -500,6 +536,15 @@ export function stateToParams( continue; } + // After tenure: a "Council housing:" key never matches isTenureFilterName, + // and a bare "% Social rent" is handled by the tenure branch above. + const councilFeatureName = getCouncilFeatureName(name); + if (councilFeatureName && isCouncilFilterName(name)) { + const [min, max] = value as [number, number]; + params.append('council', `${councilFeatureName}:${min}:${max}`); + continue; + } + const amenityDistanceFeatureName = getPoiDistanceFeatureName(name); if (amenityDistanceFeatureName && isPoiDistanceFilterName(name)) { const [min, max] = value as [number, number]; @@ -577,6 +622,12 @@ export function stateToParams( } } + // Emit the listings mode only when it deviates from the default, matching the + // "only serialize non-default" convention above (keeps default URLs clean). + if (listingsMode && listingsMode !== DEFAULT_LISTINGS_MODE) { + params.set('listings', listingsMode); + } + // Travel time: repeated `tt` params if (travelTimeEntries) { for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) { @@ -612,6 +663,7 @@ export function summarizeParams(queryString: string): string { const ethnicityParams = params.getAll('ethnicity'); const qualificationParams = params.getAll('qualification'); const tenureParams = params.getAll('tenure'); + const councilParams = params.getAll('council'); const amenityDistanceParams = params.getAll('amenityDistance'); const transportDistanceParams = params.getAll('transportDistance'); const amenityCount2KmParams = params.getAll('amenityCount2km'); @@ -625,6 +677,7 @@ export function summarizeParams(queryString: string): string { ethnicityParams.length > 0 || qualificationParams.length > 0 || tenureParams.length > 0 || + councilParams.length > 0 || amenityDistanceParams.length > 0 || transportDistanceParams.length > 0 || amenityCount2KmParams.length > 0 || @@ -640,6 +693,7 @@ export function summarizeParams(queryString: string): string { if (isEthnicityFeatureName(name)) return ETHNICITIES_FILTER_NAME; if (isQualificationFeatureName(name)) return QUALIFICATIONS_FILTER_NAME; if (isTenureFeatureName(name)) return TENURE_FILTER_NAME; + if (isCouncilFeatureName(name)) return COUNCIL_FILTER_NAME; const poiFilterName = getPoiFilterName(name); if (poiFilterName) return poiFilterName; return name; @@ -667,6 +721,9 @@ export function summarizeParams(queryString: string): string { for (let i = 0; i < tenureParams.length; i++) { filterNames.push(TENURE_FILTER_NAME); } + for (let i = 0; i < councilParams.length; i++) { + filterNames.push(COUNCIL_FILTER_NAME); + } for (let i = 0; i < amenityDistanceParams.length; i++) { filterNames.push(POI_DISTANCE_FILTER_NAME); }