Files
school_compare/nextjs-app/components/SecondarySchoolRow.tsx
T
TudorandClaude Opus 4.8 de943ded19
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
fix(review): don't suppress a genuine mainstream 0 in the rows
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
2026-07-20 21:58:13 +01:00

168 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 080 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>
);
}