..
This commit is contained in:
parent
ab688243d7
commit
463bd4c647
54 changed files with 13239 additions and 625 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
52
frontend/src/lib/listings.test.ts
Normal file
52
frontend/src/lib/listings.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
41
frontend/src/lib/listings.ts
Normal file
41
frontend/src/lib/listings.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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>[],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue