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

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:
Tudor Sitaru
2026-04-08 10:32:33 +01:00
parent f053b35c6f
commit c749d72a6a
5 changed files with 563 additions and 6 deletions
@@ -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);
}
+57
View File
@@ -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;
}
}
+145 -3
View File
@@ -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 &amp; 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 &amp; 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 &amp; Maths combined Reading, Writing &amp; 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 &amp; Maths) Exceeding expected level (Reading, Writing &amp; 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
View File
@@ -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(', ') + '.';
}