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>
276 lines
9.0 KiB
TypeScript
276 lines
9.0 KiB
TypeScript
/**
|
||
* ComparisonChart Component
|
||
* Multi-school comparison chart using Chart.js.
|
||
*
|
||
* Desktop: built-in legend (point-style markers double as per-school shapes).
|
||
* Mobile (≤640px): the in-chart legend and axis titles are dropped in favour
|
||
* of a chip row above the canvas; tapping a chip highlights that school's
|
||
* line and dims the rest. The y-axis auto-fits the data on all viewports so
|
||
* clustered schools stay distinguishable.
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { Line } from 'react-chartjs-2';
|
||
import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
|
||
import '@/lib/chartSetup';
|
||
import { useSeriesColors, useThemeTokens } from '@/lib/theme';
|
||
import { buildCompareChart } from '@/lib/compareChartData';
|
||
import type { ComparisonData } from '@/lib/types';
|
||
import {
|
||
CHART_COLORS,
|
||
CHART_TEXT_COLORS,
|
||
computeYBounds,
|
||
formatAcademicYear,
|
||
metricKind,
|
||
rgbToRgba,
|
||
} from '@/lib/utils';
|
||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||
import { track } from '@/lib/analytics';
|
||
import styles from './ComparisonChart.module.css';
|
||
|
||
interface ComparisonChartProps {
|
||
comparisonData: Record<string, ComparisonData>;
|
||
/** Ordered as displayed in the school cards, so colours match by index. */
|
||
schools: Array<{ urn: number; school_name: string }>;
|
||
metric: string;
|
||
metricLabel: string;
|
||
/** Official England figure per academic year for this metric — renders a
|
||
* dashed grey reference line when provided. */
|
||
nationalByYear?: Record<number, number | null | undefined>;
|
||
/** KS4 metrics get a different (honest) gap caption than KS2. */
|
||
isSecondary?: boolean;
|
||
}
|
||
|
||
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
|
||
// converging lines stay tellable apart without relying on hue alone.
|
||
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
||
|
||
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
|
||
const isMobile = useIsMobile();
|
||
// Canvas can't resolve var(), so the series come through the token bridge.
|
||
// Same index order as CHART_COLORS, so a school's line and its DOM swatch
|
||
// stay the same colour.
|
||
const seriesColors = useSeriesColors();
|
||
const [cRef, cGrid, cInverse, cInverseText] = useThemeTokens(
|
||
'--chart-reference', '--chart-grid', '--surface-inverse', '--text-inverse'
|
||
);
|
||
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
|
||
|
||
// A focused school that leaves the basket must not linger.
|
||
const urnKey = schools.map((s) => s.urn).join(',');
|
||
useEffect(() => {
|
||
setFocusedUrn(null);
|
||
}, [urnKey]);
|
||
|
||
if (schools.length === 0) {
|
||
return <div>No data available</div>;
|
||
}
|
||
|
||
// Pure, tested series construction: union of years with cancelled /
|
||
// unpublished years kept as real gaps, plus the England overlay.
|
||
const built = buildCompareChart(comparisonData, schools, metric, nationalByYear);
|
||
const { years } = built;
|
||
|
||
const datasets: ChartDataset<'line'>[] = built.schoolDatasets.map((series) => {
|
||
const school = schools[series.schoolIndex];
|
||
const color = seriesColors[series.schoolIndex % seriesColors.length];
|
||
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
|
||
|
||
return {
|
||
label: series.label,
|
||
data: series.data,
|
||
borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
|
||
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
|
||
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
|
||
pointStyle: POINT_STYLES[series.schoolIndex % POINT_STYLES.length],
|
||
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
|
||
pointHoverRadius: isMobile ? 5 : 6,
|
||
tension: 0.3,
|
||
// Never bridge missing years — gaps are information (COVID
|
||
// cancellations, unpublished 2021/22, schools that opened later).
|
||
spanGaps: false,
|
||
};
|
||
});
|
||
|
||
if (built.englandDataset) {
|
||
datasets.push({
|
||
label: built.englandDataset.label,
|
||
data: built.englandDataset.data,
|
||
borderColor: cRef,
|
||
backgroundColor: 'transparent',
|
||
borderWidth: 1.5,
|
||
borderDash: built.englandDataset.borderDash,
|
||
pointStyle: 'line',
|
||
pointRadius: 0,
|
||
pointHoverRadius: 4,
|
||
tension: 0,
|
||
spanGaps: false,
|
||
});
|
||
}
|
||
|
||
const chartData = {
|
||
labels: years.map(formatAcademicYear),
|
||
datasets,
|
||
};
|
||
|
||
const kind = metricKind(metric);
|
||
const yBounds = computeYBounds(
|
||
datasets.flatMap((ds) => ds.data as Array<number | null>),
|
||
kind,
|
||
);
|
||
|
||
const options: ChartOptions<'line'> = {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {
|
||
mode: 'index' as const,
|
||
intersect: false,
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: !isMobile,
|
||
position: 'top' as const,
|
||
labels: {
|
||
usePointStyle: true,
|
||
padding: 15,
|
||
font: {
|
||
size: 12,
|
||
},
|
||
},
|
||
},
|
||
// No in-chart title: the section heading and metric selector above the
|
||
// chart already state the metric.
|
||
title: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
backgroundColor: cInverse,
|
||
titleColor: cInverseText,
|
||
bodyColor: cInverseText,
|
||
padding: isMobile ? 10 : 12,
|
||
titleFont: {
|
||
size: isMobile ? 12 : 14,
|
||
},
|
||
bodyFont: {
|
||
size: isMobile ? 11 : 13,
|
||
},
|
||
usePointStyle: true,
|
||
itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
|
||
callbacks: {
|
||
label: function (context) {
|
||
let label = context.dataset.label || '';
|
||
if (label) {
|
||
label += ': ';
|
||
}
|
||
if (context.parsed.y !== null) {
|
||
label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : '');
|
||
} else {
|
||
label += 'N/A';
|
||
}
|
||
return label;
|
||
},
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
y: {
|
||
type: 'linear' as const,
|
||
display: true,
|
||
title: {
|
||
display: !isMobile,
|
||
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
|
||
font: {
|
||
size: 12,
|
||
weight: 'bold',
|
||
},
|
||
},
|
||
...yBounds,
|
||
ticks: {
|
||
font: { size: isMobile ? 10 : 12 },
|
||
...(isMobile && { maxTicksLimit: 5 }),
|
||
},
|
||
grid: {
|
||
color: cGrid,
|
||
},
|
||
},
|
||
x: {
|
||
grid: {
|
||
display: false,
|
||
},
|
||
title: {
|
||
display: !isMobile,
|
||
text: 'Year',
|
||
font: {
|
||
size: 12,
|
||
weight: 'bold',
|
||
},
|
||
},
|
||
ticks: {
|
||
font: { size: isMobile ? 10 : 12 },
|
||
...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }),
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const toggleFocus = (urn: number) => {
|
||
const next = focusedUrn === urn ? null : urn;
|
||
setFocusedUrn(next);
|
||
if (next !== null) track('compare_focus_school', { urn: next });
|
||
};
|
||
|
||
return (
|
||
<div className={styles.wrapper}>
|
||
{/* Mobile legend + focus control; a single series needs no legend. */}
|
||
{schools.length > 1 && (
|
||
<div className={styles.chips} role="group" aria-label="Highlight a school on the chart">
|
||
{schools.map((school, index) => (
|
||
<button
|
||
key={school.urn}
|
||
type="button"
|
||
className={styles.chip}
|
||
aria-pressed={focusedUrn === school.urn}
|
||
onClick={() => toggleFocus(school.urn)}
|
||
>
|
||
<span
|
||
className={styles.chipDot}
|
||
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
|
||
aria-hidden="true"
|
||
/>
|
||
<span
|
||
className={styles.chipName}
|
||
style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}
|
||
>
|
||
{school.school_name}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className={styles.canvasBox}>
|
||
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
|
||
</div>
|
||
{isSecondary && built.englandOnlyYears.length > 0 ? (
|
||
// KS4's honest story differs from KS2's: 2019/20–2020/21 school-level
|
||
// GCSE results weren't published (COVID grading); later years WERE
|
||
// published by DfE but aren't in our dataset yet.
|
||
<p className={styles.chartNote}>
|
||
School-level GCSE figures for 2019/20 and 2020/21 weren't published (COVID
|
||
grading), and more recent years aren't in our dataset yet where lines break — the
|
||
England average is shown where available.
|
||
</p>
|
||
) : (
|
||
!isSecondary &&
|
||
built.showUnpublished202122Note && (
|
||
<p className={styles.chartNote}>
|
||
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 that year.
|
||
</p>
|
||
)
|
||
)}
|
||
</div>
|
||
);
|
||
}
|