PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
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 2m37s
Every place page built its phase links as /schools/[slug]/[phase], the shape that belongs to towns alone. On an authority page that pointed into the town namespace. For 87 of the 151 authorities the target does not exist and the link 404s; for the other 64 it resolves to the town of the same name — a different set of schools, which is precisely the near-duplicate the two namespaces were introduced to prevent. On an outcode page it 404s outright. Two causes behind it, both a rule written twice and inherited by only one of the places that needed it. The authority phase route was in the spec and dropped by the plan, which built the three bare routes and no fourth. The sitemap is generated from the place registry, which was right about them all along, so 302 authority phase URLs have been submitted to Google and every one 404s. Adding the route makes the sitemap true and serves a real query — admissions are authority-run, so "primary schools in Kent" is how a parent searches before they have settled on a town. The outcode variants were the opposite: the registry computed phases for outcodes although the spec gives them no route, and the sitemap knew to skip them while the API did not. The registry now decides alone, and the sitemap's duplicate of that rule is gone. Also: an authority under the five-school threshold has no page, so the API sends a null slug for it and the page names it without linking. Two English authorities are in that position. It was unreachable in today's data — verified across the EC and TR outcodes — but the thin place redirect would have sent a reader to a 404 the year it isn't. The e2e journey now walks every /schools link a page of each family emits and requires a 200, which is the check that was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
85 lines
2.6 KiB
TypeScript
85 lines
2.6 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;
|
|
/** null when that authority has no page of its own — two English
|
|
* authorities hold fewer schools than the threshold. */
|
|
slug: string | null;
|
|
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.
|
|
*
|
|
* Only a fallback: the API sends the slug it built, and that is what should
|
|
* be used. This mirrors `_slugify` in backend/app.py, collapsed runs and
|
|
* trimmed hyphens included, so the two cannot disagree about a name like
|
|
* "Bristol, City of".
|
|
*/
|
|
export function authoritySlug(name: string): string {
|
|
return name.toLowerCase().trim()
|
|
.replace(/[^\w\s-]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/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();
|
|
}
|