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 ( +
+
+

{phaseWord} in {place.name}

+

+ {place.count} schools + {place.parent_authority && ( + <> + {' · '} + + {place.parent_authority} + + + )} +

+
+ + {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}
  • + ))} +
+ )} + +
+ + + + + + + + + {schools.map((s) => ( + + + + + ))} + +
School{phase === 'secondary' ? 'Attainment 8' : 'RWM expected'}
+ {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(); +}