Files
school_compare/nextjs-app/components/PerformanceChart.tsx
T
Tudor Sitaru 7e182e88b2
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 57s
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
feat(analytics): typed Umami event taxonomy across the funnel
Add lib/analytics.ts with a single typed track() wrapper. SSR-safe,
never throws, no-ops when Umami isn't loaded. Event names form a
fixed union so refactors stay safe.

14 events wired:

  Discovery (3)
    search_submitted          FilterBar submit + near_me path
    near_me_used              all geolocation outcomes
    empty_results             search returns 0 schools

  Engagement (5)
    school_viewed             SchoolDetail + Secondary on mount, with
                              urn / phase / local_authority / from
    section_nav_used          section-nav links on both detail views
    chart_metric_changed      mobile chart chip switch
    metric_compared_in_rankings rankings metric dropdown
    external_link_clicked     Ofsted / school website / DfE (declarative
                              data-umami-event attributes)

  Conversion (5)
    compare_school_added      search/rankings/detail/compare sources
    compare_school_removed    detail toggle and compare page
    compare_viewed            once per session when there's a selection
                              (school_count, phase_mix)
    compare_metric_changed    compare page metric dropdown
    compare_shared            native sheet vs clipboard distinguished

  Operational (1)
    api_error                 caught in handleResponse, includes
                              endpoint / status / route

Suggested Goals to configure in the Umami dashboard for the funnel
report: search_submitted → school_viewed → compare_school_added →
compare_viewed → compare_shared.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:04:22 +01:00

442 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { useEffect, useMemo, useState } from 'react';
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 { 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[];
}
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({
data,
isSecondary = false,
nationalRwmAvg,
nationalAtt8Avg,
nationalByYear,
}: PerformanceChartProps) {
const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => formatAcademicYear(d.year));
// ── Mobile detection ─────────────────────────────────────────────────
// 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 => {
if (nationalByYear) {
const match = nationalByYear.find(n => n.year === d.year);
return match?.primary?.rwm_expected_pct ?? null;
}
return nationalRwmAvg ?? null;
});
const natRefAtt8: (number | null)[] = sortedData.map(d => {
if (nationalByYear) {
const match = nationalByYear.find(n => n.year === d.year);
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}% Reading, Writing & Maths`;
}
return `${arrow} Peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
})();
const hasCovidGap = isSecondary
? false
: COVID_YEARS.size > 0 &&
[...COVID_YEARS].some(y => !sortedData.find(d => d.year === y));
// ── Datasets (full set; mobile filters them via the active chip) ─────
const refLineStyle = {
borderColor: 'rgba(90,80,70,0.35)',
backgroundColor: 'transparent',
borderWidth: 1.5,
borderDash: [6, 4] as number[],
pointRadius: 0,
tension: 0,
order: 10,
};
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
{
label: 'Attainment 8',
data: sortedData.map(d => d.attainment_8_score),
borderColor: '#2d7d7d',
backgroundColor: 'rgba(45,125,125,0.08)',
borderWidth: 2.5,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6,
yAxisID: 'y',
},
{
label: 'English & Maths Grade 4+',
data: sortedData.map(d => d.english_maths_standard_pass_pct),
borderColor: '#c9a227',
backgroundColor: 'rgba(201,162,39,0.08)',
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
yAxisID: 'y',
},
{
label: 'Progress 8',
data: sortedData.map(d => d.progress_8_score),
borderColor: 'rgb(139,92,246)',
backgroundColor: 'rgba(139,92,246,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: sortedData.map(d => d.rwm_expected_pct),
borderColor: '#2d7d7d',
backgroundColor: 'rgba(45,125,125,0.08)',
borderWidth: 2.5,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6,
yAxisID: 'y',
},
{
label: 'Exceeding expected level',
data: sortedData.map(d => d.rwm_high_pct),
borderColor: '#c9a227',
backgroundColor: 'rgba(201,162,39,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: sortedData.map(d => d.reading_progress),
borderColor: 'rgb(59,130,246)',
backgroundColor: 'rgba(59,130,246,0.08)',
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
hidden: true,
yAxisID: 'y1',
},
{
label: 'Writing progress',
data: sortedData.map(d => d.writing_progress),
borderColor: 'rgb(139,92,246)',
backgroundColor: 'rgba(139,92,246,0.08)',
borderWidth: 1.5,
tension: 0.3,
pointRadius: 3,
hidden: true,
yAxisID: 'y1',
},
{
label: 'Maths progress',
data: sortedData.map(d => d.maths_progress),
borderColor: 'rgb(236,72,153)',
backgroundColor: 'rgba(236,72,153,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 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,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 14,
font: { size: 12 },
},
},
title: { display: false },
tooltip: {
backgroundColor: 'rgba(26,22,18,0.92)',
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: 'rgba(0,0,0,0.05)' },
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 },
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 (
<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: '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 && (
<p className={styles.covidNote}>
* No data for 2019/20 or 2020/21 national assessments were cancelled due to COVID-19.
</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>
);
}