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>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-06 15:13:49 +00:00
parent 8a0ad0c0dc
commit 158d5ded92
5 changed files with 36 additions and 60 deletions

3
.gitignore vendored
View file

@ -40,6 +40,9 @@ next-env.d.ts
backlog/**
# Session working notes / handovers — not for review (PR #355 feedback)
docs/wip/**
# Personal Claude Code settings (per-developer, not shared)
.claude/settings.local.json

View file

@ -1,58 +0,0 @@
# Handover: Add properties by postcode search
**Branch:** `feature/add-properties-by-postcode` (2 commits pushed, tree clean).
**Context date:** 2026-07-06. **Goal journey:** add properties → create scenarios (done, PR #353) → trigger modelling (soon).
## State
Done and verified (`npx tsc --noEmit` clean, 329 tests pass):
- **Domain core** `src/lib/postcodeSearch/model.ts` + `model.test.ts` (9 tests):
`normalisePostcode`, `classifyOsCode`, `isCacheFresh` (30-day), `toAddressCandidates`
(DPA>LPI, UPRN-dedup, postcode-stripped title-case address), `buildWritePlan`
(property row + 02 override facts).
- **ADR-0007** (`docs/adr/`) — all decisions + rationale. `docs/adr/**` gitignore rule removed
(it had eaten ADRs 00040006; ask team if recoverable).
- **CONTEXT.md** — "Postcode search" entry; VocabularyMapping now has two producers.
- `os_places` added to `OverrideSourceValues` (schema only — **migration not yet generated**).
- **Approved design mockup** (build the page to this): https://claude.ai/code/artifact/d05a3ab0-48ed-41c3-94b4-b13106b7f372
## Remaining work (in order)
1. **Enum migration**: run `./generate_migration.sh` for `override_source` + `os_places`
(no `.sql` migrations live in-repo; script is repo-root).
2. **Routes** under `src/app/api/portfolio/[portfolioId]/properties/`:
- `search/route.ts` GET `?postcode=`: normalise → `postcode_search` cache read
(`isCacheFresh`; row has unique `postcode` + jsonb `resultData`) → on miss/stale call
`lookupPostcode` (`src/app/api/postcode/LookupPostcode.ts`; **add
`parliamentary_constituency` to its result type**) + OS pages fetch
(`LookupOsPlaces.ts`) → upsert cache → return `toAddressCandidates` + geo +
`alreadyInPortfolio` flags (one query: uprns in portfolio) + cache age.
- `route.ts` POST submit `{candidates, geo}` (re-validate server-side): transaction over
`buildWritePlan` rows — insert `property` `ON CONFLICT DO NOTHING` (partial unique
`uq_property_portfolio_uprn`), upsert `landlord_property_type_overrides` /
`landlord_built_form_type_overrides` (`(portfolio_id, description)` unique,
description = OS code, source `os_places`), insert `property_overrides` snapshots
(`building_part=0`, `original_spreadsheet_description` = OS code). Return
`{added, skipped}`. Next-15 rules: `await params`, `{ error }` shape.
3. **Page** `/portfolio/[slug]/properties/add` + **"Add properties" dropdown** beside Export
in `src/app/portfolio/[slug]/components/PropertyTable.tsx` ("Search by postcode" +
"Bulk excel import — Soon"). Then retire toolbar `AddNew` dropdown. TanStack Query v4
mutations; no useEffect/useMemo (CLAUDE.md).
## How to verify (patterns proven this session)
- DB: `.env.local` creds; NODE_PATH node scripts with `pg` (`ssl:{rejectUnauthorized:false}`).
- Live app: `./node_modules/.bin/next dev -p 3111`; mint a session cookie with
`next-auth/jwt` `encode` + `NEXTAUTH_SECRET` from `.env.local`, email `@domna.homes`,
cookie `next-auth.session-token`. Server-rendered data is greppable in the RSC payload.
- Test portfolio: **814** (new-approach). Searched-in properties will render blank/awaiting
modelling — that's by design (ADR-0007), not a bug.
- `OS_API_KEY` env var powers OS Places.
## Gotchas
- Properties inserted now are "new approach" (`updated_at >= 2026-06-01`, `epcSources.ts`) —
every read path expects an EPC graph that won't exist until modelling. Expected.
- Never per-scenario/per-property probe loops on `plan` (1.6M rows) — grouped/scoped queries
only (see `docs/wip/new-modelling-ui-handover.md` perf notes).
- PR #353 (Scenarios tab) is separate, open; its ADR is numbered 0003.

View file

@ -160,6 +160,9 @@ export default function AddPropertiesClient({
}}
placeholder='e.g. "M20 4TF"'
autoFocus
spellCheck={false}
autoComplete="off"
autoCorrect="off"
className="flex-1 border border-slate-200 rounded-xl px-4 py-2.5 text-base font-semibold uppercase placeholder:normal-case placeholder:font-normal placeholder:text-sm placeholder:text-slate-400 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/10"
/>
<button
@ -298,6 +301,9 @@ export default function AddPropertiesClient({
</span>
</div>
{/* Long postcodes can return ~100 addresses scroll inside the card
rather than growing the page. */}
<div className="max-h-[26rem] overflow-y-auto pr-1 -mr-1">
{data.candidates.map((c) => {
const { label, note } = describeCandidate(c);
const disabled = !c.selectable || c.alreadyInPortfolio;
@ -350,12 +356,15 @@ export default function AddPropertiesClient({
</button>
);
})}
</div>
</div>
)}
{/* Selection tray */}
{totalSelected > 0 && !done && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 w-[min(56rem,calc(100vw-2rem))] bg-white/95 backdrop-blur border border-slate-200 rounded-2xl shadow-2xl px-4 py-3 flex items-center gap-3 flex-wrap">
{/* Many postcodes: chips scroll inside the tray instead of growing it */}
<div className="flex items-center gap-2 flex-wrap max-h-16 overflow-y-auto">
{postcodes.map((pc) => (
<span
key={pc}
@ -377,6 +386,7 @@ export default function AddPropertiesClient({
</button>
</span>
))}
</div>
<span className="text-xs text-slate-500">
<b className="text-gray-800">{totalSelected}</b>{" "}
{totalSelected === 1 ? "property" : "properties"} across{" "}

View file

@ -62,6 +62,24 @@ describe("toAddressCandidates", () => {
});
expect(out[1]).toMatchObject({ uprn: "101", selectable: false });
});
it("orders addresses numerically, so Flat 2 comes before Flat 10", () => {
const flats = [
{ DPA: { UPRN: "1", ADDRESS: "FLAT 10, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } },
{ DPA: { UPRN: "2", ADDRESS: "FLAT 2, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } },
{ DPA: { UPRN: "3", ADDRESS: "FLAT 1, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } },
{ DPA: { UPRN: "4", ADDRESS: "3 BIRCH LANE, M20 4TF", CLASSIFICATION_CODE: "RD02" } },
{ DPA: { UPRN: "5", ADDRESS: "12 BIRCH LANE, M20 4TF", CLASSIFICATION_CODE: "RD02" } },
];
const out = toAddressCandidates(flats as never, "M20 4TF");
expect(out.map((c) => c.address)).toEqual([
"3 Birch Lane",
"12 Birch Lane",
"Flat 1, Birch Court",
"Flat 2, Birch Court",
"Flat 10, Birch Court",
]);
});
});
describe("buildWritePlan", () => {

View file

@ -58,7 +58,8 @@ const titleCase = (s: string) =>
/**
* 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).
* suffix stripped (it lives in its own column); ordered naturally so
* "Flat 2" sorts before "Flat 10".
*/
export function toAddressCandidates(
items: OsItem[],
@ -90,7 +91,9 @@ export function toAddressCandidates(
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 {