From 91314a80b58763968f1995883202813d830997f1 Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 21 Sep 2026 22:41:40 +0100 Subject: [PATCH] feat(web): similar schools section, honest about what it matched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/SimilarSchoolsSection.test.tsx | 140 ++++++++++++++++++ .../components/school/AddToCompareButton.tsx | 45 ++++++ .../school/SimilarSchools.module.css | 65 ++++++++ .../school/SimilarSchoolsCarousel.tsx | 127 ++++++++++++++++ .../school/SimilarSchoolsCompareBar.tsx | 59 ++++++++ .../school/SimilarSchoolsSection.tsx | 129 ++++++++++++++++ nextjs-app/lib/types.ts | 29 ++++ 7 files changed, 594 insertions(+) create mode 100644 nextjs-app/__tests__/components/SimilarSchoolsSection.test.tsx create mode 100644 nextjs-app/components/school/AddToCompareButton.tsx create mode 100644 nextjs-app/components/school/SimilarSchools.module.css create mode 100644 nextjs-app/components/school/SimilarSchoolsCarousel.tsx create mode 100644 nextjs-app/components/school/SimilarSchoolsCompareBar.tsx create mode 100644 nextjs-app/components/school/SimilarSchoolsSection.tsx 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.'} +

+ {count ? ( + + Compare {count + 1} schools → + + ) : ( + + Compare → + + )} +
+ ); +} 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.`} +

+ + } + > + {schools.map((school) => ( +
  • +

    {school.distance_miles} miles away

    +

    + + {school.school_name} + +

    +

    + {[school.school_type, school.age_range && `Ages ${school.age_range}`] + .filter(Boolean) + .join(' · ')} +

    +
      + {school.shared.map((label) => ( +
    • = 3 ? styles.chipLoose : styles.chip}> + {label} +
    • + ))} +
    +
    +

    + {formatMetric(school.metric_value, school.metric_key)} +

    +

    {metricLabel(school.metric_key)}

    + {thisMetricValue != null && ( +

    + {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)