feat(school-detail): editorial hero with signal chips, at-a-glance stats, summary
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 15s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 15s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Elevates the primary school detail hero from a flat report header into a scannable editorial block. Parents can read the headline signal in seconds. - A1: bump .schoolName to clamp(2rem, 5vw, 3.25rem) Playfair. - A2: framework-aware signal chip strip via new buildOfstedHeroChip() helper. Branches on ofsted.framework so Report Card schools never show a fake overall grade — they get "Ofsted Report Card" + inspection date + Safeguarding: Met/Not met. OEIF schools keep the grade word. - A3: oversized Playfair stats — Reading, Writing & Maths % (primary) or Attainment 8 (secondary) with inline DeltaChip vs national, Ofsted verdict with tone colouring, and first-choice offer rate. - B1: italic serif one-sentence summary via buildSchoolSummary() helper, also framework-aware so Report Card schools are described by framework, not a synthetic grade. - C1: new DeltaChip component reused in the two headline KS2 metric cards (rwm_expected_pct, rwm_high_pct). All copy uses "Reading, Writing & Maths" in full. Secondary detail view untouched in this slice. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sm {
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suffix {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.good {
|
||||||
|
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12));
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bad {
|
||||||
|
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neutral {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* DeltaChip — small coloured chip showing how a value compares to a baseline.
|
||||||
|
*
|
||||||
|
* Example: <DeltaChip value={70} baseline={60} unit="pts" /> → "+10 pts"
|
||||||
|
*
|
||||||
|
* Colours (reuse globals.css status tokens):
|
||||||
|
* above baseline → statusGood (teal)
|
||||||
|
* below baseline → statusBad (coral)
|
||||||
|
* within ±tolerance → statusWarn (gold, "in line")
|
||||||
|
*/
|
||||||
|
|
||||||
|
import styles from './DeltaChip.module.css';
|
||||||
|
|
||||||
|
interface DeltaChipProps {
|
||||||
|
value: number | null | undefined;
|
||||||
|
baseline: number | null | undefined;
|
||||||
|
/** Unit suffix for the delta (e.g. "pts", "%") */
|
||||||
|
unit?: string;
|
||||||
|
/** Absolute delta below which the chip is treated as neutral */
|
||||||
|
tolerance?: number;
|
||||||
|
/** Override label when we want "vs national" under a number */
|
||||||
|
suffix?: string;
|
||||||
|
/** Smaller variant for inline use next to metric values */
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeltaChip({
|
||||||
|
value,
|
||||||
|
baseline,
|
||||||
|
unit = 'pts',
|
||||||
|
tolerance = 1,
|
||||||
|
suffix,
|
||||||
|
size = 'md',
|
||||||
|
}: DeltaChipProps) {
|
||||||
|
if (value == null || baseline == null) return null;
|
||||||
|
|
||||||
|
const delta = value - baseline;
|
||||||
|
const rounded = Math.round(delta);
|
||||||
|
|
||||||
|
let tone: 'good' | 'bad' | 'neutral';
|
||||||
|
if (Math.abs(delta) < tolerance) tone = 'neutral';
|
||||||
|
else if (delta > 0) tone = 'good';
|
||||||
|
else tone = 'bad';
|
||||||
|
|
||||||
|
const toneClass =
|
||||||
|
tone === 'good' ? styles.good : tone === 'bad' ? styles.bad : styles.neutral;
|
||||||
|
|
||||||
|
const sign = rounded > 0 ? '+' : '';
|
||||||
|
const label = `${sign}${rounded} ${unit}`.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`${styles.chip} ${toneClass} ${size === 'sm' ? styles.sm : ''}`}>
|
||||||
|
{label}
|
||||||
|
{suffix && <span className={styles.suffix}>{suffix}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,11 +26,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.schoolName {
|
.schoolName {
|
||||||
font-size: 1.5rem;
|
font-size: clamp(2rem, 5vw, 3.25rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
line-height: 1.2;
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,3 +716,171 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Hero signal chip strip (A2) ─────────────────────────────────────── */
|
||||||
|
.heroChips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChip {
|
||||||
|
min-width: 180px;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--border-color, #e5dfd5);
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChipTitle {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChipSub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChipDetail {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tone variants — keep consistent with .ofstedGrade{N} / .rcGrade{N} */
|
||||||
|
.heroChip.ofstedGrade1,
|
||||||
|
.heroChip.rcGrade1,
|
||||||
|
.heroChip.rcGrade2,
|
||||||
|
.heroChipGood {
|
||||||
|
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.08));
|
||||||
|
border-left-color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChip.ofstedGrade2 {
|
||||||
|
background: rgba(60, 140, 60, 0.08);
|
||||||
|
border-left-color: #3c8c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChip.ofstedGrade3,
|
||||||
|
.heroChip.rcGrade3,
|
||||||
|
.heroChipWarn {
|
||||||
|
background: var(--accent-gold-bg, rgba(201, 162, 39, 0.10));
|
||||||
|
border-left-color: var(--accent-gold, #c9a227);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChip.ofstedGrade4,
|
||||||
|
.heroChip.rcGrade4,
|
||||||
|
.heroChip.rcGrade5 {
|
||||||
|
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.10));
|
||||||
|
border-left-color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChip.heroChipNeutral {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-left-color: var(--border-color, #e5dfd5);
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero at-a-glance stats (A3) ─────────────────────────────────────── */
|
||||||
|
.heroStats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1.5rem 2rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatNumber {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatNumberSerif {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatNumberSerif.ofstedGrade1 { color: var(--accent-teal, #2d7d7d); }
|
||||||
|
.heroStatNumberSerif.ofstedGrade2 { color: #3c8c3c; }
|
||||||
|
.heroStatNumberSerif.ofstedGrade3 { color: #b8920e; }
|
||||||
|
.heroStatNumberSerif.ofstedGrade4 { color: var(--accent-coral, #e07256); }
|
||||||
|
.heroStatNumberSerif.rcGrade1 { color: var(--accent-teal, #2d7d7d); }
|
||||||
|
.heroStatNumberSerif.rcGrade2 { color: #3c8c3c; }
|
||||||
|
.heroStatNumberSerif.rcGrade3 { color: #b8920e; }
|
||||||
|
.heroStatNumberSerif.rcGrade4 { color: #c2410c; }
|
||||||
|
.heroStatNumberSerif.rcGrade5 { color: var(--accent-coral, #e07256); }
|
||||||
|
|
||||||
|
.heroStatLabel {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatFoot {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero summary sentence (B1) ──────────────────────────────────────── */
|
||||||
|
.heroSummary {
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: clamp(1.0625rem, 1.6vw, 1.25rem);
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
max-width: 64ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroDataNote {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.heroChips {
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroChip {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStats {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroSummary {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ import type {
|
|||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions, SenDetail, Phonics,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear } from '@/lib/utils';
|
import {
|
||||||
|
formatPercentage, formatProgress, formatAcademicYear,
|
||||||
|
buildOfstedHeroChip, buildSchoolSummary,
|
||||||
|
} from '@/lib/utils';
|
||||||
|
import { DeltaChip } from './DeltaChip';
|
||||||
import styles from './SchoolDetailView.module.css';
|
import styles from './SchoolDetailView.module.css';
|
||||||
|
|
||||||
const OFSTED_LABELS: Record<number, string> = {
|
const OFSTED_LABELS: Record<number, string> = {
|
||||||
@@ -134,6 +138,20 @@ export function SchoolDetailView({
|
|||||||
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
|
||||||
|
|
||||||
|
// ── Hero: framework-aware signal chip + narrative summary ─────────────
|
||||||
|
const ofstedHeroChip = buildOfstedHeroChip(ofsted);
|
||||||
|
const heroSummary = buildSchoolSummary(schoolInfo, ofsted, admissions, latestResults);
|
||||||
|
|
||||||
|
// KS2 headline numbers for the at-a-glance row
|
||||||
|
const heroRwm = isPrimary ? latestResults?.rwm_expected_pct ?? null : null;
|
||||||
|
const heroRwmNat = primaryAvg.rwm_expected_pct ?? null;
|
||||||
|
|
||||||
|
// KS4 headline number for secondary/all-through schools
|
||||||
|
const heroAtt8 = isSecondary ? latestResults?.attainment_8_score ?? null : null;
|
||||||
|
const heroAtt8Nat = secondaryAvg.attainment_8_score ?? null;
|
||||||
|
|
||||||
|
const heroAcademicYear = latestResults ? formatAcademicYear(latestResults.year) : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -192,6 +210,110 @@ export function SchoolDetailView({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hero signal chip strip */}
|
||||||
|
<div className={styles.heroChips}>
|
||||||
|
<div className={`${styles.heroChip} ${styles[ofstedHeroChip.toneClass] ?? ''}`}>
|
||||||
|
<div className={styles.heroChipTitle}>{ofstedHeroChip.title}</div>
|
||||||
|
<div className={styles.heroChipSub}>{ofstedHeroChip.subtitle}</div>
|
||||||
|
{ofstedHeroChip.detail && (
|
||||||
|
<div className={styles.heroChipDetail}>{ofstedHeroChip.detail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{admissions?.oversubscribed && (
|
||||||
|
<div className={`${styles.heroChip} ${styles.heroChipWarn}`}>
|
||||||
|
<div className={styles.heroChipTitle}>Oversubscribed</div>
|
||||||
|
<div className={styles.heroChipSub}>
|
||||||
|
{admissions.first_preference_offer_pct != null
|
||||||
|
? `${Math.round(admissions.first_preference_offer_pct)}% got first choice`
|
||||||
|
: 'More applicants than places'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPrimary && heroRwm != null && heroRwmNat != null && heroRwm > heroRwmNat && (
|
||||||
|
<div className={`${styles.heroChip} ${styles.heroChipGood}`}>
|
||||||
|
<div className={styles.heroChipTitle}>Above national average</div>
|
||||||
|
<div className={styles.heroChipSub}>
|
||||||
|
Reading, Writing & Maths · {Math.round(heroRwm)} vs {Math.round(heroRwmNat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSecondary && heroAtt8 != null && heroAtt8Nat != null && heroAtt8 > heroAtt8Nat && (
|
||||||
|
<div className={`${styles.heroChip} ${styles.heroChipGood}`}>
|
||||||
|
<div className={styles.heroChipTitle}>Above national average</div>
|
||||||
|
<div className={styles.heroChipSub}>
|
||||||
|
Attainment 8 · {heroAtt8.toFixed(1)} vs {heroAtt8Nat.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* At-a-glance stats row */}
|
||||||
|
{latestResults && (
|
||||||
|
<div className={styles.heroStats}>
|
||||||
|
{isPrimary && heroRwm != null && (
|
||||||
|
<div className={styles.heroStat}>
|
||||||
|
<div className={styles.heroStatNumber}>{Math.round(heroRwm)}%</div>
|
||||||
|
<div className={styles.heroStatLabel}>Reading, Writing & Maths</div>
|
||||||
|
{heroRwmNat != null && (
|
||||||
|
<DeltaChip value={heroRwm} baseline={heroRwmNat} unit="pts" suffix="vs national" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSecondary && heroAtt8 != null && (
|
||||||
|
<div className={styles.heroStat}>
|
||||||
|
<div className={styles.heroStatNumber}>{heroAtt8.toFixed(1)}</div>
|
||||||
|
<div className={styles.heroStatLabel}>Attainment 8 score</div>
|
||||||
|
{heroAtt8Nat != null && (
|
||||||
|
<DeltaChip value={heroAtt8} baseline={heroAtt8Nat} unit="pts" suffix="vs national" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ofsted && (
|
||||||
|
<div className={styles.heroStat}>
|
||||||
|
<div className={`${styles.heroStatNumberSerif} ${styles[ofstedHeroChip.toneClass] ?? ''}`}>
|
||||||
|
{ofstedHeroChip.state === 'oeif'
|
||||||
|
? ofstedHeroChip.title.replace(/^Ofsted\s+/, '')
|
||||||
|
: ofstedHeroChip.state === 'reportCard'
|
||||||
|
? 'Report Card'
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroStatLabel}>
|
||||||
|
{ofstedHeroChip.subtitle}
|
||||||
|
</div>
|
||||||
|
{ofstedHeroChip.detail && (
|
||||||
|
<div className={styles.heroStatFoot}>{ofstedHeroChip.detail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{admissions?.first_preference_offer_pct != null && (
|
||||||
|
<div className={styles.heroStat}>
|
||||||
|
<div className={styles.heroStatNumber}>
|
||||||
|
{Math.round(admissions.first_preference_offer_pct)}%
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroStatLabel}>First-choice offer rate</div>
|
||||||
|
{admissions.oversubscribed && (
|
||||||
|
<div className={styles.heroStatFoot}>Oversubscribed</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{heroSummary && (
|
||||||
|
<p className={styles.heroSummary}>{heroSummary}</p>
|
||||||
|
)}
|
||||||
|
{heroAcademicYear && (
|
||||||
|
<p className={styles.heroDataNote}>
|
||||||
|
Latest data: {heroAcademicYear}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Sticky Section Navigation */}
|
{/* Sticky Section Navigation */}
|
||||||
@@ -361,7 +483,17 @@ export function SchoolDetailView({
|
|||||||
Reading, Writing & Maths combined
|
Reading, Writing & Maths combined
|
||||||
<MetricTooltip metricKey="rwm_expected_pct" />
|
<MetricTooltip metricKey="rwm_expected_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.rwm_expected_pct)}</div>
|
<div className={styles.metricValue}>
|
||||||
|
{formatPercentage(latestResults.rwm_expected_pct)}
|
||||||
|
{primaryAvg.rwm_expected_pct != null && (
|
||||||
|
<DeltaChip
|
||||||
|
value={latestResults.rwm_expected_pct}
|
||||||
|
baseline={primaryAvg.rwm_expected_pct}
|
||||||
|
unit="pts"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{primaryAvg.rwm_expected_pct != null && (
|
{primaryAvg.rwm_expected_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
@@ -373,7 +505,17 @@ export function SchoolDetailView({
|
|||||||
Exceeding expected level (Reading, Writing & Maths)
|
Exceeding expected level (Reading, Writing & Maths)
|
||||||
<MetricTooltip metricKey="rwm_high_pct" />
|
<MetricTooltip metricKey="rwm_high_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.rwm_high_pct)}</div>
|
<div className={styles.metricValue}>
|
||||||
|
{formatPercentage(latestResults.rwm_high_pct)}
|
||||||
|
{primaryAvg.rwm_high_pct != null && (
|
||||||
|
<DeltaChip
|
||||||
|
value={latestResults.rwm_high_pct}
|
||||||
|
baseline={primaryAvg.rwm_high_pct}
|
||||||
|
unit="pts"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{primaryAvg.rwm_high_pct != null && (
|
{primaryAvg.rwm_high_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+152
-1
@@ -2,7 +2,7 @@
|
|||||||
* Utility functions for SchoolCompare
|
* Utility functions for SchoolCompare
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { School, MetricDefinition } from './types';
|
import type { School, MetricDefinition, OfstedInspection, SchoolAdmissions, SchoolResult } from './types';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// String Utilities
|
// String Utilities
|
||||||
@@ -404,3 +404,154 @@ export function getCurrentAcademicYear(): number {
|
|||||||
// Academic year starts in September (month 8)
|
// Academic year starts in September (month 8)
|
||||||
return month >= 8 ? year : year - 1;
|
return month >= 8 ? year : year - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// School Detail Hero Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const OFSTED_OEIF_WORDS: Record<number, string> = {
|
||||||
|
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an Ofsted inspection date as "Month YYYY" (e.g. "November 2023").
|
||||||
|
*/
|
||||||
|
function formatOfstedMonth(date: string | null | undefined): string {
|
||||||
|
if (!date) return '';
|
||||||
|
const d = new Date(date);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfstedHeroChip {
|
||||||
|
state: 'oeif' | 'reportCard' | 'none';
|
||||||
|
title: string; // Main label (e.g. "Ofsted Outstanding", "Ofsted Report Card")
|
||||||
|
subtitle: string; // Context line (e.g. "Inspected November 2023")
|
||||||
|
detail?: string; // Optional extra line (e.g. "Safeguarding: Met")
|
||||||
|
toneClass: string; // CSS class key — matches .ofstedGrade{N} / .rcGrade{N} / neutral
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the hero-strip Ofsted chip, branching on the inspection framework.
|
||||||
|
* Never synthesises a single overall grade for ReportCard schools.
|
||||||
|
*/
|
||||||
|
export function buildOfstedHeroChip(ofsted: OfstedInspection | null | undefined): OfstedHeroChip {
|
||||||
|
if (!ofsted || !ofsted.framework) {
|
||||||
|
return {
|
||||||
|
state: 'none',
|
||||||
|
title: 'Ofsted pending',
|
||||||
|
subtitle: 'No inspection on record',
|
||||||
|
toneClass: 'heroChipNeutral',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const when = formatOfstedMonth(ofsted.inspection_date);
|
||||||
|
|
||||||
|
if (ofsted.framework === 'OEIF') {
|
||||||
|
const grade = ofsted.overall_effectiveness;
|
||||||
|
if (grade && OFSTED_OEIF_WORDS[grade]) {
|
||||||
|
return {
|
||||||
|
state: 'oeif',
|
||||||
|
title: `Ofsted ${OFSTED_OEIF_WORDS[grade]}`,
|
||||||
|
subtitle: when ? `Inspected ${when}` : 'Inspected',
|
||||||
|
toneClass: `ofstedGrade${grade}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: 'oeif',
|
||||||
|
title: 'Ofsted inspected',
|
||||||
|
subtitle: when ? `Inspected ${when}` : 'Inspection on record',
|
||||||
|
toneClass: 'heroChipNeutral',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportCard — never fabricate an overall grade
|
||||||
|
const safeguarding = ofsted.rc_safeguarding_met;
|
||||||
|
return {
|
||||||
|
state: 'reportCard',
|
||||||
|
title: 'Ofsted Report Card',
|
||||||
|
subtitle: when ? `Inspected ${when}` : 'New framework inspection',
|
||||||
|
detail:
|
||||||
|
safeguarding == null
|
||||||
|
? undefined
|
||||||
|
: safeguarding ? 'Safeguarding: Met' : 'Safeguarding: Not met',
|
||||||
|
toneClass: safeguarding === false ? 'rcGrade5' : 'rcGrade2',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a one-sentence editorial summary for the school detail hero.
|
||||||
|
* Branches on Ofsted framework so Report Card schools are never described
|
||||||
|
* with an overall grade they do not have.
|
||||||
|
*/
|
||||||
|
export function buildSchoolSummary(
|
||||||
|
schoolInfo: School,
|
||||||
|
ofsted: OfstedInspection | null | undefined,
|
||||||
|
admissions: SchoolAdmissions | null | undefined,
|
||||||
|
latestResults: SchoolResult | null | undefined,
|
||||||
|
): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// Size descriptor
|
||||||
|
const pupils = latestResults?.total_pupils ?? schoolInfo.total_pupils ?? null;
|
||||||
|
const sizeWord =
|
||||||
|
pupils == null ? '' :
|
||||||
|
pupils < 200 ? 'Small' :
|
||||||
|
pupils < 500 ? 'Mid-sized' :
|
||||||
|
'Large';
|
||||||
|
|
||||||
|
// Phase descriptor — avoid the raw code
|
||||||
|
const phase = (schoolInfo.phase ?? '').toLowerCase();
|
||||||
|
const phaseWord =
|
||||||
|
phase.includes('secondary') ? 'secondary' :
|
||||||
|
phase === 'all-through' ? 'all-through' :
|
||||||
|
phase.includes('primary') ? 'primary' :
|
||||||
|
'school';
|
||||||
|
|
||||||
|
// Religious character
|
||||||
|
const religion = schoolInfo.religious_denomination;
|
||||||
|
const religionWord =
|
||||||
|
!religion || /none|does not apply/i.test(religion) ? '' :
|
||||||
|
/roman catholic|catholic/i.test(religion) ? 'Catholic ' :
|
||||||
|
/church of england|ce|anglican/i.test(religion) ? 'Church of England ' :
|
||||||
|
/jewish/i.test(religion) ? 'Jewish ' :
|
||||||
|
/muslim|islam/i.test(religion) ? 'Muslim ' :
|
||||||
|
/hindu/i.test(religion) ? 'Hindu ' :
|
||||||
|
/sikh/i.test(religion) ? 'Sikh ' :
|
||||||
|
'';
|
||||||
|
|
||||||
|
// Locality — prefer town from address parsing (fallback to LA)
|
||||||
|
const locality = schoolInfo.town || schoolInfo.local_authority || '';
|
||||||
|
|
||||||
|
const lead = [sizeWord, religionWord + phaseWord].filter(Boolean).join(' ');
|
||||||
|
let opening = lead || 'School';
|
||||||
|
if (locality) opening += ` in ${locality}`;
|
||||||
|
parts.push(opening);
|
||||||
|
|
||||||
|
// Ofsted clause (framework-aware)
|
||||||
|
if (ofsted?.framework === 'OEIF' && ofsted.overall_effectiveness) {
|
||||||
|
parts.push(`rated ${OFSTED_OEIF_WORDS[ofsted.overall_effectiveness]} by Ofsted`);
|
||||||
|
} else if (ofsted?.framework === 'ReportCard') {
|
||||||
|
const when = formatOfstedMonth(ofsted.inspection_date);
|
||||||
|
parts.push(
|
||||||
|
when
|
||||||
|
? `most recently inspected under Ofsted's Report Card framework in ${when}`
|
||||||
|
: "recently inspected under Ofsted's new Report Card framework",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admissions clause
|
||||||
|
if (admissions?.oversubscribed) {
|
||||||
|
if (admissions.first_preference_offer_pct != null) {
|
||||||
|
parts.push(
|
||||||
|
`heavily oversubscribed — just ${Math.round(admissions.first_preference_offer_pct)}% of applicants get a first-choice offer`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
parts.push('heavily oversubscribed');
|
||||||
|
}
|
||||||
|
} else if (admissions?.first_preference_offer_pct != null && admissions.first_preference_offer_pct >= 90) {
|
||||||
|
parts.push('most families get their first-choice offer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(', ') + '.';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user