diff --git a/src/app/api/elections/ward-lookup/route.ts b/src/app/api/elections/ward-lookup/route.ts index e6afb4d..1ebcc64 100644 --- a/src/app/api/elections/ward-lookup/route.ts +++ b/src/app/api/elections/ward-lookup/route.ts @@ -1,18 +1,44 @@ import { NextRequest, NextResponse } from "next/server"; import { API_URL } from "@/lib/api/client"; +import type { WardLookupResponse } from "@/lib/elections/ward-lookup"; -// Thin proxy for york_factory's public ward lookup, so the browser never needs -// the API base URL and the response can be cached at our edge. +// Thin caching proxy for york_factory's public ward lookup, so the browser never +// needs the API base URL and a postal code is only ever resolved upstream once. // -// The postal code is forwarded exactly as typed: york_factory normalizes it and -// distinguishes "not a postal code" from "not in our data", which we'd lose by -// validating here. -// -// Cache-Control comes from upstream rather than being set here. The TTL varies -// by outcome on purpose — a day for a resolved ward, an hour for an unknown -// postal code (a real new code may appear in a later import), no-store for an -// upstream data outage — and second-guessing it would cache the wrong things. +// Caching is decided here rather than forwarded from upstream: york_factory +// currently answers every lookup `no-store`, which made each keystroke-submit a +// two-hop origin round trip for an answer that depends only on the postal code +// and changes at most once per boundary import. TTL varies by outcome — see +// CACHE_SECONDS. + +/** Full postal codes only; a bare FSA can't resolve to one ward. */ +const POSTAL_CODE = /^([A-Za-z]\d[A-Za-z])[\s-]*(\d[A-Za-z]\d)$/; + +// Seconds to cache each outcome. Malformed input is a pure function of the +// string, so it never expires; a resolved ward holds for a day. An unrecognized +// code gets an hour, because a real new code may appear in a later import. An +// upstream data outage isn't cached at all — it's a state to retry, not an +// answer. +const CACHE_SECONDS: Record = { + malformed_postal_code: 31_536_000, + resolved: 86_400, + outside_boundary: 86_400, + unknown_postal_code: 3_600, + boundary_data_unavailable: 0, +}; + +/** + * Canonicalize to "M4C 1S9" so spacing and casing variants of one code share a + * cache entry. Anything that isn't a full postal code is forwarded as typed: + * york_factory is what distinguishes "not a postal code" from "not in our + * data", and validating here would lose that. + */ +function cacheKeyFor(typed: string): string { + const match = typed.match(POSTAL_CODE); + return match ? `${match[1]} ${match[2]}`.toUpperCase() : typed; +} + export async function GET(req: NextRequest) { const postalCode = req.nextUrl.searchParams.get("postal_code")?.trim(); @@ -24,17 +50,33 @@ export async function GET(req: NextRequest) { } const url = new URL(`${API_URL}/geo/ward_lookup`); - url.searchParams.set("postal_code", postalCode); + url.searchParams.set("postal_code", cacheKeyFor(postalCode)); try { - const res = await fetch(url, { cache: "no-store" }); + // Next's data cache keys on the URL, so the second visitor to type a given + // postal code is served without touching york_factory even if the CDN in + // front of us missed. Held for the longest TTL any outcome uses; the + // Cache-Control below is what actually shortens the volatile ones. + const res = await fetch(url, { next: { revalidate: 86_400 } }); const body = await res.text(); + let maxAge = 0; + if (res.ok) { + try { + const reason = (JSON.parse(body) as WardLookupResponse).reason; + maxAge = CACHE_SECONDS[reason] ?? 0; + } catch { + // An unparseable 200 is an upstream problem, not an answer to keep. + } + } + return new NextResponse(body, { status: res.status, headers: { "Content-Type": "application/json", - "Cache-Control": res.headers.get("Cache-Control") ?? "no-store", + "Cache-Control": maxAge + ? `public, max-age=60, s-maxage=${maxAge}, stale-while-revalidate=${maxAge}` + : "no-store", }, }); } catch (error) { diff --git a/src/components/elections/WardLookup.tsx b/src/components/elections/WardLookup.tsx index 3d6abc1..8511b23 100644 --- a/src/components/elections/WardLookup.tsx +++ b/src/components/elections/WardLookup.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import type { WardView } from "@/lib/elections/election-data"; import type { WardLookupResponse } from "@/lib/elections/ward-lookup"; @@ -10,6 +10,22 @@ type State = | { status: "done"; result: WardLookupResponse } | { status: "failed" }; +/** A complete postal code, however the visitor spaced or cased it. */ +const POSTAL_CODE = /^([A-Za-z]\d[A-Za-z])[\s-]*(\d[A-Za-z]\d)$/; + +function normalize(typed: string): string | null { + const match = typed.trim().match(POSTAL_CODE); + return match ? `${match[1]} ${match[2]}`.toUpperCase() : null; +} + +/** + * Answers already fetched this session, keyed by normalized postal code. A + * lookup is a pure function of the code, so correcting a typo back to a code + * already tried — or re-submitting the same one — should cost nothing. Module + * scope so it survives the section unmounting. + */ +const cache = new Map(); + /** * Postal code → ward lookup for the wards section. The result is a best guess * — postal centroids sit off-line near ward boundaries — so it reads as "looks @@ -32,12 +48,19 @@ export default function WardLookup({ }) { const [postalCode, setPostalCode] = useState(""); const [state, setState] = useState({ status: "idle" }); + /** Which lookup is current, so a slow earlier reply can't overwrite a later one. */ + const latest = useRef(0); - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - const typed = postalCode.trim(); + const lookup = useCallback(async (typed: string) => { if (!typed) return; + const cached = cache.get(normalize(typed) ?? typed); + if (cached) { + setState({ status: "done", result: cached }); + return; + } + + const request = ++latest.current; setState({ status: "loading" }); try { // Sent exactly as typed — the API tolerates any spacing and casing, and @@ -46,11 +69,30 @@ export default function WardLookup({ `/api/elections/ward-lookup?postal_code=${encodeURIComponent(typed)}`, ); if (!res.ok) throw new Error(`ward-lookup ${res.status}`); - setState({ status: "done", result: await res.json() }); + const result: WardLookupResponse = await res.json(); + cache.set(normalize(typed) ?? typed, result); + if (request === latest.current) setState({ status: "done", result }); } catch (error) { console.error("[ward-lookup]", error); - setState({ status: "failed" }); + if (request === latest.current) setState({ status: "failed" }); } + }, []); + + // Look up as soon as the field holds a complete postal code, so the answer is + // usually on screen before the visitor reaches the button. Only complete + // codes fire — half-typed input would just spend requests on + // malformed_postal_code — and a short delay keeps a fast typist correcting the + // last character from sending two. + const complete = normalize(postalCode); + useEffect(() => { + if (!complete) return; + const timer = setTimeout(() => lookup(complete), 200); + return () => clearTimeout(timer); + }, [complete, lookup]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + lookup(postalCode.trim()); }; return (