Reported from staging: a Catholic primary showed six Catholic primaries, none of them close enough to be a real option, and omitted the community school down the road. Three causes, compounding. Ranking put tier before distance, so a faith match at 2.9 miles outranked a community school at 0.3. The ENOUGH=3 stopping rule — added so a cap of six would not drag in weak distant matches — filled the row from the best tier before it ever widened, which is what made every card Catholic. And a 3-mile tier-1 radius is sane for a secondary and most of a city for a primary, whose catchments are routinely under a mile. The premise was backwards. For a parent, distance is a constraint and intake is a preference; a school beyond a primary catchment is not a weaker option, it is not an option. So distance now decides the order and nothing else does. The hard filters are untouched — they were always where the defensibility lived. Similarity survives as chips on the card: reported, so a reader applies their own weighting, rather than ranked, so we apply ours for them. Reach is capped per phase (primary 2, secondary 6, post-16 10) as a sanity bound, not a target: ordering already handles density, so the cap only decides what happens where an area is sparse. A primary with nothing inside two miles now renders no section, which is the honest answer. Deleted: the tier system, the stopping rule, the tier-dependent lede, the `tier` field, the tier-3 fallback chip and its style. select_similar also stops taking is_secondary — it reads the phase from the subject's own row, so no caller can hand it one that disagrees with the data. The heading is now "Other schools nearby". The hard filters still guarantee a comparable set, but nothing ranks on likeness, so the heading no longer says it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
5.7 KiB
TypeScript
153 lines
5.7 KiB
TypeScript
/**
|
|
* NearbySchoolsSection — the nearest eligible schools, closest first. Server
|
|
* component; only the carousel, the compare bar and the add-to-compare button
|
|
* are client-side.
|
|
*
|
|
* "Other schools nearby", not "similar" ones: the order is distance and only
|
|
* distance. The hard filters upstream still guarantee the set is comparable —
|
|
* same phase, same selectivity, mainstream never beside special — but nothing
|
|
* here ranks by how alike two schools are, so the heading does not say it does.
|
|
*
|
|
* The chips report what a school shares, and may be absent entirely. That is
|
|
* information for the reader to weigh, not a verdict this section has already
|
|
* reached on their behalf.
|
|
*
|
|
* 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 metricKey = schools[0].metric_key;
|
|
const noun = nearbyNoun(phase);
|
|
|
|
return (
|
|
<Section id="nearby">
|
|
<SimilarSchoolsCarousel
|
|
count={schools.length}
|
|
labelledBy="nearby-schools-heading"
|
|
header={
|
|
<div>
|
|
<h2 id="nearby-schools-heading" className={styles.heading}>
|
|
Other schools nearby
|
|
</h2>
|
|
<p className={styles.lede}>{`Other ${noun} near ${schoolName}.`}</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>
|
|
{school.shared.length > 0 && (
|
|
<ul className={styles.shared}>
|
|
{school.shared.map((label) => (
|
|
<li key={label} className={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>
|
|
);
|
|
}
|