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)