PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
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) Successful in 3m8s
S1: first-choice chip banded (More than half / About 1 in 3 / Over 1 in 4
missed out) so a 44%-offered grammar isn't understated by half.
S2: Progress 8 explains its absence for 2024/25+ cohorts (no KS2 baseline,
COVID) instead of a bare 'No data'.
S3: KS4 trend charts get their own honest gap caption (2019/20-2020/21
unpublished; later years not in our dataset yet); y-axis 'Value'→'Score';
buildCompareChart exposes englandOnlyYears.
S4: all-through schools labelled in chips, rail caption says 'N schools ·
<phase> view' for mixed baskets, whole-school roll no longer judged
against the single-phase median, community section carries an all-ages
caveat.
S6 (spec §8.5): disadvantaged attainment shows the cohort behind it
('of ~50 disadvantaged pupils').
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
108 lines
3.7 KiB
TypeScript
108 lines
3.7 KiB
TypeScript
/**
|
|
* Pure series-building for the comparison trend chart, extracted from
|
|
* ComparisonChart so it is unit-testable without a canvas.
|
|
*
|
|
* Chart truthfulness rules (spec §8.1): every academic year between the
|
|
* first and last data point appears on the axis — cancelled test years
|
|
* (2019/20, 2020/21) and the unpublished 2021/22 school-level year render
|
|
* as real gaps, never as compressed time; school lines never bridge gaps.
|
|
*/
|
|
|
|
import type { ComparisonData } from './types';
|
|
|
|
/** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */
|
|
function nextAcademicYear(year: number): number {
|
|
const start = Math.floor(year / 100);
|
|
const end = year % 100;
|
|
return (start + 1) * 100 + (end + 1);
|
|
}
|
|
|
|
/** Every academic year from min(years) to max(years), inclusive. */
|
|
export function fillAcademicYears(years: number[]): number[] {
|
|
if (years.length === 0) return [];
|
|
const ints = [...new Set(years.map((y) => Math.trunc(y)))].sort((a, b) => a - b);
|
|
const out: number[] = [];
|
|
let y = ints[0];
|
|
const last = ints[ints.length - 1];
|
|
while (y <= last && out.length < 50) {
|
|
out.push(y);
|
|
y = nextAcademicYear(y);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export interface CompareChartSeries {
|
|
label: string;
|
|
data: Array<number | null>;
|
|
/** Index into CHART_COLORS / point styles. */
|
|
schoolIndex: number;
|
|
spanGaps: false;
|
|
}
|
|
|
|
export interface EnglandSeries {
|
|
label: 'England average';
|
|
data: Array<number | null>;
|
|
borderDash: [number, number];
|
|
spanGaps: false;
|
|
}
|
|
|
|
export interface CompareChart {
|
|
years: number[];
|
|
schoolDatasets: CompareChartSeries[];
|
|
englandDataset: EnglandSeries | null;
|
|
/** True when England published a 2021/22 figure but no school has one —
|
|
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */
|
|
showUnpublished202122Note: boolean;
|
|
/** Years where the England overlay has a value but no school does — the
|
|
* chart shows a dashed-line-only stretch that needs explaining (KS2 and
|
|
* KS4 have different honest explanations, so the component owns the copy). */
|
|
englandOnlyYears: number[];
|
|
}
|
|
|
|
export function buildCompareChart(
|
|
comparisonData: Record<string, ComparisonData>,
|
|
schools: Array<{ urn: number; school_name: string }>,
|
|
metric: string,
|
|
nationalByYear?: Record<number, number | null | undefined>,
|
|
): CompareChart {
|
|
const rawYears = schools.flatMap(
|
|
(s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [],
|
|
);
|
|
const years = fillAcademicYears(rawYears);
|
|
|
|
const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => {
|
|
const rows = comparisonData[String(school.urn)]?.yearly_data ?? [];
|
|
const byYear = new Map<number, Record<string, unknown>>();
|
|
for (const row of rows) byYear.set(Math.trunc(row.year), row as unknown as Record<string, unknown>);
|
|
return {
|
|
label: school.school_name,
|
|
data: years.map((year) => {
|
|
const v = byYear.get(year)?.[metric];
|
|
return typeof v === 'number' && !Number.isNaN(v) ? v : null;
|
|
}),
|
|
schoolIndex,
|
|
spanGaps: false,
|
|
};
|
|
});
|
|
|
|
let englandDataset: EnglandSeries | null = null;
|
|
if (nationalByYear) {
|
|
const data = years.map((year) => {
|
|
const v = nationalByYear[year];
|
|
return typeof v === 'number' && !Number.isNaN(v) ? v : null;
|
|
});
|
|
if (data.some((v) => v != null)) {
|
|
englandDataset = { label: 'England average', data, borderDash: [5, 4], spanGaps: false };
|
|
}
|
|
}
|
|
|
|
const englandOnlyYears = years.filter(
|
|
(year, i) =>
|
|
englandDataset?.data[i] != null && schoolDatasets.every((ds) => ds.data[i] == null),
|
|
);
|
|
|
|
const showUnpublished202122Note = englandOnlyYears.includes(202122);
|
|
|
|
return { years, schoolDatasets, englandDataset, showUnpublished202122Note, englandOnlyYears };
|
|
}
|