PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m33s
SW19 is mostly Merton but partly Wandsworth, and the page said only Merton. The cause was one field doing two jobs: _parent_authority takes the modal authority, which is right for a 301 target and wrong as a statement about where a place is. This is not a corner case. A quarter of viable outcodes (425 of 1,760) and a third of viable towns (263 of 783) cross an authority boundary — Bedford the town spans Bedford and Central Bedfordshire. Place now carries `authorities`, every authority holding at least a tenth of the schools and at least two of them, largest first. parent_authority stays single and unchanged, because a redirect still needs one target. The share threshold exists because GIAS carries postcode errors: EN6 lists two Shropshire schools among fourteen in Hertfordshire, and a bare "any authority present" rule would print those as though they were real. A place too small or too fragmented to clear the threshold still names its largest, so the page never goes silent about where it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
72 lines
2.2 KiB
TypeScript
72 lines
2.2 KiB
TypeScript
/**
|
|
* Client for the places API.
|
|
*
|
|
* Two namespaces, matching the backend: towns and localities share
|
|
* /schools/[place]; authorities take /schools/authority/[la]. 67 town names
|
|
* collide with an authority name and neither set contains the other, so one
|
|
* namespace would publish near-duplicate pages.
|
|
*/
|
|
import type { School } from '@/lib/types';
|
|
|
|
export interface PlaceSummary {
|
|
kind: string;
|
|
slug: string;
|
|
name: string;
|
|
count: number;
|
|
/** Phases that clear the threshold on their own, so the page links
|
|
* variants that exist rather than 404s. Absent on the registry listing. */
|
|
phases?: string[];
|
|
}
|
|
|
|
export interface PlaceAuthority {
|
|
name: string;
|
|
slug: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface PlaceDetail {
|
|
place: PlaceSummary & {
|
|
parent_authority: string | null;
|
|
/** Every authority the place meaningfully sits in, largest first. SW19 is
|
|
* mostly Merton but partly Wandsworth. */
|
|
authorities?: PlaceAuthority[];
|
|
};
|
|
schools: School[];
|
|
averages: {
|
|
rwm_expected_pct: number | null;
|
|
attainment_8_score: number | null;
|
|
};
|
|
}
|
|
|
|
export function placeUrl(kind: string, slug: string, phase?: string): string {
|
|
const base =
|
|
kind === 'authority' ? `/schools/authority/${slug}`
|
|
: kind === 'outcode' ? `/schools/near/${slug}`
|
|
: `/schools/${slug}`;
|
|
return phase ? `${base}/${phase}` : base;
|
|
}
|
|
|
|
/** An authority name as it appears in a URL. */
|
|
export function authoritySlug(name: string): string {
|
|
return name.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-');
|
|
}
|
|
|
|
const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL
|
|
|| 'http://localhost:8000/api';
|
|
|
|
export async function fetchPlaces(): Promise<PlaceSummary[]> {
|
|
const res = await fetch(`${API}/places`, { next: { revalidate: 604800 } });
|
|
if (!res.ok) return [];
|
|
return (await res.json()).places ?? [];
|
|
}
|
|
|
|
export async function fetchPlace(
|
|
kind: string, slug: string, phase?: string,
|
|
): Promise<PlaceDetail | null> {
|
|
const q = phase ? `?phase=${encodeURIComponent(phase)}` : '';
|
|
const res = await fetch(`${API}/places/${kind}/${slug}${q}`,
|
|
{ next: { revalidate: 604800 } });
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
}
|