Files
school_compare/nextjs-app/components/school/SimilarSchoolsSection.tsx
T
TudorandClaude Opus 5 2175dccb7c
PR Checks / Frontend Typecheck + Tests (pull_request) Canceled after 9s
PR Checks / Backend Smoke (pull_request) Canceled after 0s
PR Checks / Build Backend (no push) (pull_request) Canceled after 0s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 0s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
fix(api): treat "16 plus" as secondary, the way PHASE_GROUPS already does
GIAS phase 6 is "16 plus", and PHASE_GROUPS deliberately files it in the
secondary group. The payload helper decided the same question with
`"secondary" in phase_text`, which that value does not satisfy — so a
sixth-form college was handed the primary bucket and offered infant schools
as its peers, with the KS2 metric key to label them. No crash; just a page
confidently showing the wrong schools.

The decision now lives in similar_schools.is_secondary_phase, beside the
PHASE_GROUPS bucket it selects from, so the two cannot drift again. A test
pins them together.

The same binary assumption had a second output. computeSchoolFlags tests for
the substring too, so a 16-plus school renders the primary template, and the
composer was labelling the section from the template: "Other primary schools
near <sixth form college>" above a row of secondaries. The section now
derives its noun from the school's own phase, which also removes the
duplicated wording from both composers. A 16-plus school's candidates span
the whole secondary group, so no single noun fits and it gets the honest
general one.

Reported in review on #150.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 06:41:49 +01:00

154 lines
5.7 KiB
TypeScript

/**
* 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;
}
/**
* What the lede calls the set of schools it is showing.
*
* Derived from the school's own GIAS phase rather than the template it renders
* with, because those disagree for "16 plus" (GIAS phase 6): a sixth-form
* college renders the primary template — computeSchoolFlags tests for the
* substring "secondary" — while the backend correctly matches it against the
* secondary group. Taking the noun from the template would print "Other primary
* schools near <sixth form college>" above a row of secondaries.
*
* A 16-plus school's candidates span the whole secondary group, so no single
* noun fits and it gets the honest general one.
*/
export function nearbyNoun(phase: string | null | undefined): string {
const text = (phase ?? '').trim().toLowerCase();
if (text === 'all-through') return 'all-through schools';
if (text === '16 plus') return 'schools and colleges';
if (text.includes('secondary')) return 'secondary schools';
if (text.includes('primary')) return 'primary schools';
return 'schools';
}
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,
phase,
thisMetricValue,
similar,
}: {
urn: number;
schoolName: string;
/** The school's own GIAS phase, not the template it renders with. */
phase: string | null | undefined;
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;
const noun = nearbyNoun(phase);
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 ${noun} near ${schoolName}.`
: `Other ${noun} 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>
);
}