feat(chart): mobile-only single-metric chip selector for Results Over Time
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 15s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
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 1s

The Results Over Time chart on the school detail page was overcrowded
on phones — 5-item legend wrapping over the plot area, a hidden right
y-axis still rendered for the (collapsed) progress series, fixed
0–100 percentage scale that flattened all variation, angled x-axis
labels eating vertical space.

At ≤640px the chart now renders one metric at a time, selected via a
chip strip above the plot. No more legend. No more dual y-axis. The
y-axis auto-tightens around the actual data range so variation is
visible (a school sitting in the 70–90% band now uses a 65–95 axis
instead of squashing onto a 0–100 line). A small subtitle above the
chips sets the subject context ("KS2 SATs · Reading, Writing & Maths"
or "GCSE results · Year 11") so chip labels can describe the *view*
rather than re-stating the subject.

Chip labels are spelled out in parent-facing language — no internal
shorthand like "RWM":

  Primary  (KS2):  At expected level (default)
                   Above expected level
                   Pupil progress         (shows 3 series + mini-legend)

  Secondary (KS4): Attainment 8 (default)
                   English & Maths grade 4+
                   Progress 8

Chips disable themselves (greyed, with a "No data for this school"
title) when the underlying series has no data points. Desktop
behaviour is unchanged — the full multi-series chart with dual y-axis
still renders >640px.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tudor Sitaru
2026-05-19 12:49:19 +01:00
parent 1ca957499a
commit d569a2afda
2 changed files with 272 additions and 35 deletions
@@ -29,8 +29,98 @@
font-style: italic; font-style: italic;
} }
/* ── Mobile chip selector ────────────────────────────────────────────
Hidden on desktop. Replaces the in-chart legend on phones — one
metric at a time so the line variation is actually readable. */
.mobileChips {
display: none;
}
.mobileSubtitle {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted, #6d685f);
margin-bottom: 0.4rem;
}
.chipRow {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.chip {
border: 1px solid var(--border-color, #e5dfd5);
background: var(--bg-card, #fff);
color: var(--text-secondary, #5c564d);
padding: 0.4rem 0.75rem;
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.chip:active {
background: var(--bg-secondary, #f3ede4);
}
.chip:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.chipActive {
background: var(--accent-coral, #e07256);
color: white;
border-color: var(--accent-coral, #e07256);
font-weight: 600;
}
.chipActive:active {
background: var(--accent-coral-dark, #c45a3f);
}
.miniLegend {
display: flex;
gap: 0.875rem;
font-size: 0.75rem;
color: var(--text-secondary, #5c564d);
margin-top: -0.25rem;
}
.miniLegend span {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.miniDot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.chartWrapper { .chartWrapper {
font-size: 0.875rem; font-size: 0.875rem;
} }
} }
@media (max-width: 640px) {
.mobileChips {
display: block;
}
/* The desktop "click the legend to show progress" hint is irrelevant
once the chip row is the disclosure mechanism. */
.chartHint {
display: none;
}
}
+182 -35
View File
@@ -1,10 +1,16 @@
/** /**
* PerformanceChart Component * PerformanceChart Component
* Displays school performance data over time using Chart.js * 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'; 'use client';
import { useEffect, useMemo, useState } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset } from 'chart.js'; import { ChartOptions, ChartDataset } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
@@ -22,17 +28,35 @@ interface PerformanceChartProps {
data: SchoolResult[]; data: SchoolResult[];
schoolName: string; schoolName: string;
isSecondary?: boolean; isSecondary?: boolean;
/** National average RWM expected % for the latest year — fallback if no by_year data */
nationalRwmAvg?: number | null; nationalRwmAvg?: number | null;
/** National average Attainment 8 for the latest year — fallback if no by_year data */
nationalAtt8Avg?: number | null; nationalAtt8Avg?: number | null;
/** Per-year national averages — used to draw a changing reference line */
nationalByYear?: NationalByYear[]; nationalByYear?: NationalByYear[];
} }
// Academic years when SATs/GCSEs were cancelled due to COVID
const COVID_YEARS = new Set([201920, 202021]); const COVID_YEARS = new Set([201920, 202021]);
// 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({ export function PerformanceChart({
data, data,
isSecondary = false, isSecondary = false,
@@ -43,8 +67,18 @@ export function PerformanceChart({
const sortedData = [...data].sort((a, b) => a.year - b.year); const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => formatAcademicYear(d.year)); const years = sortedData.map(d => formatAcademicYear(d.year));
// Build per-year national average series aligned to the school's data years. // ── Mobile detection ─────────────────────────────────────────────────
// Falls back to a flat line using the scalar prop if by_year isn't available. // Hydration-safe: SSR renders desktop; client flips to mobile after mount.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)');
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
// ── Build per-year national averages ─────────────────────────────────
const natRefRwm: (number | null)[] = sortedData.map(d => { const natRefRwm: (number | null)[] = sortedData.map(d => {
if (nationalByYear) { if (nationalByYear) {
const match = nationalByYear.find(n => n.year === d.year); const match = nationalByYear.find(n => n.year === d.year);
@@ -62,7 +96,7 @@ export function PerformanceChart({
const hasNatRwm = natRefRwm.some(v => v != null); const hasNatRwm = natRefRwm.some(v => v != null);
const hasNatAtt8 = natRefAtt8.some(v => v != null); const hasNatAtt8 = natRefAtt8.some(v => v != null);
// ── Trend summary (primary only) ────────────────────────────────────── // ── Trend summary (primary only — references headline metric) ────────
const trendSummary = (() => { const trendSummary = (() => {
if (isSecondary) return null; if (isSecondary) return null;
const rwm = sortedData.filter(d => d.rwm_expected_pct != null); const rwm = sortedData.filter(d => d.rwm_expected_pct != null);
@@ -80,13 +114,12 @@ export function PerformanceChart({
return `${arrow} Peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`; return `${arrow} Peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
})(); })();
// ── COVID gap note ─────────────────────────────────────────────────────
const hasCovidGap = isSecondary const hasCovidGap = isSecondary
? false ? false
: COVID_YEARS.size > 0 && : COVID_YEARS.size > 0 &&
[...COVID_YEARS].some(y => !sortedData.find(d => d.year === y)); [...COVID_YEARS].some(y => !sortedData.find(d => d.year === y));
// ── Datasets ────────────────────────────────────────────────────────── // ── Datasets (full set; mobile filters them via the active chip) ─────
const refLineStyle = { const refLineStyle = {
borderColor: 'rgba(90,80,70,0.35)', borderColor: 'rgba(90,80,70,0.35)',
backgroundColor: 'transparent', backgroundColor: 'transparent',
@@ -97,7 +130,7 @@ export function PerformanceChart({
order: 10, order: 10,
}; };
const datasets: ChartDataset<'line'>[] = isSecondary ? [ const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
{ {
label: 'Attainment 8', label: 'Attainment 8',
data: sortedData.map(d => d.attainment_8_score), data: sortedData.map(d => d.attainment_8_score),
@@ -199,7 +232,53 @@ export function PerformanceChart({
}, },
]; ];
const options: ChartOptions<'line'> = { // ── 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 0100 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, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
@@ -210,7 +289,6 @@ export function PerformanceChart({
usePointStyle: true, usePointStyle: true,
padding: 14, padding: 14,
font: { size: 12 }, font: { size: 12 },
filter: item => item.text !== 'National average' || true,
}, },
}, },
title: { display: false }, title: { display: false },
@@ -225,36 +303,22 @@ export function PerformanceChart({
if (ctx.parsed.y == null) return label; if (ctx.parsed.y == null) return label;
const isProgress = ctx.dataset.yAxisID === 'y1'; const isProgress = ctx.dataset.yAxisID === 'y1';
const suffix = isProgress ? '' : '%'; const suffix = isProgress ? '' : '%';
const val = ctx.parsed.y.toFixed(1); return `${label}: ${ctx.parsed.y.toFixed(1)}${suffix}`;
return `${label}: ${val}${suffix}`;
}, },
}, },
}, },
}, },
scales: { scales: {
y: { y: {
type: 'linear', type: 'linear', display: true, position: 'left',
display: true, title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 } },
position: 'left', min: 0, max: isSecondary ? undefined : 100,
title: {
display: true,
text: isSecondary ? 'Score / %' : 'Percentage (%)',
font: { size: 11 },
},
min: 0,
max: isSecondary ? undefined : 100,
grid: { color: 'rgba(0,0,0,0.05)' }, grid: { color: 'rgba(0,0,0,0.05)' },
ticks: { font: { size: 11 } }, ticks: { font: { size: 11 } },
}, },
y1: { y1: {
type: 'linear', type: 'linear', display: true, position: 'right',
display: true, title: { display: true, text: isSecondary ? 'Progress 8' : 'Progress score', font: { size: 11 } },
position: 'right',
title: {
display: true,
text: isSecondary ? 'Progress 8' : 'Progress score',
font: { size: 11 },
},
grid: { drawOnChartArea: false }, grid: { drawOnChartArea: false },
ticks: { font: { size: 11 } }, ticks: { font: { size: 11 } },
}, },
@@ -265,19 +329,102 @@ export function PerformanceChart({
}, },
}; };
const mobileOptions: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
title: { display: false },
tooltip: {
backgroundColor: 'rgba(26,22,18,0.92)',
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: 'rgba(0,0,0,0.05)' },
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
},
x: {
grid: { display: false },
ticks: { font: { size: 10 }, autoSkip: false },
},
},
};
const subtitle = isSecondary
? 'GCSE results · Year 11'
: 'KS2 SATs · Reading, Writing & Maths';
return ( return (
<div className={styles.chartOuter}> <div className={styles.chartOuter}>
{trendSummary && ( {trendSummary && (
<div className={styles.trendSummary}>{trendSummary}</div> <div className={styles.trendSummary}>{trendSummary}</div>
)} )}
<div className={styles.chartWrapper}>
<Line data={{ labels: years, datasets }} options={options} /> {/* 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={() => setActiveChip(chip.id)}
className={`${styles.chip}${active ? ` ${styles.chipActive}` : ''}`}
title={!enabled ? 'No data for this school' : undefined}
>
{chip.label}
</button>
);
})}
</div>
</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: 'rgb(59,130,246)' }} />Reading</span>
<span><span className={styles.miniDot} style={{ background: 'rgb(139,92,246)' }} />Writing</span>
<span><span className={styles.miniDot} style={{ background: 'rgb(236,72,153)' }} />Maths</span>
</div>
)}
{hasCovidGap && ( {hasCovidGap && (
<p className={styles.covidNote}> <p className={styles.covidNote}>
* No data for 2019/20 or 2020/21 national assessments were cancelled due to COVID-19. * No data for 2019/20 or 2020/21 national assessments were cancelled due to COVID-19.
</p> </p>
)} )}
{/* Desktop-only hint about toggling progress in the legend */}
{!isSecondary && ( {!isSecondary && (
<p className={styles.chartHint}> <p className={styles.chartHint}>
Progress scores (Reading, Writing, Maths) are hidden by default click them in the legend to show. Progress scores (Reading, Writing, Maths) are hidden by default click them in the legend to show.