Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 55 additions & 13 deletions src/app/api/elections/ward-lookup/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
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();

Expand All @@ -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) {
Expand Down
54 changes: 48 additions & 6 deletions src/components/elections/WardLookup.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<string, WardLookupResponse>();

/**
* 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
Expand All @@ -32,12 +48,19 @@ export default function WardLookup({
}) {
const [postalCode, setPostalCode] = useState("");
const [state, setState] = useState<State>({ 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;
Comment on lines +57 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cache hits bypass sequence guard

When an uncached lookup remains in flight and the visitor enters a cached postal code, the cached branch returns without advancing latest.current; the earlier request therefore still passes the sequence guard and overwrites the current result with another postal code's ward or an error.

Suggested change
const cached = cache.get(normalize(typed) ?? typed);
if (cached) {
setState({ status: "done", result: cached });
return;
}
const request = ++latest.current;
const request = ++latest.current;
const cached = cache.get(normalize(typed) ?? typed);
if (cached) {
setState({ status: "done", result: cached });
return;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/elections/WardLookup.tsx
Line: 57-63

Comment:
**Cache hits bypass sequence guard**

When an uncached lookup remains in flight and the visitor enters a cached postal code, the cached branch returns without advancing `latest.current`; the earlier request therefore still passes the sequence guard and overwrites the current result with another postal code's ward or an error.

```suggestion
    const request = ++latest.current;
    const cached = cache.get(normalize(typed) ?? typed);
    if (cached) {
      setState({ status: "done", result: cached });
      return;
    }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

setState({ status: "loading" });
try {
// Sent exactly as typed — the API tolerates any spacing and casing, and
Expand All @@ -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);
Comment on lines +72 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Transient outages persist in session

When the API returns boundary_data_unavailable, the unconditional cache write stores that transient outage in the module-scoped map, causing every retry for the postal code to keep showing "We can't look that up right now" for the remainder of the session even after the service recovers.

Suggested change
const result: WardLookupResponse = await res.json();
cache.set(normalize(typed) ?? typed, result);
const result: WardLookupResponse = await res.json();
if (result.reason !== "boundary_data_unavailable") {
cache.set(normalize(typed) ?? typed, result);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/elections/WardLookup.tsx
Line: 72-73

Comment:
**Transient outages persist in session**

When the API returns `boundary_data_unavailable`, the unconditional cache write stores that transient outage in the module-scoped map, causing every retry for the postal code to keep showing "We can't look that up right now" for the remainder of the session even after the service recovers.

```suggestion
      const result: WardLookupResponse = await res.json();
      if (result.reason !== "boundary_data_unavailable") {
        cache.set(normalize(typed) ?? typed, result);
      }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

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 (
Expand Down
Loading