lgtm 2
Some checks failed
Build and publish Docker image / build-and-push (push) Failing after 2m43s
CI / Check (push) Failing after 3m7s

This commit is contained in:
Andras Schmelczer 2026-05-14 22:39:41 +01:00
parent a8de0a614d
commit 3fa95819e3
30 changed files with 907 additions and 205 deletions

View file

@ -0,0 +1,12 @@
export function findActiveFilterElement(root: ParentNode | null, filterName: string) {
if (!root) return null;
const cards = root.querySelectorAll<HTMLElement>('[data-filter-name]');
for (let i = cards.length - 1; i >= 0; i -= 1) {
if (cards[i].dataset.filterName === filterName) {
return cards[i];
}
}
return null;
}

View file

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { FeatureMeta } from '../types';
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
import { apiUrl, assertOk, buildFilterString, isAbortError, paramsWithLanguage } from './api';
import { createSchoolFilterKey } from './school-filter';
import { createSpecificCrimeFilterKey } from './crime-filter';
import { createElectionVoteShareFilterKey } from './election-filter';
@ -38,6 +38,11 @@ describe('api utilities', () => {
expect(isAbortError(regular)).toBe(false);
});
it('adds supported language parameters without overriding explicit languages', () => {
expect(paramsWithLanguage('lat=51.5&lon=-0.1', 'fr-FR')).toBe('lat=51.5&lon=-0.1&lang=fr');
expect(paramsWithLanguage('lat=51.5&lang=de', 'fr')).toBe('lat=51.5&lang=de');
});
it('serializes numeric, absolute, and enum filters for backend routes', () => {
const features: FeatureMeta[] = [
{ name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },

View file

@ -7,6 +7,8 @@ import { getElectionVoteShareFeatureName } from './election-filter';
import { getEthnicityFeatureName } from './ethnicity-filter';
import { getPoiDistanceFeatureName } from './poi-distance-filter';
const SCREENSHOT_LANGUAGES = new Set(['en', 'fr', 'de', 'zh', 'hi', 'hu']);
export function logNonAbortError(label: string, error: unknown): void {
if (error instanceof Error && error.name === 'AbortError') {
return;
@ -42,6 +44,36 @@ export function apiUrl(endpoint: string, params?: URLSearchParams): string {
return query ? `${path}?${query}` : path;
}
function toSupportedLanguage(value: string | undefined): string | null {
if (!value) return null;
const lower = value.toLowerCase();
if (SCREENSHOT_LANGUAGES.has(lower)) return lower;
const prefix = lower.split('-')[0];
if (SCREENSHOT_LANGUAGES.has(prefix)) return prefix;
return null;
}
function browserLanguage(): string | null {
if (typeof navigator === 'undefined') return null;
const languages = navigator.languages?.length ? navigator.languages : [navigator.language];
for (const language of languages) {
const supported = toSupportedLanguage(language);
if (supported) return supported;
}
return null;
}
export function paramsWithLanguage(params: string, language?: string): string {
const qs = new URLSearchParams(params.replace(/^\?/, ''));
if (!qs.has('lang')) {
const supported = toSupportedLanguage(language) ?? browserLanguage();
if (supported) qs.set('lang', supported);
}
return qs.toString();
}
export async function fetchWithRetry<T>(
url: string,
onSuccess: (data: T) => void,
@ -65,17 +97,19 @@ export async function fetchWithRetry<T>(
}
/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
export function prewarmScreenshot(params: string): void {
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
export function prewarmScreenshot(params: string, language?: string): void {
const qs = new URLSearchParams(paramsWithLanguage(params, language));
qs.set('og', '1');
fetch(apiUrl('screenshot', qs), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
}
export async function shortenUrl(params: string): Promise<string> {
export async function shortenUrl(params: string, language?: string): Promise<string> {
const res = await fetch(
apiUrl('shorten'),
authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
body: JSON.stringify({ params: paramsWithLanguage(params, language) }),
})
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);

View file

@ -76,7 +76,7 @@ describe('external property search URLs', () => {
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.25');
});
it('uses Rightmove this-area-only radius when an exact postcode identifier is provided', () => {
it('uses Rightmove quarter-mile radius when an exact postcode identifier is provided', () => {
const urls = buildPropertySearchUrls({
location: {
lat: 51.501,
@ -93,7 +93,28 @@ describe('external property search URLs', () => {
expect(rightmove.searchParams.get('searchLocation')).toBe('SW1A 1AA');
expect(rightmove.searchParams.get('locationIdentifier')).toBe('POSTCODE^837246');
expect(rightmove.searchParams.get('radius')).toBe('0.25');
});
it('uses Rightmove outcode-only radius when an outcode identifier is provided', () => {
const urls = buildPropertySearchUrls({
location: {
lat: 51.501,
lon: -0.141,
resolution: 8,
postcode: 'SW1A 1AA',
isPostcode: false,
},
rightmoveLocationId: 'OUTCODE^2506',
filters: {},
});
const rightmove = new URL(urls!.rightmove!);
expect(rightmove.searchParams.get('locationIdentifier')).toBe('OUTCODE^2506');
expect(rightmove.searchParams.get('radius')).toBe('0.0');
expect(new URL(urls!.onthemarket).searchParams.get('radius')).toBe('0.5');
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.5');
});
it('builds a same-origin Rightmove redirect for exact postcode clicks', () => {

View file

@ -36,7 +36,10 @@ export const H3_RADIUS_MILES: Record<number, number> = {
12: 1,
};
export const POSTCODE_RADIUS_MILES = 0.25;
const RIGHTMOVE_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
const RIGHTMOVE_OUTCODE_RADIUS_MILES = '0.0';
const OTM_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 30, 40];
const ZOOPLA_RADII = [0.25, 0.5, 1, 3, 5, 10, 15, 20, 25, 30];
@ -111,7 +114,7 @@ export function buildPropertySearchUrls({
const { postcode, resolution, isPostcode } = location;
if (!postcode) return null;
const radiusMiles = isPostcode ? 0 : (H3_RADIUS_MILES[resolution] ?? 1);
const radiusMiles = isPostcode ? POSTCODE_RADIUS_MILES : (H3_RADIUS_MILES[resolution] ?? 1);
const priceFilter = filters['Estimated current price'] ?? filters['Last known price'];
const minPrice =
@ -150,8 +153,8 @@ export function buildPropertySearchUrls({
rmParams.set('locationIdentifier', rightmoveLocationId);
rmParams.set(
'radius',
isPostcode && rightmoveLocationId.startsWith('POSTCODE^')
? '0.0'
rightmoveLocationId.startsWith('OUTCODE^')
? RIGHTMOVE_OUTCODE_RADIUS_MILES
: String(nearestRadius(radiusMiles, RIGHTMOVE_RADII))
);
if (minPrice !== undefined)

View file

@ -5,15 +5,17 @@ import {
POI_COUNT_2KM_FILTER_NAME,
POI_DISTANCE_FILTER_NAME,
TRANSPORT_DISTANCE_FILTER_NAME,
clampPoiFilterRange,
getPoiFilterFeatureOptions,
getPoiFilterName,
} from './poi-distance-filter';
const numeric = (name: string): FeatureMeta => ({
const numeric = (name: string, overrides: Partial<FeatureMeta> = {}): FeatureMeta => ({
name,
type: 'numeric',
min: 0,
max: 5,
...overrides,
});
describe('poi-distance-filter', () => {
@ -57,4 +59,13 @@ describe('poi-distance-filter', () => {
);
expect(getPoiFilterName('Number of amenities (Bus stop) within 2km')).toBeNull();
});
it('clamps fixed amenity distance scales to the 0-5km slider bounds', () => {
const feature = numeric('Distance to nearest amenity (Cafe) (km)', {
absolute: true,
histogram: { min: 0.2, max: 12, p1: 0.3, p99: 9, counts: [1, 2, 3] },
});
expect(clampPoiFilterRange([-1, 8], feature)).toEqual([0, 5]);
});
});

View file

@ -136,6 +136,10 @@ export function isPoiDistanceFeatureName(name: string): boolean {
return isDynamicPoiDistanceFeatureName(name);
}
export function usesFixedPoiDistanceScale(feature?: FeatureMeta): boolean {
return Boolean(feature?.absolute && isPoiDistanceFeatureName(feature.name));
}
export function isPoiFilterFeatureName(name: string): boolean {
return getPoiMetric(name) != null;
}
@ -263,6 +267,7 @@ export function getPoiFilterMeta(features: FeatureMeta[], filterName: PoiFilterN
detail: config.detail,
source: sourceFeature?.source ?? 'osm-pois',
suffix: config.suffix,
absolute: sourceFeature?.absolute,
};
}
@ -295,8 +300,12 @@ export function clampPoiFilterRange(
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]);
const min = usesFixedPoiDistanceScale(feature)
? (feature?.min ?? 0)
: (feature?.histogram?.min ?? feature?.min ?? 0);
const max = usesFixedPoiDistanceScale(feature)
? (feature?.max ?? 5)
: (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))];
}

View file

@ -47,6 +47,7 @@ import {
isPoiDistanceFilterName,
type PoiFilterName,
} from './poi-distance-filter';
import { dedupeTravelTimeEntries } from './travel-params';
const POI_NONE_PARAM = '__none';
@ -280,7 +281,7 @@ export function parseUrlState(): UrlState {
entries.push({ mode, slug, label, timeRange, useBest });
}
if (entries.length > 0) {
result.travelTime = { entries };
result.travelTime = { entries: dedupeTravelTimeEntries(entries) };
}
}
@ -379,7 +380,7 @@ export function stateToParams(
// Travel time: repeated `tt` params
if (travelTimeEntries) {
for (const entry of travelTimeEntries) {
for (const entry of dedupeTravelTimeEntries(travelTimeEntries)) {
if (!entry.slug) continue;
let val = `${entry.mode}:${entry.slug}:${encodeURIComponent(entry.label)}`;
if (entry.useBest) val += ':b';