PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The map view now follows option B of the results-controls mockups: - The list sits in a pane on the left, under the result count and sort, and the map fills the rest of the screen below the pinned toolbar. The toolbar's height is measured, so the split ends 1rem above the bottom of the screen however the controls wrap. - Pins are brand-teal dots and the selected one is coral. The search location is an ink dot, the search radius a dashed circle with its distance, and the view fits that circle. Tiles are muted and the zoom sits under the fullscreen button. - A school picked on the map or in the list opens a card on the map (View, + Compare, following the basket), and its list card is ringed and scrolled into view. Phones keep the bottom sheet. - The list cards show the full name, Ofsted and school type, the headline figure and pupils. The map cards now follow the list rows: no England benchmark for special schools, PRUs and AP, and no placeholder all-zero RWM (Greenmead showed "0% RWM -62 pts vs national"). That rule moves to a shared listRwmValue helper. A postcode search opens on the map for desktop browsers, chosen on the server from the user agent so the list never paints first. The client falls back to the list below 1024px, and follows the default through client-side navigation from the hero until the reader picks a view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
6.7 KiB
TypeScript
178 lines
6.7 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, listRwmValue } 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';
|
|
|
|
// The school's OWN figure and its year-over-year trend are same-school
|
|
// measures — shown whenever there's a real value (not the all-zero
|
|
// placeholder, see listRwmValue), special schools included.
|
|
const showRwmValue = listRwmValue(school) != null;
|
|
// 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 & 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>
|
|
);
|
|
}
|