This commit is contained in:
Andras Schmelczer 2026-07-03 18:47:28 +01:00
parent ab688243d7
commit 463bd4c647
54 changed files with 13239 additions and 625 deletions

View file

@ -12,7 +12,7 @@ import {
// "Serious crime" and "Minor crime" are aggregate severity rollups (they overlap
// the 14 detailed categories, so they must stay OUT of the "Specific crimes"
// dropdown). Each is a single feature with a 7-year/2-year window toggle the
// dropdown). Each is a single feature with a 7-year/2-year window toggle, the
// same folding the specific-crimes card has, but with no variant dropdown (one
// variant). Modeled on the multi-filter-name POI pattern: one lib, two filter
// names, distinguished by their key prefix / bare crime type.

View file

@ -152,7 +152,7 @@ export function buildPropertySearchUrls({
? Math.max(0, habitableRoomsFilter[1] - 1)
: undefined;
// Rightmove requires locationIdentifier from typeahead API
// Rightmove: requires locationIdentifier from typeahead API
let rightmove: string | null = null;
if (rightmoveLocationId) {
const rmParams = new URLSearchParams();
@ -190,7 +190,7 @@ export function buildPropertySearchUrls({
rightmove = `https://www.rightmove.co.uk/property-for-sale/find.html?${rmParams.toString()}`;
}
// OnTheMarket postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
// OnTheMarket: postcode slug in URL path (e.g. "SW1A 1AA" → "sw1a-1aa")
const otmSlug = postcode.toLowerCase().replace(/\s+/g, '-');
const otmParams = new URLSearchParams();
otmParams.set('radius', String(nearestRadius(radiusMiles, OTM_RADII)));

View file

@ -80,7 +80,7 @@ export function parseInputValue(
export function formatDuration(d: string): string {
if (d === 'F' || d === 'L') {
// These are server enum values translate via ts()
// These are server enum values: translate via ts()
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { ts } = require('../i18n/server') as { ts: (v: string) => string };
if (d === 'F') return ts('Freehold');

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import type { ActualListing } from '../types';
import { filterListingsByMode, isNewBuildListing } from './listings';
function listing(listing_url: string): ActualListing {
return {
lat: 51.5,
lon: -0.1,
postcode: 'N1 1AA',
listing_url,
features: [],
};
}
const newBuild = listing('https://www.rightmove.co.uk/properties/1#/?channel=RES_NEW');
const resale = listing('https://www.rightmove.co.uk/properties/2#/?channel=RES_BUY');
const otherPortal = listing('https://www.onthemarket.com/details/3/');
describe('isNewBuildListing', () => {
it('flags RES_NEW channel URLs as new builds', () => {
expect(isNewBuildListing(newBuild)).toBe(true);
});
it('treats RES_BUY (resale) as non-new', () => {
expect(isNewBuildListing(resale)).toBe(false);
});
it('treats non-Rightmove portals as non-new', () => {
expect(isNewBuildListing(otherPortal)).toBe(false);
});
});
describe('filterListingsByMode', () => {
const all = [newBuild, resale, otherPortal];
it('returns everything for "all"', () => {
expect(filterListingsByMode(all, 'all')).toEqual(all);
});
it('returns only new builds for "new"', () => {
expect(filterListingsByMode(all, 'new')).toEqual([newBuild]);
});
it('excludes new builds for "non-new"', () => {
expect(filterListingsByMode(all, 'non-new')).toEqual([resale, otherPortal]);
});
it('returns nothing for "none"', () => {
expect(filterListingsByMode(all, 'none')).toEqual([]);
});
});

View file

@ -0,0 +1,41 @@
import type { ActualListing } from '../types';
/**
* Which slice of the live for-sale listings to render on the map.
*
* - `all`: every listing in view
* - `new`: new-build listings only
* - `non-new`: everything that isn't a new build (resale, OnTheMarket, )
* - `none`: hide listings entirely (also skips the fetch)
*/
export type ListingsMode = 'all' | 'new' | 'non-new' | 'none';
export const LISTINGS_MODES: readonly ListingsMode[] = ['all', 'new', 'non-new', 'none'];
export const DEFAULT_LISTINGS_MODE: ListingsMode = 'all';
/**
* Rightmove encodes the sales channel in the listing URL: `?channel=RES_NEW` for
* new-build developments and `?channel=RES_BUY` for ordinary resale. Anything
* without `RES_NEW` (resale, OnTheMarket and other portals) is treated as non-new.
*/
export function isNewBuildListing(listing: Pick<ActualListing, 'listing_url'>): boolean {
return listing.listing_url.includes('RES_NEW');
}
/** Apply a {@link ListingsMode} to a list of listings. */
export function filterListingsByMode(
listings: ActualListing[],
mode: ListingsMode
): ActualListing[] {
switch (mode) {
case 'all':
return listings;
case 'new':
return listings.filter(isNewBuildListing);
case 'non-new':
return listings.filter((listing) => !isNewBuildListing(listing));
case 'none':
return [];
}
}

View file

@ -24,7 +24,7 @@ export interface PlacedItem<T> {
* Greedily keep a spaced-out subset of screen-space items so dense areas don't
* render a wall of overlapping cards. Walks `candidates` in order, keeping one
* only when it clears every already-kept item by `minDx`/`minDy`, and stops at
* `max`. Order-dependent by design pass candidates in priority order.
* `max`. Order-dependent by design: pass candidates in priority order.
*/
export function selectSpacedItems<T>(
candidates: PlacedItem<T>[],