PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 12s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m38s
Three review points on the special-schools change: 1. Copy accuracy — the context note said "Its pupils have special educational needs" for every isSpecialSchool() match, but the helper also matches pupil referral units and alternative provision, whose pupils are educated outside a mainstream setting (e.g. after exclusion) and are not necessarily SEND. Extracted a shared <SpecialSchoolNote> with type-aware copy: SEND wording only for genuine special schools; PRUs/AP get their own accurate wording. 2. Same-school trend was conflated with the England comparison — SchoolRow's year-over-year trend arrow (and the school's own figure) were gated on the same flag that drops the vs-England delta, hiding a still-meaningful trend for special schools with real data. Split the two: the school's OWN RWM figure + trend show whenever there's a real value (special schools included; only a placeholder all-zero row is hidden); only the vs-England delta is additionally dropped for special/PRU/AP. Mirrored in SecondarySchoolRow (own Attainment 8 shown; only the vs-LA delta dropped). 3. De-duplicated the .specialNote CSS (was copy-pasted between the two detail view module files) into SpecialSchoolNote.module.css, owned by the shared component so it can't drift. New SpecialSchoolNote unit tests assert SEND wording for special schools and NOT for PRUs/AP. tsc clean; 112/112 unit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
167 lines
5.8 KiB
TypeScript
167 lines
5.8 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;
|
|
// The school's own Attainment 8 is a same-school figure — shown whenever it
|
|
// exists (special schools included; their type tag on line 2 gives context).
|
|
// Only the vs-LA-average delta, a benchmark comparison, is dropped for
|
|
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
|
|
const laDelta =
|
|
att8 != null && !isSpecialSchool(school) && laAvgAttainment8 != null
|
|
? att8 - 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}>
|
|
{att8 != null ? att8.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>
|
|
);
|
|
}
|