mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-19 17:02:59 +00:00
- 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>
192 lines
6.4 KiB
TypeScript
192 lines
6.4 KiB
TypeScript
/**
|
||
* 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 + 0–2 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,
|
||
};
|
||
}
|