Files
school_compare/nextjs-app/components/SchoolRow.tsx
TudorandClaude Opus 4.8 0b36eace9d
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
fix(review): accurate PRU/AP copy, unbundle same-school trend, de-dupe note CSS
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
2026-07-20 22:09:32 +01:00

186 lines
7.1 KiB
TypeScript

/**
* SchoolRow Component
* Four-line row for primary school search results
*
* Line 1: School name · Ofsted badge (framework-aware)
* Line 2: School type · Age range · Denomination · Gender
* Line 3: Reading, Writing & Maths % · trend arrow · vs-national delta · Pupils
* Line 4: Local authority · Distance
*/
import type { School } from '@/lib/types';
import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
import styles from './SchoolRow.module.css';
interface SchoolRowProps {
school: School;
isLocationSearch?: boolean;
isInCompare?: boolean;
onAddToCompare?: (school: School) => void;
onRemoveFromCompare?: (urn: number) => void;
nationalAvgRwm?: number | null;
}
export function SchoolRow({
school,
isLocationSearch,
isInCompare = false,
onAddToCompare,
onRemoveFromCompare,
nationalAvgRwm,
}: SchoolRowProps) {
const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct);
const phase = getPhaseStyle(school.phase);
const ofstedBadge = buildOfstedListBadge(school);
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
const showDenomination =
school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
// A placeholder all-zero row (every subject 0 — a special/suppressed
// signature, matching SchoolDetailView's ks2Placeholder) isn't a real score,
// so its figure is hidden. A genuine 0% combined (some pupils met individual
// subjects but not all three) is NOT all-zero and stays shown.
const rwmPlaceholder =
school.rwm_expected_pct === 0 &&
(school.reading_expected_pct ?? 0) === 0 &&
(school.writing_expected_pct ?? 0) === 0 &&
(school.maths_expected_pct ?? 0) === 0;
// The school's OWN figure and its year-over-year trend are same-school
// measures — shown whenever there's a real value, special schools included.
const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
// The vs-England delta is a mainstream benchmark: additionally dropped for
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
const rwmDelta =
showRwmValue && !isSpecialSchool(school) && nationalAvgRwm != null
? Math.round((school.rwm_expected_pct as number) - nationalAvgRwm)
: null;
const handleCompareClick = () => {
if (isInCompare) {
onRemoveFromCompare?.(school.urn);
} else {
onAddToCompare?.(school);
}
};
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>}
{showDenomination && <span className={styles.attr}>{school.religious_denomination}</span>}
{showGender && <span className={styles.attr}>{school.gender}</span>}
{isProposedToClose(school) && (
<span className={`${styles.attr} ${styles.attrClosing}`}> Proposed to close</span>
)}
</div>
{/* Line 3: Key stats */}
<div className={styles.line3}>
<span className={styles.stat}>
<strong className={styles.statValue}>
{showRwmValue ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
</strong>
{showRwmValue && school.prev_rwm_expected_pct != null && (
<span
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
>
{trend === 'up' && (
<svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend up">
<path d="M8 3L14 10H2L8 3Z" fill="currentColor" />
</svg>
)}
{trend === 'down' && (
<svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend down">
<path d="M8 13L2 6H14L8 13Z" fill="currentColor" />
</svg>
)}
{trend === 'stable' && (
<svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend stable">
<rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor" />
</svg>
)}
</span>
)}
<span className={styles.statLabel}>Reading, Writing &amp; Maths</span>
{rwmDelta != null && (
<span
className={
rwmDelta >= 2
? styles.vsNational
: rwmDelta <= -2
? styles.vsNationalNeg
: styles.vsNationalFlat
}
>
{rwmDelta >= 2
? `+${rwmDelta} pts vs national`
: rwmDelta <= -2
? `${rwmDelta} pts vs national`
: '≈ national avg'}
</span>
)}
</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, vertically centred */}
<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>
);
}