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 {
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;
}
}
+145 -3
View File
@@ -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<number, string> = {
@@ -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 (
<div className={styles.container}>
{/* Header */}
@@ -192,6 +210,110 @@ export function SchoolDetailView({
</button>
</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>
{/* Sticky Section Navigation */}
@@ -361,7 +483,17 @@ export function SchoolDetailView({
Reading, Writing &amp; Maths combined
<MetricTooltip metricKey="rwm_expected_pct" />
</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 && (
<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)
<MetricTooltip metricKey="rwm_high_pct" />
</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 && (
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
)}