PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m48s
Review feedback: the rankings/search rows treated any exactly-0 attainment as not comparable regardless of school type, so a genuine 0 at a mainstream school rendered as "—" (looks like missing data) and dropped its delta — stricter and inconsistent with SchoolDetailView's placeholder check. - SchoolRow: match SchoolDetailView's ks2Placeholder signature — suppress only when ALL of RWM + reading + writing + maths are 0 (special/suppressed signature), not on a bare rwm === 0. A genuine 0% combined (some pupils met individual subjects but not all three) is not all-zero, so it stays comparable and shows its real figure + trend. - SecondarySchoolRow: Attainment 8 is a single 0–80 score with no subject breakdown to form an all-zero signature, so key off establishment type only (drop the bare att8 === 0 guard). A genuine (if extreme) 0.0 shows its value + LA delta. - For consistency, apply the same to the detail views: drop the bare attainment_8_score === 0 guard (KS4 keys off isSpecial only); KS2 keeps the all-four-subjects-zero placeholder signature. Special schools / PRUs / AP are still handled via isSpecialSchool everywhere. tsc clean; 108/108 unit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
168 lines
5.9 KiB
TypeScript
168 lines
5.9 KiB
TypeScript
/**
|
||
* SecondarySchoolRow Component
|
||
* Four-line row for secondary school search results
|
||
*
|
||
* Line 1: School name · Ofsted badge
|
||
* Line 2: School type · Age range · Gender · Sixth form · Admissions tag
|
||
* Line 3: Attainment 8 (large) · ±LA avg delta · Pupils
|
||
* Line 4: LA name · distance
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import type { School } from '@/lib/types';
|
||
import { buildOfstedListBadge, getPhaseStyle, schoolUrl, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
|
||
import styles from './SecondarySchoolRow.module.css';
|
||
|
||
function detectAdmissionsTag(school: School): string | null {
|
||
const policy = school.admissions_policy?.toLowerCase() ?? '';
|
||
if (policy.includes('selective')) return 'Selective';
|
||
const denom = school.religious_denomination ?? '';
|
||
if (denom && denom !== 'Does not apply') return 'Faith priority';
|
||
return null;
|
||
}
|
||
|
||
function hasSixthForm(school: School): boolean {
|
||
// GIAS OfficialSixthForm flag; missing (pipeline not yet re-run) => false.
|
||
return school.has_sixth_form ?? false;
|
||
}
|
||
|
||
interface SecondarySchoolRowProps {
|
||
school: School;
|
||
isLocationSearch?: boolean;
|
||
isInCompare?: boolean;
|
||
onAddToCompare?: (school: School) => void;
|
||
onRemoveFromCompare?: (urn: number) => void;
|
||
laAvgAttainment8?: number | null;
|
||
}
|
||
|
||
export function SecondarySchoolRow({
|
||
school,
|
||
isLocationSearch,
|
||
isInCompare = false,
|
||
onAddToCompare,
|
||
onRemoveFromCompare,
|
||
laAvgAttainment8,
|
||
}: SecondarySchoolRowProps) {
|
||
const handleCompareClick = () => {
|
||
if (isInCompare) {
|
||
onRemoveFromCompare?.(school.urn);
|
||
} else {
|
||
onAddToCompare?.(school);
|
||
}
|
||
};
|
||
|
||
const ofstedBadge = buildOfstedListBadge(school);
|
||
const phase = getPhaseStyle(school.phase);
|
||
const att8 = school.attainment_8_score;
|
||
// Special schools / PRUs / AP: Attainment 8 vs the LA average isn't a fair
|
||
// comparison (their pupils have SEND), so drop the delta and the number
|
||
// rather than show them trailing "the average" by design. Attainment 8 is a
|
||
// single 0–80 score with no subject breakdown to test for a placeholder, so
|
||
// this keys off establishment type only — a genuine (if extreme) 0.0 at a
|
||
// mainstream school still shows its real value.
|
||
const att8Comparable = att8 != null && !isSpecialSchool(school);
|
||
const laDelta =
|
||
att8Comparable && laAvgAttainment8 != null ? (att8 as number) - laAvgAttainment8 : null;
|
||
|
||
const admissionsTag = detectAdmissionsTag(school);
|
||
const sixthForm = hasSixthForm(school);
|
||
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
|
||
|
||
return (
|
||
<div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}>
|
||
{/* Left: four content lines */}
|
||
<div className={styles.rowContent}>
|
||
|
||
{/* Line 1: School name + Ofsted badge */}
|
||
<div className={styles.line1}>
|
||
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
|
||
{school.school_name}
|
||
</a>
|
||
<span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
|
||
{ofstedBadge.label}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Line 2: Context tags */}
|
||
<div className={styles.line2}>
|
||
{phase.label && (
|
||
<span className={`${styles.phaseLabel} ${styles[`phaseLabel${phase.key}`]}`}>
|
||
{phase.label}
|
||
</span>
|
||
)}
|
||
{school.school_type && <span className={styles.attr}>{school.school_type}</span>}
|
||
{school.age_range && <span className={styles.attr}>{formatAgeRange(school.age_range)}</span>}
|
||
{showGender && (
|
||
<span className={styles.provisionTag}>{school.gender}</span>
|
||
)}
|
||
{sixthForm && (
|
||
<span className={styles.provisionTag}>Sixth form</span>
|
||
)}
|
||
{admissionsTag && (
|
||
<span className={`${styles.provisionTag} ${admissionsTag === 'Selective' ? styles.selectiveTag : ''}`}>
|
||
{admissionsTag}
|
||
</span>
|
||
)}
|
||
{isProposedToClose(school) && (
|
||
<span className={`${styles.provisionTag} ${styles.closingTag}`}>⚠ Proposed to close</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Line 3: KS4 stats */}
|
||
<div className={styles.line3}>
|
||
<span className={styles.stat}>
|
||
<strong className={styles.statValueLarge}>
|
||
{att8Comparable ? (att8 as number).toFixed(1) : '—'}
|
||
</strong>
|
||
<span className={styles.statLabel}>Attainment 8</span>
|
||
</span>
|
||
|
||
{laDelta != null && (
|
||
<span className={`${styles.delta} ${laDelta >= 0 ? styles.deltaPositive : styles.deltaNegative}`}>
|
||
{laDelta >= 0 ? '+' : ''}{laDelta.toFixed(1)} vs LA avg
|
||
</span>
|
||
)}
|
||
|
||
{school.total_pupils != null && (
|
||
<span className={styles.stat}>
|
||
<strong className={styles.statValue}>
|
||
{school.total_pupils.toLocaleString()}
|
||
</strong>
|
||
<span className={styles.statLabel}>pupils</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Line 4: Location + distance */}
|
||
<div className={styles.line4}>
|
||
{school.local_authority && (
|
||
<span>{school.local_authority}</span>
|
||
)}
|
||
{isLocationSearch && school.distance != null && (
|
||
<span className={styles.distanceBadge}>
|
||
{school.distance.toFixed(1)} mi
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
|
||
{/* Right: actions */}
|
||
<div className={styles.rowActions}>
|
||
<a href={schoolUrl(school.urn, school.school_name)} className="btn btn-tertiary btn-sm">
|
||
View
|
||
</a>
|
||
{(onAddToCompare || onRemoveFromCompare) && (
|
||
<button
|
||
onClick={handleCompareClick}
|
||
className={isInCompare ? 'btn btn-active btn-sm' : 'btn btn-secondary btn-sm'}
|
||
>
|
||
{isInCompare ? '✓ Comparing' : '+ Compare'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|