From c749d72a6a58c8d588daec0996291119e2749cec Mon Sep 17 00:00:00 2001 From: Tudor Sitaru Date: Wed, 8 Apr 2026 10:32:33 +0100 Subject: [PATCH] feat(school-detail): editorial hero with signal chips, at-a-glance stats, summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- nextjs-app/components/DeltaChip.module.css | 38 ++++ nextjs-app/components/DeltaChip.tsx | 57 ++++++ .../components/SchoolDetailView.module.css | 173 +++++++++++++++++- nextjs-app/components/SchoolDetailView.tsx | 148 ++++++++++++++- nextjs-app/lib/utils.ts | 153 +++++++++++++++- 5 files changed, 563 insertions(+), 6 deletions(-) create mode 100644 nextjs-app/components/DeltaChip.module.css create mode 100644 nextjs-app/components/DeltaChip.tsx diff --git a/nextjs-app/components/DeltaChip.module.css b/nextjs-app/components/DeltaChip.module.css new file mode 100644 index 0000000..493c29a --- /dev/null +++ b/nextjs-app/components/DeltaChip.module.css @@ -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); +} diff --git a/nextjs-app/components/DeltaChip.tsx b/nextjs-app/components/DeltaChip.tsx new file mode 100644 index 0000000..4eb1299 --- /dev/null +++ b/nextjs-app/components/DeltaChip.tsx @@ -0,0 +1,57 @@ +/** + * DeltaChip — small coloured chip showing how a value compares to a baseline. + * + * Example: → "+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 ( + + {label} + {suffix && {suffix}} + + ); +} diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index 8cc8e33..779695a 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -26,11 +26,12 @@ } .schoolName { - font-size: 1.5rem; + font-size: clamp(2rem, 5vw, 3.25rem); font-weight: 700; color: var(--text-primary, #1a1612); margin-bottom: 0.5rem; - line-height: 1.2; + line-height: 1.1; + letter-spacing: -0.01em; font-family: var(--font-playfair), 'Playfair Display', serif; } @@ -715,3 +716,171 @@ 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; + } +} diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index d1d06a3..596305c 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -17,7 +17,11 @@ import type { SchoolAdmissions, SenDetail, Phonics, SchoolDeprivation, SchoolFinance, NationalAverages, } 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'; const OFSTED_LABELS: Record = { @@ -134,6 +138,20 @@ export function SchoolDetailView({ if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); 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 (
{/* Header */} @@ -192,6 +210,110 @@ export function SchoolDetailView({
+ + {/* Hero signal chip strip */} +
+
+
{ofstedHeroChip.title}
+
{ofstedHeroChip.subtitle}
+ {ofstedHeroChip.detail && ( +
{ofstedHeroChip.detail}
+ )} +
+ + {admissions?.oversubscribed && ( +
+
Oversubscribed
+
+ {admissions.first_preference_offer_pct != null + ? `${Math.round(admissions.first_preference_offer_pct)}% got first choice` + : 'More applicants than places'} +
+
+ )} + + {isPrimary && heroRwm != null && heroRwmNat != null && heroRwm > heroRwmNat && ( +
+
Above national average
+
+ Reading, Writing & Maths · {Math.round(heroRwm)} vs {Math.round(heroRwmNat)} +
+
+ )} + + {isSecondary && heroAtt8 != null && heroAtt8Nat != null && heroAtt8 > heroAtt8Nat && ( +
+
Above national average
+
+ Attainment 8 · {heroAtt8.toFixed(1)} vs {heroAtt8Nat.toFixed(1)} +
+
+ )} +
+ + {/* At-a-glance stats row */} + {latestResults && ( +
+ {isPrimary && heroRwm != null && ( +
+
{Math.round(heroRwm)}%
+
Reading, Writing & Maths
+ {heroRwmNat != null && ( + + )} +
+ )} + + {isSecondary && heroAtt8 != null && ( +
+
{heroAtt8.toFixed(1)}
+
Attainment 8 score
+ {heroAtt8Nat != null && ( + + )} +
+ )} + + {ofsted && ( +
+
+ {ofstedHeroChip.state === 'oeif' + ? ofstedHeroChip.title.replace(/^Ofsted\s+/, '') + : ofstedHeroChip.state === 'reportCard' + ? 'Report Card' + : '—'} +
+
+ {ofstedHeroChip.subtitle} +
+ {ofstedHeroChip.detail && ( +
{ofstedHeroChip.detail}
+ )} +
+ )} + + {admissions?.first_preference_offer_pct != null && ( +
+
+ {Math.round(admissions.first_preference_offer_pct)}% +
+
First-choice offer rate
+ {admissions.oversubscribed && ( +
Oversubscribed
+ )} +
+ )} +
+ )} + + {heroSummary && ( +

{heroSummary}

+ )} + {heroAcademicYear && ( +

+ Latest data: {heroAcademicYear} +

+ )} {/* Sticky Section Navigation */} @@ -361,7 +483,17 @@ export function SchoolDetailView({ Reading, Writing & Maths combined -
{formatPercentage(latestResults.rwm_expected_pct)}
+
+ {formatPercentage(latestResults.rwm_expected_pct)} + {primaryAvg.rwm_expected_pct != null && ( + + )} +
{primaryAvg.rwm_expected_pct != null && (
National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%
)} @@ -373,7 +505,17 @@ export function SchoolDetailView({ Exceeding expected level (Reading, Writing & Maths) -
{formatPercentage(latestResults.rwm_high_pct)}
+
+ {formatPercentage(latestResults.rwm_high_pct)} + {primaryAvg.rwm_high_pct != null && ( + + )} +
{primaryAvg.rwm_high_pct != null && (
National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%
)} diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index f052b41..9c64ba8 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -2,7 +2,7 @@ * Utility functions for SchoolCompare */ -import type { School, MetricDefinition } from './types'; +import type { School, MetricDefinition, OfstedInspection, SchoolAdmissions, SchoolResult } from './types'; // ============================================================================ // String Utilities @@ -404,3 +404,154 @@ export function getCurrentAcademicYear(): number { // Academic year starts in September (month 8) return month >= 8 ? year : year - 1; } + +// ============================================================================ +// School Detail Hero Helpers +// ============================================================================ + +const OFSTED_OEIF_WORDS: Record = { + 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(', ') + '.'; +}