feat(web): similar schools section, honest about what it matched

Server-rendered cards inside a client carousel that scrolls rather than
paginates, so all six links stay in the initial HTML and the row still works
with JavaScript off. Three client islands, split by what each needs: a school,
the whole selection, a DOM ref.

The lede claims a similar intake only when no card came from tier 3, chips
list what a school actually shares, a missing figure reads "Not published",
and the neighbour's number carries no valence colour — green and terracotta
mean "against England" everywhere else, and colouring it here would read as
ranking the neighbours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5 committed 2026-09-21 22:41:40 +01:00
1 parent 52b00ac752
commit 91314a80b5
7 files changed
+594

No files matched your search

@@ -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 }) => (
<button type="button">Add {school.school_name} to compare</button>
),
}));
jest.mock('@/components/school/SimilarSchoolsCompareBar', () => ({
SimilarSchoolsCompareBar: ({ thisUrn }: { thisUrn: number }) => (
<div data-testid="compare-bar">bar for {thisUrn}</div>
),
}));
function school(overrides: Partial<SimilarSchool> = {}): 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(
<SimilarSchoolsSection
urn={100001}
schoolName="Meadowbrook Primary School"
phaseNoun="primary"
thisMetricValue={72}
similar={similar}
/>,
);
}
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();
});
});
@@ -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 (
<button
type="button"
className={styles.add}
onClick={toggle}
aria-pressed={selected}
>
{selected ? '✓ Added to compare' : '+ Add to compare'}
<span className={styles.srOnly}> — {school.school_name}</span>
</button>
);
}
@@ -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; }
@@ -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<HTMLUListElement>(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 (
<>
<div className={styles.top}>
{header}
{scrollable && (
<div className={styles.arrows}>
<button
type="button"
className={styles.arrow}
onClick={() => page(-1)}
disabled={atStart}
aria-label="Previous schools"
>
<Chevron direction="prev" />
</button>
<button
type="button"
className={styles.arrow}
onClick={() => page(1)}
disabled={atEnd}
aria-label="More schools"
>
<Chevron direction="next" />
</button>
</div>
)}
</div>
<ul
ref={scroller}
className={styles.scroller}
onScroll={sync}
data-at-end={atEnd}
{...(scrollable
? { tabIndex: 0, role: 'group', 'aria-labelledby': labelledBy }
: {})}
>
{children}
</ul>
</>
);
}
function Chevron({ direction }: { direction: 'prev' | 'next' }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={direction === 'prev' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6'} />
</svg>
);
}
@@ -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 (
<div className={styles.footer}>
<p aria-live="polite">
<strong>
{count
? `${count} school${count === 1 ? '' : 's'} selected`
: 'Compare side by side'}
</strong>
{count
? 'This school is included automatically.'
: 'Add a school to compare it with this one.'}
</p>
{count ? (
<Link className={styles.compare} href={href}>
Compare {count + 1} schools →
</Link>
) : (
<span className={styles.compare} aria-disabled="true">
Compare →
</span>
)}
</div>
);
}
@@ -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 (
<Section id="similar">
<SimilarSchoolsCarousel
count={schools.length}
labelledBy="similar-schools-heading"
header={
<div>
<h2 id="similar-schools-heading" className={styles.heading}>
Similar schools nearby
</h2>
<p className={styles.lede}>
{loosest >= 3
? `Other ${phaseNoun} schools near ${schoolName}.`
: `Other ${phaseNoun} schools near ${schoolName}, with a similar intake.`}
</p>
</div>
}
>
{schools.map((school) => (
<li key={school.urn} className={styles.school}>
<p className={styles.distance}>{school.distance_miles} miles away</p>
<h3 className={styles.name}>
<Link href={schoolUrl(school.urn, school.school_name)}>
{school.school_name}
</Link>
</h3>
<p className={styles.meta}>
{[school.school_type, school.age_range && `Ages ${school.age_range}`]
.filter(Boolean)
.join(' · ')}
</p>
<ul className={styles.shared}>
{school.shared.map((label) => (
<li key={label} className={school.tier >= 3 ? styles.chipLoose : styles.chip}>
{label}
</li>
))}
</ul>
<div className={styles.metric}>
<p
className={
school.metric_value == null ? styles.valueAbsent : styles.value
}
>
{formatMetric(school.metric_value, school.metric_key)}
</p>
<p className={styles.metricLabel}>{metricLabel(school.metric_key)}</p>
{thisMetricValue != null && (
<p className={styles.metricRef}>
{formatMetric(thisMetricValue, metricKey)} at this school
</p>
)}
</div>
<AddToCompareButton school={school} />
</li>
))}
</SimilarSchoolsCarousel>
<SimilarSchoolsCompareBar thisUrn={urn} candidates={schools} />
{/* 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. */}
<p className={styles.caption}>
Distances are straight-line from this school, not road distance.
</p>
</Section>
);
}
+29
View File
@@ -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)