PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 12s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m13s
Implements the direction agreed from the identity board: Route C ("Cohort")
with the paper ground from C1, the Schibsted Grotesk / Literata pairing from
C2, and the iris accent from C3. Dark theme is in scope from the start rather
than retrofitted.
The audit found three things wrong beyond taste:
* No brand asset set. og:image was absent entirely, so every link shared into
a class WhatsApp group rendered as a bare grey card. apple-touch-icon pointed
at an SVG, which iOS ignores, and the manifest shipped no PNGs, so Android
installs had no icon. The header mark and the favicon had also drifted into
two different logos.
* No colour discipline. --primary and --trend-down were the same coral, so the
main CTA and "below average" shared a hue. 58 distinct hex values were spread
across component CSS, and the chart palette was still Chart.js's stock demo
colours.
* A dark theme that was declared but never built — themeColor announced a dark
variant with no dark styling behind it.
What changed:
Colour now has exactly three jobs that never borrow each other's hues: brand
(iris) for interactive and identity, status (teal/amber) for above/below a
comparison point, and phase for categories. Teal/amber rather than green/red
keeps the above/below signal readable for every form of colour blindness.
Every chromatic literal in component CSS is now a token, and the JS-painted
surfaces (Chart.js, Leaflet) read the tokens through lib/theme so they follow
the theme instead of ignoring it.
The mark is the five-bar cohort spread — the same object as the distribution
strip inside a school row, built from opacity steps so it inverts cleanly.
components/Logo.tsx is the single source; the favicon, apple-icon and share
card all derive from its geometry.
globals.css drops 123 dead global classes left over from the vanilla-JS app
(only the btn family, .skip-link and .main were still referenced), along with
the noise overlay. It also gains prefers-reduced-motion support, which was
missing entirely, and a type scale so the 54 ad-hoc font sizes have somewhere
to converge.
Verified: tsc clean, 159 unit tests pass, production build succeeds and
prerenders /icon.svg, /apple-icon and /opengraph-image. Three e2e journeys
added for the asset set, the themeColor/background match, and the dark theme
actually repainting — all silent failures that nothing on the page reveals.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
/**
|
||
* PerformanceChart Component
|
||
* Displays school performance data over time using Chart.js.
|
||
*
|
||
* Desktop: full multi-series chart with dual y-axis (percentage + progress).
|
||
* Mobile (≤640px): a chip selector switches the chart between one focused view
|
||
* at a time — no dual axis, no legend, auto-scaled y-axis. Designed so the
|
||
* actual variation in the data is visible on a phone instead of a flat line.
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useMemo, useState } from 'react';
|
||
import { useThemeTokens, alpha } from '@/lib/theme';
|
||
import { Line } from 'react-chartjs-2';
|
||
import { ChartOptions, ChartDataset } from 'chart.js';
|
||
import '@/lib/chartSetup';
|
||
import type { SchoolResult } from '@/lib/types';
|
||
import { formatAcademicYear } from '@/lib/utils';
|
||
import { fillAcademicYears } from '@/lib/compareChartData';
|
||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||
import { track } from '@/lib/analytics';
|
||
import styles from './PerformanceChart.module.css';
|
||
|
||
interface NationalByYear {
|
||
year: number;
|
||
primary: Record<string, number>;
|
||
secondary: Record<string, number>;
|
||
}
|
||
|
||
interface PerformanceChartProps {
|
||
data: SchoolResult[];
|
||
schoolName: string;
|
||
isSecondary?: boolean;
|
||
nationalRwmAvg?: number | null;
|
||
nationalAtt8Avg?: number | null;
|
||
nationalByYear?: NationalByYear[];
|
||
}
|
||
|
||
// Mobile chip definitions: which datasets render when each chip is active.
|
||
// `series` keys reference the dataset labels so we can filter cleanly.
|
||
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
|
||
interface ChipDef {
|
||
id: ChipId;
|
||
label: string;
|
||
/** Dataset labels (from the desktop dataset list below) this chip surfaces. */
|
||
series: string[];
|
||
}
|
||
|
||
const PRIMARY_CHIPS: ChipDef[] = [
|
||
{ id: 'expected', label: 'At expected level', series: ['Reading, Writing & Maths expected %', 'National average'] },
|
||
{ id: 'higher', label: 'Above expected level', series: ['Exceeding expected level'] },
|
||
{ id: 'progress', label: 'Pupil progress', series: ['Reading progress', 'Writing progress', 'Maths progress'] },
|
||
];
|
||
|
||
const SECONDARY_CHIPS: ChipDef[] = [
|
||
{ id: 'attainment8', label: 'Attainment 8', series: ['Attainment 8', 'National average'] },
|
||
{ id: 'em_pass', label: 'English & Maths grade 4+', series: ['English & Maths Grade 4+'] },
|
||
{ id: 'progress8', label: 'Progress 8', series: ['Progress 8'] },
|
||
];
|
||
|
||
export function PerformanceChart({
|
||
data,
|
||
isSecondary = false,
|
||
nationalRwmAvg,
|
||
nationalAtt8Avg,
|
||
nationalByYear,
|
||
}: PerformanceChartProps) {
|
||
const sortedData = [...data].sort((a, b) => a.year - b.year);
|
||
|
||
// Gap-honest year axis: every academic year between the first and last data
|
||
// point appears, so cancelled/unpublished years (2019/20, 2020/21, and — for
|
||
// KS2 — 2021/22) render as real gaps rather than compressed time. School
|
||
// lines never bridge these gaps (spanGaps:false below).
|
||
const axisYears = fillAcademicYears(sortedData.map(d => d.year));
|
||
const byYear = new Map(sortedData.map(d => [d.year, d]));
|
||
const col = (key: keyof SchoolResult): (number | null)[] =>
|
||
axisYears.map(y => {
|
||
const v = byYear.get(y)?.[key];
|
||
return typeof v === 'number' ? v : null;
|
||
});
|
||
const years = axisYears.map(formatAcademicYear);
|
||
|
||
const isMobile = useIsMobile();
|
||
|
||
// ── Build per-year national averages (aligned to the filled axis) ────
|
||
const natRefRwm: (number | null)[] = axisYears.map(y => {
|
||
if (nationalByYear) {
|
||
const match = nationalByYear.find(n => n.year === y);
|
||
return match?.primary?.rwm_expected_pct ?? null;
|
||
}
|
||
return nationalRwmAvg ?? null;
|
||
});
|
||
const natRefAtt8: (number | null)[] = axisYears.map(y => {
|
||
if (nationalByYear) {
|
||
const match = nationalByYear.find(n => n.year === y);
|
||
return match?.secondary?.attainment_8_score ?? null;
|
||
}
|
||
return nationalAtt8Avg ?? null;
|
||
});
|
||
const hasNatRwm = natRefRwm.some(v => v != null);
|
||
const hasNatAtt8 = natRefAtt8.some(v => v != null);
|
||
|
||
// ── Trend summary (primary only — references headline metric) ────────
|
||
const trendSummary = (() => {
|
||
if (isSecondary) return null;
|
||
const rwm = sortedData.filter(d => d.rwm_expected_pct != null);
|
||
if (rwm.length < 2) return null;
|
||
const latest = rwm[rwm.length - 1];
|
||
const prev = rwm[rwm.length - 2];
|
||
const best = rwm.reduce((a, b) => (b.rwm_expected_pct! > a.rwm_expected_pct! ? b : a));
|
||
const latestPct = Math.round(latest.rwm_expected_pct!);
|
||
const bestPct = Math.round(best.rwm_expected_pct!);
|
||
const delta = latest.rwm_expected_pct! - prev.rwm_expected_pct!;
|
||
const arrow = delta > 1 ? '↑' : delta < -1 ? '↓' : '→';
|
||
if (best.year === latest.year) {
|
||
return `${arrow} Best year on record — ${latestPct}% met the expected standard in Reading, Writing & Maths`;
|
||
}
|
||
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
||
})();
|
||
|
||
// A gap year is any filled axis year the school has no results row for —
|
||
// exactly the cancelled/unpublished years the fill introduced. Drives the
|
||
// honest note below (KS2 and KS4 have different gap stories).
|
||
const gapYears = axisYears.filter(y => !byYear.has(y));
|
||
const hasGap = gapYears.length > 0;
|
||
|
||
// Chart.js paints to a canvas, so it needs resolved colours rather than
|
||
// var(). Reading them through the token bridge keeps the series on the
|
||
// brand palette and lets them follow the theme.
|
||
const [c1, c2, c3, c4, c5, c6, cGrid, cRef, cInverse, cInverseText] = useThemeTokens(
|
||
'--chart-1', '--chart-2', '--chart-3', '--chart-4', '--chart-5', '--chart-6',
|
||
'--chart-grid', '--chart-reference', '--surface-inverse', '--text-inverse'
|
||
);
|
||
|
||
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||
const refLineStyle = {
|
||
borderColor: cRef,
|
||
backgroundColor: 'transparent',
|
||
borderWidth: 1.5,
|
||
borderDash: [6, 4] as number[],
|
||
pointRadius: 0,
|
||
tension: 0,
|
||
spanGaps: false as const,
|
||
order: 10,
|
||
};
|
||
|
||
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
|
||
{
|
||
label: 'Attainment 8',
|
||
data: col('attainment_8_score'),
|
||
borderColor: c1,
|
||
backgroundColor: alpha('--chart-1', 0.08),
|
||
borderWidth: 2.5,
|
||
tension: 0.3,
|
||
pointRadius: 4,
|
||
pointHoverRadius: 6,
|
||
yAxisID: 'y',
|
||
},
|
||
{
|
||
label: 'English & Maths Grade 4+',
|
||
data: col('english_maths_standard_pass_pct'),
|
||
borderColor: c2,
|
||
backgroundColor: alpha('--chart-2', 0.08),
|
||
borderWidth: 1.5,
|
||
tension: 0.3,
|
||
pointRadius: 3,
|
||
yAxisID: 'y',
|
||
},
|
||
{
|
||
label: 'Progress 8',
|
||
data: col('progress_8_score'),
|
||
borderColor: c3,
|
||
backgroundColor: alpha('--chart-3', 0.08),
|
||
borderWidth: 1.5,
|
||
tension: 0.3,
|
||
pointRadius: 3,
|
||
hidden: true,
|
||
yAxisID: 'y1',
|
||
},
|
||
...(hasNatAtt8 ? [{
|
||
...refLineStyle,
|
||
label: 'National average',
|
||
data: natRefAtt8,
|
||
yAxisID: 'y',
|
||
} as ChartDataset<'line'>] : []),
|
||
] : [
|
||
{
|
||
label: 'Reading, Writing & Maths expected %',
|
||
data: col('rwm_expected_pct'),
|
||
borderColor: c1,
|
||
backgroundColor: alpha('--chart-1', 0.08),
|
||
borderWidth: 2.5,
|
||
tension: 0.3,
|
||
pointRadius: 4,
|
||
pointHoverRadius: 6,
|
||
yAxisID: 'y',
|
||
},
|
||
{
|
||
label: 'Exceeding expected level',
|
||
data: col('rwm_high_pct'),
|
||
borderColor: c2,
|
||
backgroundColor: alpha('--chart-2', 0.08),
|
||
borderWidth: 1.5,
|
||
tension: 0.3,
|
||
pointRadius: 3,
|
||
yAxisID: 'y',
|
||
},
|
||
...(hasNatRwm ? [{
|
||
...refLineStyle,
|
||
label: 'National average',
|
||
data: natRefRwm,
|
||
yAxisID: 'y',
|
||
} as ChartDataset<'line'>] : []),
|
||
{
|
||
label: 'Reading progress',
|
||
data: col('reading_progress'),
|
||
borderColor: c4,
|
||
backgroundColor: alpha('--chart-4', 0.08),
|
||
borderWidth: 1.5,
|
||
tension: 0.3,
|
||
pointRadius: 3,
|
||
hidden: true,
|
||
yAxisID: 'y1',
|
||
},
|
||
{
|
||
label: 'Writing progress',
|
||
data: col('writing_progress'),
|
||
borderColor: c3,
|
||
backgroundColor: alpha('--chart-3', 0.08),
|
||
borderWidth: 1.5,
|
||
tension: 0.3,
|
||
pointRadius: 3,
|
||
hidden: true,
|
||
yAxisID: 'y1',
|
||
},
|
||
{
|
||
label: 'Maths progress',
|
||
data: col('maths_progress'),
|
||
borderColor: c5,
|
||
backgroundColor: alpha('--chart-5', 0.08),
|
||
borderWidth: 1.5,
|
||
tension: 0.3,
|
||
pointRadius: 3,
|
||
hidden: true,
|
||
yAxisID: 'y1',
|
||
},
|
||
];
|
||
|
||
// ── Mobile chip state + filtered datasets ────────────────────────────
|
||
const chips = isSecondary ? SECONDARY_CHIPS : PRIMARY_CHIPS;
|
||
|
||
// A chip is enabled only if at least one of its series has any real data.
|
||
const chipHasData = (chip: ChipDef) =>
|
||
chip.series.some(name => {
|
||
const ds = allDatasets.find(d => d.label === name);
|
||
return ds?.data?.some(v => v != null);
|
||
});
|
||
|
||
const firstEnabledChip = chips.find(chipHasData)?.id ?? chips[0].id;
|
||
const [activeChip, setActiveChip] = useState<ChipId>(firstEnabledChip);
|
||
|
||
const activeChipDef = chips.find(c => c.id === activeChip) ?? chips[0];
|
||
|
||
const mobileDatasets = useMemo(() => {
|
||
return allDatasets
|
||
.filter(ds => activeChipDef.series.includes(ds.label ?? ''))
|
||
.map(ds => ({ ...ds, hidden: false, yAxisID: 'y' as const }));
|
||
}, [activeChipDef, allDatasets]);
|
||
|
||
// Auto-scale Y axis for the mobile chart so variation is visible.
|
||
// For percentage chips: clamp to 0–100 but tighten when data sits in a band.
|
||
// For progress chips: centre on 0 with a small symmetric range.
|
||
const mobileYBounds = useMemo(() => {
|
||
const isProgress = activeChip === 'progress' || activeChip === 'progress8';
|
||
const values: number[] = mobileDatasets.flatMap(ds =>
|
||
(ds.data as Array<number | null | undefined>).filter((v): v is number => typeof v === 'number')
|
||
);
|
||
if (values.length === 0) return { min: 0, max: 100, isProgress };
|
||
const lo = Math.min(...values);
|
||
const hi = Math.max(...values);
|
||
if (isProgress) {
|
||
const reach = Math.max(2, Math.ceil(Math.max(Math.abs(lo), Math.abs(hi)) + 0.5));
|
||
return { min: -reach, max: reach, isProgress };
|
||
}
|
||
// Percentage: leave headroom but never widen below 0 / above 100.
|
||
const padded = Math.max(5, Math.round((hi - lo) * 0.2));
|
||
return {
|
||
min: Math.max(0, Math.floor((lo - padded) / 5) * 5),
|
||
max: Math.min(100, Math.ceil((hi + padded) / 5) * 5),
|
||
isProgress,
|
||
};
|
||
}, [activeChip, mobileDatasets]);
|
||
|
||
// ── Chart options ────────────────────────────────────────────────────
|
||
const desktopOptions: ChartOptions<'line'> = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: 'index', intersect: false },
|
||
// Never bridge missing years — cancelled/unpublished years are real gaps.
|
||
datasets: { line: { spanGaps: false } },
|
||
plugins: {
|
||
legend: {
|
||
position: 'top',
|
||
labels: {
|
||
usePointStyle: true,
|
||
padding: 14,
|
||
font: { size: 12 },
|
||
},
|
||
},
|
||
title: { display: false },
|
||
tooltip: {
|
||
backgroundColor: cInverse,
|
||
titleColor: cInverseText,
|
||
bodyColor: cInverseText,
|
||
padding: 12,
|
||
titleFont: { size: 13 },
|
||
bodyFont: { size: 12 },
|
||
callbacks: {
|
||
label: ctx => {
|
||
const label = ctx.dataset.label ?? '';
|
||
if (ctx.parsed.y == null) return label;
|
||
const isProgress = ctx.dataset.yAxisID === 'y1';
|
||
const suffix = isProgress ? '' : '%';
|
||
return `${label}: ${ctx.parsed.y.toFixed(1)}${suffix}`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
y: {
|
||
type: 'linear', display: true, position: 'left',
|
||
title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 } },
|
||
min: 0, max: isSecondary ? undefined : 100,
|
||
grid: { color: cGrid },
|
||
ticks: { font: { size: 11 } },
|
||
},
|
||
y1: {
|
||
type: 'linear', display: true, position: 'right',
|
||
title: { display: true, text: isSecondary ? 'Progress 8' : 'Progress score', font: { size: 11 } },
|
||
grid: { drawOnChartArea: false },
|
||
ticks: { font: { size: 11 } },
|
||
},
|
||
x: {
|
||
grid: { display: false },
|
||
ticks: { font: { size: 11 } },
|
||
},
|
||
},
|
||
};
|
||
|
||
const mobileOptions: ChartOptions<'line'> = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: 'index', intersect: false },
|
||
datasets: { line: { spanGaps: false } },
|
||
plugins: {
|
||
legend: { display: false },
|
||
title: { display: false },
|
||
tooltip: {
|
||
backgroundColor: cInverse,
|
||
titleColor: cInverseText,
|
||
bodyColor: cInverseText,
|
||
padding: 10,
|
||
titleFont: { size: 12 },
|
||
bodyFont: { size: 11 },
|
||
callbacks: {
|
||
label: ctx => {
|
||
const label = ctx.dataset.label ?? '';
|
||
if (ctx.parsed.y == null) return label;
|
||
const suffix = mobileYBounds.isProgress ? '' : '%';
|
||
return `${label}: ${ctx.parsed.y.toFixed(1)}${suffix}`;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
y: {
|
||
type: 'linear', display: true, position: 'left',
|
||
min: mobileYBounds.min, max: mobileYBounds.max,
|
||
grid: { color: cGrid },
|
||
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
|
||
},
|
||
x: {
|
||
grid: { display: false },
|
||
// With the gap-honest axis (more year labels) autoSkip keeps the
|
||
// phone axis readable; the broken line still shows where a year is
|
||
// missing even when its tick label is skipped.
|
||
ticks: { font: { size: 10 }, autoSkip: true, maxTicksLimit: 5, maxRotation: 0 },
|
||
},
|
||
},
|
||
};
|
||
|
||
const subtitle = isSecondary
|
||
? 'GCSE results · Year 11'
|
||
: 'KS2 SATs · Reading, Writing & Maths';
|
||
|
||
return (
|
||
<div className={styles.chartOuter}>
|
||
{trendSummary && (
|
||
<div className={styles.trendSummary}>{trendSummary}</div>
|
||
)}
|
||
|
||
{/* Mobile-only chip selector */}
|
||
<div className={styles.mobileChips} aria-hidden={!isMobile}>
|
||
<div className={styles.mobileSubtitle}>{subtitle}</div>
|
||
<div className={styles.chipRow} role="tablist" aria-label="Select metric">
|
||
{chips.map(chip => {
|
||
const enabled = chipHasData(chip);
|
||
const active = chip.id === activeChip;
|
||
return (
|
||
<button
|
||
key={chip.id}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
disabled={!enabled}
|
||
onClick={() => {
|
||
if (chip.id !== activeChip) {
|
||
track('chart_metric_changed', { chip: chip.id, phase: isSecondary ? 'secondary' : 'primary', viewport: 'mobile' });
|
||
}
|
||
setActiveChip(chip.id);
|
||
}}
|
||
className={`${styles.chip}${active ? ` ${styles.chipActive}` : ''}`}
|
||
title={!enabled ? 'No data for this school' : undefined}
|
||
>
|
||
{chip.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.chartWrapper}>
|
||
<Line
|
||
data={{ labels: years, datasets: isMobile ? mobileDatasets : allDatasets }}
|
||
options={isMobile ? mobileOptions : desktopOptions}
|
||
/>
|
||
</div>
|
||
|
||
{/* When the Progress chip is active on primary, show a tiny inline legend
|
||
for the 3 sub-series (reading/writing/maths) — they share a unit and
|
||
belong together. */}
|
||
{isMobile && activeChip === 'progress' && (
|
||
<div className={styles.miniLegend}>
|
||
<span><span className={styles.miniDot} style={{ background: c4 }} />Reading</span>
|
||
<span><span className={styles.miniDot} style={{ background: c3 }} />Writing</span>
|
||
<span><span className={styles.miniDot} style={{ background: c5 }} />Maths</span>
|
||
</div>
|
||
)}
|
||
|
||
{hasGap && (
|
||
<p className={styles.covidNote}>
|
||
{isSecondary
|
||
? "School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID grading) where the line breaks — the England average is shown where available."
|
||
: "No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn't publish school-level figures for 2021/22 — the England average is shown for those years where available."}
|
||
</p>
|
||
)}
|
||
|
||
{/* Desktop-only hint about toggling progress in the legend */}
|
||
{!isSecondary && (
|
||
<p className={styles.chartHint}>
|
||
Progress scores (Reading, Writing, Maths) are hidden by default — click them in the legend to show.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|