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>
130 lines
4.6 KiB
TypeScript
130 lines
4.6 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;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|