diff --git a/nextjs-app/__tests__/components/PlaceView.test.tsx b/nextjs-app/__tests__/components/PlaceView.test.tsx
new file mode 100644
index 0000000..c1685c1
--- /dev/null
+++ b/nextjs-app/__tests__/components/PlaceView.test.tsx
@@ -0,0 +1,70 @@
+import { render, screen } from '@testing-library/react';
+import { PlaceView } from '@/components/places/PlaceView';
+import type { PlaceDetail } from '@/lib/places';
+
+const detail: PlaceDetail = {
+ place: { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 29,
+ parent_authority: 'Essex' },
+ schools: [
+ { urn: 1, school_name: 'Alpha Primary', rwm_expected_pct: 82,
+ ofsted_grade: 1 } as never,
+ { urn: 2, school_name: 'Beta Primary', rwm_expected_pct: 44,
+ ofsted_grade: 3 } as never,
+ ],
+ averages: { rwm_expected_pct: 63, attainment_8_score: null },
+};
+
+describe('PlaceView', () => {
+ it('leads with an H1 that matches how the place is searched', () => {
+ render();
+ expect(screen.getByRole('heading', { level: 1 }))
+ .toHaveTextContent(/primary schools in brentwood/i);
+ });
+
+ it('states the count so the page says something before the table', () => {
+ render();
+ expect(screen.getByText(/29 schools/i)).toBeInTheDocument();
+ });
+
+ it('compares the local average against England, which a list cannot', () => {
+ render();
+ expect(screen.getByTestId('local-vs-england')).toHaveTextContent('63');
+ expect(screen.getByTestId('local-vs-england')).toHaveTextContent('61');
+ });
+
+ it('links every school in scope, which is what de-orphans them', () => {
+ render();
+ expect(screen.getAllByRole('link', { name: /Primary$/ })).toHaveLength(2);
+ });
+
+ it('links to the parent authority so the place sits in a hierarchy', () => {
+ render();
+ expect(screen.getByRole('link', { name: /Essex/i }))
+ .toHaveAttribute('href', '/schools/authority/essex');
+ });
+
+ it('shows the Ofsted distribution, not just a count of Outstanding', () => {
+ render();
+ expect(screen.getByTestId('ofsted-distribution')).toBeInTheDocument();
+ });
+
+ it('links to neighbouring places so the page is not a dead end', () => {
+ render();
+ expect(screen.getByRole('link', { name: /Romford/ }))
+ .toHaveAttribute('href', '/schools/romford');
+ });
+
+ it('says nothing about an average it does not have', () => {
+ render();
+ expect(screen.queryByTestId('local-vs-england')).not.toBeInTheDocument();
+ });
+});
diff --git a/nextjs-app/components/places/PlaceView.module.css b/nextjs-app/components/places/PlaceView.module.css
new file mode 100644
index 0000000..326d778
--- /dev/null
+++ b/nextjs-app/components/places/PlaceView.module.css
@@ -0,0 +1,108 @@
+/* Tokens only — see globals.css. Matches RankingsView's conventions. */
+.container {
+ width: 100%;
+ min-width: 0;
+}
+
+.header {
+ margin-bottom: 1.5rem;
+}
+
+.header h1 {
+ font-size: 2.25rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ margin-bottom: 0.5rem;
+ font-family: var(--font-display);
+ text-wrap: balance;
+}
+
+.summary {
+ font-size: 1rem;
+ color: var(--text-secondary);
+ margin: 0;
+ line-height: 1.6;
+}
+
+/* The one number a list cannot give you, so it gets its own band. */
+.compare {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 0.875rem 1.125rem;
+ margin: 0 0 1.5rem;
+ color: var(--text-primary);
+ font-size: 1rem;
+}
+
+.ofsted {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 1.25rem;
+ list-style: none;
+ padding: 0;
+ margin: 0 0 1.5rem;
+ font-size: 0.9375rem;
+ color: var(--text-secondary);
+}
+
+/* Wide content scrolls in its own container so the page body never does. */
+.tableWrap {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-card);
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9375rem;
+}
+
+.table th,
+.table td {
+ padding: 0.75rem 1rem;
+ text-align: left;
+ border-bottom: 1px solid var(--border);
+}
+
+.table th {
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ font-weight: 600;
+ font-size: 0.8125rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.table th:last-child,
+.num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.neighbours {
+ margin-top: 2rem;
+}
+
+.neighbours h2 {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0 0 0.75rem;
+ font-family: var(--font-display);
+}
+
+.neighbours ul {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 1rem;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
diff --git a/nextjs-app/components/places/PlaceView.tsx b/nextjs-app/components/places/PlaceView.tsx
new file mode 100644
index 0000000..56fdf97
--- /dev/null
+++ b/nextjs-app/components/places/PlaceView.tsx
@@ -0,0 +1,111 @@
+/**
+ * One place page, shared by all four families.
+ *
+ * They differ in what fills the registry, not in what the page shows, so a
+ * second component would be a second place to forget the same change.
+ *
+ * The local-versus-England comparison is the reason this page is not a list:
+ * it is the one number a parent cannot get by reading the schools one by one,
+ * and it is what keeps the page from reading as a name dropped into a
+ * template.
+ */
+import Link from 'next/link';
+import type { PlaceDetail, PlaceSummary } from '@/lib/places';
+import { placeUrl, authoritySlug } from '@/lib/places';
+import { schoolUrl } from '@/lib/utils';
+import styles from './PlaceView.module.css';
+
+interface Props {
+ detail: PlaceDetail;
+ phase?: 'primary' | 'secondary';
+ englandAverage: number | null;
+ /** Nearby places, so the page links onward instead of dead-ending. */
+ neighbours: PlaceSummary[];
+}
+
+// Ofsted grades in the order they are reported.
+const OFSTED_LABELS: Array<[number, string]> = [
+ [1, 'Outstanding'], [2, 'Good'],
+ [3, 'Requires improvement'], [4, 'Inadequate'],
+];
+
+export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) {
+ const { place, schools, averages } = detail;
+ const metric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
+ const local = averages[metric];
+ const phaseWord = phase === 'secondary' ? 'Secondary schools'
+ : phase === 'primary' ? 'Primary schools' : 'Schools';
+ const graded = OFSTED_LABELS
+ .map(([grade, label]) => [label, schools.filter((s) => s.ofsted_grade === grade).length] as const)
+ .filter(([, n]) => n > 0);
+
+ return (
+
+
+
+ {local != null && englandAverage != null && (
+
+ {place.name} averages {Math.round(local)} against{' '}
+ {Math.round(englandAverage)} across England.
+
+ )}
+
+ {graded.length > 0 && (
+
+ {graded.map(([label, n]) => (
+ - {label}: {n}
+ ))}
+
+ )}
+
+
+
+
+
+ | School |
+ {phase === 'secondary' ? 'Attainment 8' : 'RWM expected'} |
+
+
+
+ {schools.map((s) => (
+
+ |
+ {s.school_name}
+ |
+
+ {s[metric] == null ? '—' : Math.round(Number(s[metric]))}
+ |
+
+ ))}
+
+
+
+
+ {neighbours.length > 0 && (
+
+ )}
+
+ );
+}
diff --git a/nextjs-app/lib/places.ts b/nextjs-app/lib/places.ts
new file mode 100644
index 0000000..2c6b392
--- /dev/null
+++ b/nextjs-app/lib/places.ts
@@ -0,0 +1,57 @@
+/**
+ * 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;
+}
+
+export interface PlaceDetail {
+ place: PlaceSummary & { parent_authority: string | null };
+ 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 {
+ 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 {
+ 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();
+}