diff --git a/nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx b/nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx
new file mode 100644
index 0000000..5eda120
--- /dev/null
+++ b/nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx
@@ -0,0 +1,140 @@
+/**
+ * The section's job is to be honest about what it matched. These tests pin the
+ * ways it could lie: rendering below the minimum, claiming a similar intake at
+ * tier 3, showing a missing figure as a number, or hiding a card behind an
+ * arrow where a crawler cannot reach it.
+ */
+
+import { render, screen } from '@testing-library/react';
+
+import {
+ SimilarSchoolsSection,
+ shouldRenderSimilar,
+} from '@/components/school/SimilarSchoolsSection';
+import type { SimilarSchool } from '@/lib/types';
+
+jest.mock('@/components/school/AddToCompareButton', () => ({
+ AddToCompareButton: ({ school }: { school: SimilarSchool }) => (
+
+ ),
+}));
+
+jest.mock('@/components/school/SimilarSchoolsCompareBar', () => ({
+ SimilarSchoolsCompareBar: ({ thisUrn }: { thisUrn: number }) => (
+
bar for {thisUrn}
+ ),
+}));
+
+function school(overrides: Partial = {}): SimilarSchool {
+ return {
+ urn: 100002,
+ school_name: 'Willow Lane Primary School',
+ distance_miles: 0.6,
+ school_type: 'Community school',
+ age_range: '4-11',
+ shared: ['Mixed', 'No religious character'],
+ tier: 1,
+ metric_value: 74,
+ metric_key: 'rwm_expected_pct',
+ metric_year: 202425,
+ ...overrides,
+ };
+}
+
+function renderSection(similar: SimilarSchool[]) {
+ return render(
+ ,
+ );
+}
+
+describe('render gates', () => {
+ it.each([
+ ['undefined', undefined],
+ ['null', null],
+ ['empty', []],
+ ['a single school', [school()]],
+ ])('renders nothing for %s', (_label, value) => {
+ expect(shouldRenderSimilar(value as SimilarSchool[] | null | undefined)).toBe(false);
+ });
+
+ it('renders for two or more schools', () => {
+ expect(shouldRenderSimilar([school(), school({ urn: 100003 })])).toBe(true);
+ });
+
+ it('returns null rather than an empty shell below the minimum', () => {
+ const { container } = renderSection([school()]);
+ expect(container).toBeEmptyDOMElement();
+ });
+});
+
+describe('the claim the lede makes', () => {
+ it('claims a similar intake when every card is tier 1 or 2', () => {
+ renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 2 })]);
+ expect(screen.getByText(/with a similar intake/i)).toBeInTheDocument();
+ });
+
+ it('drops the claim when any card is tier 3', () => {
+ renderSection([school({ tier: 1 }), school({ urn: 100003, tier: 3, shared: ['Primary school'] })]);
+ expect(screen.queryByText(/with a similar intake/i)).not.toBeInTheDocument();
+ });
+});
+
+describe('cards', () => {
+ it('links each school to its canonical slug', () => {
+ renderSection([school(), school({ urn: 100003, school_name: 'Oakfield Primary School' })]);
+ const link = screen.getByRole('link', { name: /Willow Lane Primary School/ });
+ expect(link).toHaveAttribute('href', '/school/100002-willow-lane-primary-school');
+ });
+
+ it('shows the distance and the shared characteristics', () => {
+ renderSection([school(), school({ urn: 100003 })]);
+ expect(screen.getAllByText('0.6 miles away').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Mixed').length).toBeGreaterThan(0);
+ });
+
+ it('renders a missing figure as "Not published", never as a number', () => {
+ renderSection([school({ metric_value: null }), school({ urn: 100003 })]);
+ expect(screen.getByText('Not published')).toBeInTheDocument();
+ });
+
+ it('anchors each figure against this school', () => {
+ renderSection([school(), school({ urn: 100003 })]);
+ expect(screen.getAllByText('72% at this school').length).toBe(2);
+ });
+
+ it('offers the compare bar once, for this school', () => {
+ renderSection([school(), school({ urn: 100003 })]);
+ expect(screen.getByTestId('compare-bar')).toHaveTextContent('bar for 100001');
+ });
+
+ it('keeps every card in the DOM, including the ones scrolled out of view', () => {
+ const six = Array.from({ length: 6 }, (_, n) =>
+ school({ urn: 100002 + n, school_name: `Peer ${n} School` }),
+ );
+ renderSection(six);
+ expect(screen.getAllByRole('link', { name: /Peer \d School/ })).toHaveLength(6);
+ });
+
+ it('offers no arrows when three cards fit the row', () => {
+ renderSection([school(), school({ urn: 100003 }), school({ urn: 100004 })]);
+ expect(screen.queryByRole('button', { name: /More schools/ })).not.toBeInTheDocument();
+ });
+
+ it('offers arrows once there is a fourth school', () => {
+ renderSection(Array.from({ length: 4 }, (_, n) => school({ urn: 100002 + n })));
+ expect(screen.getByRole('button', { name: /More schools/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Previous schools/ })).toBeInTheDocument();
+ });
+
+ it('says distances are straight-line, and offers no method panel', () => {
+ const { container } = renderSection([school(), school({ urn: 100003 })]);
+ expect(screen.getByText(/straight-line from this school/i)).toBeInTheDocument();
+ expect(container.querySelector('details')).toBeNull();
+ });
+});
diff --git a/nextjs-app/components/school/AddToCompareButton.tsx b/nextjs-app/components/school/AddToCompareButton.tsx
new file mode 100644
index 0000000..b263969
--- /dev/null
+++ b/nextjs-app/components/school/AddToCompareButton.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+/**
+ * The per-card basket toggle.
+ *
+ * The links around it are server-rendered, so the section works with JS off;
+ * this adds the basket interaction on top rather than being what makes the
+ * section function.
+ */
+
+import { useComparisonContext } from '@/context/ComparisonContext';
+import type { School, SimilarSchool } from '@/lib/types';
+import styles from './SimilarSchools.module.css';
+
+export function AddToCompareButton({ school }: { school: SimilarSchool }) {
+ const { addSchool, removeSchool, selectedSchools } = useComparisonContext();
+ const selected = selectedSchools.some((s) => s.urn === school.urn);
+
+ const toggle = () => {
+ if (selected) {
+ removeSchool(school.urn);
+ return;
+ }
+ // The basket only needs identity and display fields; the compare page
+ // fetches everything it renders by URN.
+ addSchool({
+ urn: school.urn,
+ school_name: school.school_name,
+ school_type: school.school_type,
+ age_range: school.age_range,
+ } as School);
+ };
+
+ return (
+
+ );
+}
diff --git a/nextjs-app/components/school/SimilarSchools.module.css b/nextjs-app/components/school/SimilarSchools.module.css
new file mode 100644
index 0000000..2427b7a
--- /dev/null
+++ b/nextjs-app/components/school/SimilarSchools.module.css
@@ -0,0 +1,65 @@
+.heading { font-family: var(--font-display); font-size: 1.4rem; letter-spacing: -0.4px; margin: 0; }
+.lede { margin: 0.5rem 0 1.25rem; color: var(--text-secondary); max-width: 64ch; }
+.caption { margin: 1rem 0 0; font-size: 0.72rem; color: var(--text-muted); }
+
+.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
+.arrows { display: flex; gap: 0.5rem; flex: none; }
+.arrow { width: 44px; height: 44px; display: grid; place-items: center; cursor: pointer; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--bg-card); color: var(--brand); }
+.arrow:hover:not(:disabled) { border-color: var(--brand); background: var(--brand-bg); }
+.arrow:disabled { opacity: 0.35; cursor: default; }
+.arrow svg { width: 17px; height: 17px; }
+
+/* A scroller, not a paginated view: every card is in the DOM and the arrows
+ only move the viewport across them. The 2px padding keeps focus rings from
+ being clipped — and is why the arrows' edge test needs a tolerance. */
+.scroller { display: grid; grid-auto-flow: column; grid-auto-columns: calc((100% - 1.8rem) / 3); gap: 0.9rem; overflow-x: auto; scroll-snap-type: x mandatory; padding: 2px; margin: -2px; list-style: none; scrollbar-width: none; -ms-overflow-style: none; }
+.scroller::-webkit-scrollbar { display: none; }
+@media (max-width: 820px) { .scroller { grid-auto-columns: calc((100% - 0.9rem) / 2); } }
+/* Touch widths (MOBILE.md): the arrows would take 96px from a 328px card and
+ crush the lede into four lines, for a control swiping already provides. They
+ go, and the documented right-edge fade carries the affordance — lifting at
+ the end of the travel, where there is nothing more to hint at. */
+@media (max-width: 640px) {
+ .top { display: block; }
+ .arrows { display: none; }
+ .scroller { grid-auto-columns: 86%; mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); }
+ .scroller[data-at-end="true"] { mask-image: none; }
+}
+
+.school { position: relative; display: flex; flex-direction: column; scroll-snap-align: start; border: 1px solid var(--border); border-radius: 8px; padding: 1rem; background: var(--bg-card); }
+.school:hover { border-color: var(--border-strong); }
+
+.distance { margin: 0 0 0.6rem; font-size: 0.75rem; color: var(--text-muted); }
+.name { font-family: var(--font-display); font-size: 1rem; line-height: 1.35; margin: 0 0 0.35rem; }
+.name a { color: var(--text-primary); text-decoration: none; }
+/* The whole card is the link target; the button sits above it on z-index. */
+.name a::after { content: ""; position: absolute; inset: 0; border-radius: 8px; }
+.name a:hover { color: var(--brand); text-decoration: underline; }
+.meta { margin: 0 0 0.75rem; font-size: 0.78rem; color: var(--text-muted); }
+
+.shared { display: flex; flex-wrap: wrap; gap: 0.35rem; list-style: none; margin: 0 0 0.85rem; padding: 0; }
+.chip { font-size: 0.72rem; line-height: 1.4; padding: 0.25rem 0.5rem; border-radius: 999px; background: var(--brand-bg); color: var(--brand); border: 1px solid transparent; }
+.chipLoose { font-size: 0.72rem; line-height: 1.4; padding: 0.25rem 0.5rem; border-radius: 999px; background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
+
+.metric { margin-top: auto; padding-top: 0.8rem; border-top: 1px solid var(--border); }
+/* No valence colour here, deliberately: green and terracotta mean "against the
+ England average" everywhere else on the site, and colouring a neighbour
+ against this school would read as ranking the neighbours. */
+.value { font-family: var(--font-display); font-size: 1.6rem; font-weight: 700; letter-spacing: -0.6px; margin: 0; color: var(--text-primary); }
+.valueAbsent { font-size: 0.95rem; font-weight: 600; margin: 0; color: var(--text-muted); }
+.metricLabel { margin: 0.25rem 0 0; font-size: 0.75rem; color: var(--text-secondary); }
+.metricRef { margin: 0.1rem 0 0; font-size: 0.75rem; color: var(--text-muted); }
+
+.add { position: relative; z-index: 1; margin-top: 0.85rem; width: 100%; min-height: 44px; font: inherit; font-size: 0.82rem; font-weight: 500; cursor: pointer; border-radius: 8px; border: 1px solid var(--border-strong); background: var(--bg-card); color: var(--brand); }
+.add:hover { border-color: var(--brand); background: var(--brand-bg); }
+.add[aria-pressed="true"] { border-color: var(--brand); background: var(--brand-bg); font-weight: 600; }
+
+.footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.85rem; margin-top: 1.25rem; padding-top: 1.1rem; border-top: 1px solid var(--border); }
+.footer p { margin: 0; font-size: 0.78rem; color: var(--text-muted); }
+.footer strong { display: block; font-size: 0.88rem; font-weight: 600; color: var(--text-primary); }
+/* Coral is the one decisive action per screen, and in this section this is it. */
+.compare { min-height: 44px; padding: 0 1.25rem; border-radius: 8px; font-size: 0.88rem; font-weight: 600; background: var(--action); color: var(--action-on); border: 1px solid var(--action); text-decoration: none; display: inline-flex; align-items: center; }
+.compare:hover { background: var(--action-strong); border-color: var(--action-strong); }
+.compare[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
+
+.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
diff --git a/nextjs-app/components/school/SimilarSchoolsCarousel.tsx b/nextjs-app/components/school/SimilarSchoolsCarousel.tsx
new file mode 100644
index 0000000..8b748a4
--- /dev/null
+++ b/nextjs-app/components/school/SimilarSchoolsCarousel.tsx
@@ -0,0 +1,127 @@
+'use client';
+
+/**
+ * The scroller and its arrows.
+ *
+ * `children` are the server-rendered cards and `header` the server-rendered
+ * heading and lede: both stay server components, passed through, so this file
+ * owns a DOM ref and nothing else. That is what keeps all six links in the
+ * initial HTML — a carousel that mounted cards on click would put four of the
+ * six beyond a crawler and beyond a reader with no JavaScript.
+ *
+ * With JavaScript off this degrades to a horizontally scrollable row, which is
+ * still usable by touch and trackpad.
+ */
+
+import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
+import styles from './SimilarSchools.module.css';
+
+/** Three cards fit the row, so fewer than four has nowhere to scroll to.
+ * Below 640px the arrows are not rendered at all — see the stylesheet. */
+const VISIBLE = 3;
+
+/**
+ * Why a tolerance rather than `=== 0`.
+ *
+ * The scroller carries 2px of padding so focus rings are not clipped, and
+ * scroll-snap treats that padding as the first card's snap position — a row at
+ * rest reports scrollLeft 2, not 0. Sub-pixel rounding moves it again at other
+ * zoom levels. An exact test leaves the back arrow live on first paint,
+ * pointing nowhere.
+ */
+const EDGE = 8;
+
+export function SimilarSchoolsCarousel({
+ count,
+ labelledBy,
+ header,
+ children,
+}: {
+ count: number;
+ labelledBy: string;
+ header: ReactNode;
+ children: ReactNode;
+}) {
+ const scroller = useRef(null);
+ const [atStart, setAtStart] = useState(true);
+ const [atEnd, setAtEnd] = useState(false);
+ const scrollable = count > VISIBLE;
+
+ const sync = useCallback(() => {
+ const node = scroller.current;
+ if (!node) return;
+ const max = node.scrollWidth - node.clientWidth;
+ setAtStart(node.scrollLeft <= EDGE);
+ setAtEnd(node.scrollLeft >= max - EDGE);
+ }, []);
+ // `atEnd` is not only the forward arrow's disabled state: below 640px, where
+ // no arrow is rendered, it is the only thing driving the scroll-fade.
+
+ // Also on mount: the first measurement can only happen once there is layout.
+ useEffect(sync, [sync]);
+
+ const page = (direction: 1 | -1) => {
+ const node = scroller.current;
+ if (!node) return;
+ // A page is what the reader can see, so the viewport is the step.
+ node.scrollBy({ left: direction * node.clientWidth, behavior: 'smooth' });
+ };
+
+ return (
+ <>
+
+ {header}
+ {scrollable && (
+
+
+
+
+ )}
+
+
+
+ {children}
+
+ >
+ );
+}
+
+function Chevron({ direction }: { direction: 'prev' | 'next' }) {
+ return (
+
+ );
+}
diff --git a/nextjs-app/components/school/SimilarSchoolsCompareBar.tsx b/nextjs-app/components/school/SimilarSchoolsCompareBar.tsx
new file mode 100644
index 0000000..8e0050b
--- /dev/null
+++ b/nextjs-app/components/school/SimilarSchoolsCompareBar.tsx
@@ -0,0 +1,59 @@
+'use client';
+
+/**
+ * The selection count and the one decisive action in the section.
+ *
+ * The CTA is a real link, not a handler: /compare already parses `urns` from
+ * the query string, so the hand-off needs no new compare plumbing. It counts
+ * this school plus whatever the reader ticked, because comparing a shortlist
+ * without the school they are looking at is not what they asked for.
+ */
+
+import Link from 'next/link';
+import { useComparisonContext } from '@/context/ComparisonContext';
+import type { SimilarSchool } from '@/lib/types';
+import styles from './SimilarSchools.module.css';
+
+export function SimilarSchoolsCompareBar({
+ thisUrn,
+ candidates,
+}: {
+ thisUrn: number;
+ candidates: SimilarSchool[];
+}) {
+ const { selectedSchools } = useComparisonContext();
+
+ // Only the schools this section offers, in the order the cards show them —
+ // the basket may hold schools picked up elsewhere on the site, and this bar
+ // speaks for this section.
+ const offered = candidates
+ .map((c) => c.urn)
+ .filter((urn) => selectedSchools.some((s) => s.urn === urn));
+
+ const count = offered.length;
+ const href = `/compare?urns=${[thisUrn, ...offered].join(',')}`;
+
+ return (
+
+
+
+ {count
+ ? `${count} school${count === 1 ? '' : 's'} selected`
+ : 'Compare side by side'}
+
+ {count
+ ? 'This school is included automatically.'
+ : 'Add a school to compare it with this one.'}
+
+ );
+}
diff --git a/nextjs-app/components/school/SimilarSchoolsSection.tsx b/nextjs-app/components/school/SimilarSchoolsSection.tsx
new file mode 100644
index 0000000..b9a8e9b
--- /dev/null
+++ b/nextjs-app/components/school/SimilarSchoolsSection.tsx
@@ -0,0 +1,129 @@
+/**
+ * SimilarSchoolsSection — nearby schools of the same phase and a comparable
+ * intake. Server component; only the carousel, the compare bar and the
+ * add-to-compare button are client-side.
+ *
+ * The section is allowed to say exactly what the backend matched and no more.
+ * The lede only claims a similar intake when no card came from tier 3, and a
+ * card's chips list what that school actually shares rather than a match it
+ * did not earn.
+ *
+ * There is deliberately no "how these are chosen" panel: the method is already
+ * visible in the lede, the chips and the distances. The single caption line is
+ * not a method note — it is the one thing a card cannot self-correct.
+ */
+
+import Link from 'next/link';
+import type { SimilarSchool } from '@/lib/types';
+import { schoolUrl } from '@/lib/utils';
+import { AddToCompareButton } from './AddToCompareButton';
+import { SimilarSchoolsCarousel } from './SimilarSchoolsCarousel';
+import { SimilarSchoolsCompareBar } from './SimilarSchoolsCompareBar';
+import { Section } from './sectionShared';
+import styles from './SimilarSchools.module.css';
+
+const MINIMUM = 2;
+
+export function shouldRenderSimilar(similar?: SimilarSchool[] | null): boolean {
+ return (similar?.length ?? 0) >= MINIMUM;
+}
+
+function metricLabel(key: string): string {
+ return key === 'attainment_8_score' ? 'Attainment 8' : 'Reading, writing & maths';
+}
+
+function formatMetric(value: number | null, key: string): string {
+ if (value == null) return 'Not published';
+ return key === 'attainment_8_score' ? value.toFixed(1) : `${Math.round(value)}%`;
+}
+
+export function SimilarSchoolsSection({
+ urn,
+ schoolName,
+ phaseNoun,
+ thisMetricValue,
+ similar,
+}: {
+ urn: number;
+ schoolName: string;
+ phaseNoun: string;
+ thisMetricValue: number | null;
+ similar?: SimilarSchool[] | null;
+}) {
+ if (!shouldRenderSimilar(similar)) return null;
+ const schools = similar as SimilarSchool[];
+
+ // One card matched on phase alone, so the section may not claim the set
+ // shares an intake with this school.
+ const loosest = Math.max(...schools.map((s) => s.tier));
+ const metricKey = schools[0].metric_key;
+
+ return (
+
+
+
+ Similar schools nearby
+
+
+ {loosest >= 3
+ ? `Other ${phaseNoun} schools near ${schoolName}.`
+ : `Other ${phaseNoun} schools near ${schoolName}, with a similar intake.`}
+
+ {formatMetric(thisMetricValue, metricKey)} at this school
+
+ )}
+
+
+
+ ))}
+
+
+
+
+ {/* The one caveat the cards cannot make on their own: a reader who takes
+ "0.6 miles away" for the walk has been misled, and nothing else here
+ corrects that. */}
+
+ Distances are straight-line from this school, not road distance.
+
+
+ );
+}
diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts
index 927d301..581c88a 100644
--- a/nextjs-app/lib/types.ts
+++ b/nextjs-app/lib/types.ts
@@ -346,6 +346,27 @@ export interface SchoolsResponse {
};
}
+/**
+ * A nearby school of the same phase and a comparable intake.
+ *
+ * `tier` is carried explicitly rather than inferred from `shared`, because it
+ * drives two separate decisions — whether the lede may claim a similar intake,
+ * and whether a chip renders as a fill or a muted outline — and inferring it
+ * from chip count would couple those decisions to the copy.
+ */
+export interface SimilarSchool {
+ urn: number;
+ school_name: string;
+ distance_miles: number;
+ school_type: string | null;
+ age_range: string | null;
+ shared: string[];
+ tier: number;
+ metric_value: number | null;
+ metric_key: string;
+ metric_year: number | null;
+}
+
export interface SchoolDetailsResponse {
school_info: School;
/**
@@ -357,6 +378,14 @@ export interface SchoolDetailsResponse {
* authority both fall below the publish threshold has nowhere to link.
*/
places?: SchoolPlace[];
+ /**
+ * Up to six nearby schools of a comparable intake, nearest first.
+ *
+ * Optional for the same reason as `places`: a frontend deployed ahead of the
+ * API that serves this must render without it. Absent and empty mean the
+ * same thing here — no section.
+ */
+ similar_schools?: SimilarSchool[];
yearly_data: SchoolResult[];
absence_data: AbsenceData | null;
// Supplementary data (null until Kestra populates)