assessment-model/src/lib/postcodeSearch/model.ts
Khalim Conn-Kowlessar 158d5ded92 fix(postcode-search): PR #355 review feedback
- Order address candidates naturally so Flat 2 sorts before Flat 10
  (TDD'd in the domain core; numeric locale compare).
- Disable spell check / autocomplete / autocorrect on the postcode input.
- Long result lists and the tray's postcode chips now scroll inside
  their containers instead of growing the page.
- gitignore docs/wip/** and untrack the postcode-search handover doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:13:49 +00:00

192 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Postcode search domain model — pure logic for the add-properties-by-postcode
* journey. See CONTEXT.md (Postcode search) and ADR-0007.
*/
/** Canonical form: uppercase, single space before the inward code. Null if implausible. */
export function normalisePostcode(input: string): string | null {
const m = input.trim().toUpperCase().replace(/\s+/g, "").match(
/^([A-Z]{1,2}\d[A-Z\d]?)(\d[A-Z]{2})$/,
);
return m ? `${m[1]} ${m[2]}` : null;
}
export interface OsClassification {
selectable: boolean;
propertyType: string | null;
builtForm: string | null;
}
/**
* Deterministic OS classification-code mapping (ADR-0007). Only codes with a
* confident enum equivalent write facts; "Unknown" is never produced — an
* unmappable residential code stays selectable with no facts. RD10
* (residential institutions) and all non-RD codes are not addable.
*/
const OS_CODE_FACTS: Record<string, { propertyType: string; builtForm: string | null }> = {
RD02: { propertyType: "House", builtForm: "Detached" },
RD03: { propertyType: "House", builtForm: "Semi-Detached" },
RD04: { propertyType: "House", builtForm: null }, // terraced — mid/end unknowable
RD06: { propertyType: "Flat", builtForm: null },
RD07: { propertyType: "House", builtForm: null }, // HMO
};
const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
/** Read-through cache rule: serve postcode_search rows younger than 30 days. */
export function isCacheFresh(fetchedAt: Date | null, now: Date): boolean {
return !!fetchedAt && now.getTime() - fetchedAt.getTime() < CACHE_MAX_AGE_MS;
}
type OsItem = { DPA?: Record<string, unknown>; LPI?: Record<string, unknown> };
export interface AddressCandidate {
uprn: string;
address: string;
postcode: string;
classificationCode: string | null;
lat: number | null;
lng: number | null;
selectable: boolean;
propertyType: string | null;
builtForm: string | null;
}
const titleCase = (s: string) =>
s.toLowerCase().replace(/\b[a-z]/g, (c) => c.toUpperCase());
/**
* Flatten OS Places items into unique address candidates: DPA preferred over
* LPI for the same UPRN; address rendered in title case with the postcode
* suffix stripped (it lives in its own column); ordered naturally so
* "Flat 2" sorts before "Flat 10".
*/
export function toAddressCandidates(
items: OsItem[],
postcode: string,
): AddressCandidate[] {
const byUprn = new Map<string, { rec: Record<string, unknown>; isDpa: boolean }>();
for (const item of items) {
const rec = item.DPA ?? item.LPI;
if (!rec || rec.UPRN == null) continue;
const uprn = String(rec.UPRN);
const existing = byUprn.get(uprn);
if (!existing || (item.DPA && !existing.isDpa)) {
byUprn.set(uprn, { rec, isDpa: !!item.DPA });
}
}
return [...byUprn.values()].map(({ rec }) => {
const code = (rec.CLASSIFICATION_CODE as string) ?? null;
const cls = classifyOsCode(code);
const raw = String(rec.ADDRESS ?? "");
const address = titleCase(
raw.replace(new RegExp(`,?\\s*${postcode.replace(" ", "\\s*")}\\s*$`, "i"), ""),
);
return {
uprn: String(rec.UPRN),
address,
postcode,
classificationCode: code,
lat: typeof rec.LAT === "number" ? rec.LAT : null,
lng: typeof rec.LNG === "number" ? rec.LNG : null,
...cls,
};
}).sort((a, b) =>
a.address.localeCompare(b.address, "en", { numeric: true, sensitivity: "base" }),
);
}
export interface PostcodeGeo {
localAuthority: string | null;
constituency: string | null;
}
/** The rows a selected candidate produces: one property + 02 override facts (ADR-0007). */
export function buildWritePlan(c: AddressCandidate, geo: PostcodeGeo) {
const overrides: {
component: "property_type" | "built_form_type";
value: string;
originalDescription: string;
buildingPart: number;
}[] = [];
if (c.propertyType && c.classificationCode) {
overrides.push({
component: "property_type",
value: c.propertyType,
originalDescription: c.classificationCode,
buildingPart: 0,
});
}
if (c.builtForm && c.classificationCode) {
overrides.push({
component: "built_form_type",
value: c.builtForm,
originalDescription: c.classificationCode,
buildingPart: 0,
});
}
return {
property: {
address: c.address,
postcode: c.postcode,
uprn: BigInt(c.uprn),
latitude: c.lat,
longitude: c.lng,
localAuthority: geo.localAuthority,
constituency: geo.constituency,
},
overrides,
};
}
/**
* Presentation of a candidate's classification: the type chip label plus an
* optional explanatory note (why terraced has no built form; why an unmapped
* residential code still adds cleanly).
*/
export function describeCandidate(c: {
selectable: boolean;
propertyType?: string | null;
builtForm?: string | null;
classificationCode?: string | null;
}): { label: string; note: string | null } {
if (!c.selectable) return { label: "Non-residential", note: null };
if (c.propertyType && c.builtForm) {
return { label: `${c.propertyType} · ${c.builtForm}`, note: null };
}
if (c.propertyType) {
return {
label: c.propertyType,
note: c.classificationCode?.toUpperCase() === "RD04"
? "Terraced — mid or end unknown"
: null,
};
}
return { label: "Residential", note: "No classification — type comes with modelling" };
}
/**
* postcode_search cache keys to read, canonical spaced form first. Legacy rows
* were keyed without the space; new rows are written under the canonical form.
*/
export function postcodeCacheKeys(postcode: string): string[] {
return [postcode, postcode.replace(" ", "")];
}
/** Whole days since the cache row was refreshed (for the "N days old" line). */
export function cacheAgeDays(fetchedAt: Date, now: Date): number {
return Math.max(0, Math.floor((now.getTime() - fetchedAt.getTime()) / (24 * 60 * 60 * 1000)));
}
export function classifyOsCode(code: string | null): OsClassification {
if (!code) return { selectable: false, propertyType: null, builtForm: null };
const c = code.trim().toUpperCase();
const residential = c === "RD" || (c.startsWith("RD") && c !== "RD10");
if (!residential) return { selectable: false, propertyType: null, builtForm: null };
const facts = OS_CODE_FACTS[c];
return {
selectable: true,
propertyType: facts?.propertyType ?? null,
builtForm: facts?.builtForm ?? null,
};
}