Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 4.8 dc85254ad2 fix(compare): a no-results school no longer blanks the trend chart
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m8s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
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 54s
Adding a school with no performance data to a comparison made every
school's trend line disappear until that school was removed.

Root cause: /api/compare returns such a school with a single phantom
yearly_data row (the dim_school LEFT JOIN) whose year is null. In
buildCompareChart, Math.trunc(null) is 0, so the axis was seeded at
year 0; fillAcademicYears then walked 0, 101, 202, … and hit its
50-step cap long before reaching the real years, leaving every
school's series mapped entirely to null.

Fix: ignore yearly rows without a real numeric year when building the
axis and the per-school year map. Regression test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 15:17:43 +01:00
tudor d02a323cdc Merge pull request 'fix(detail): give Results Over Time chart more height on desktop' (#78) from fix/detail-trend-chart-taller into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 58s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 1m13s
Reviewed-on: #78
2026-07-21 21:08:34 +00:00
TudorandClaude Opus 4.8 32f8a02862 fix(detail): give Results Over Time chart more height on desktop
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 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 13s
The trend chart was capped at 280px tall on desktop, squishing the
lines together and making them hard to read. Raise the base
.chartContainer height to 380px in both the primary and secondary
detail views.

Mobile is unaffected: the base value is already overridden to
height:auto by the existing max-width:768px query, where
PerformanceChart's own .chartWrapper carries the canvas height.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 22:05:10 +01:00
tudor 7e3096c881 Merge pull request 'feat(analytics): load Umami heatmap recorder' (#77) from feat/umami-heatmap-recorder into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 15s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 54s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 47s
Reviewed-on: #77
2026-07-21 15:06:04 +00:00
TudorandClaude Opus 4.8 1f80571b1f feat(analytics): load Umami heatmap recorder
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 42s
Adds the Umami session recorder (recorder.js) that powers heatmaps,
alongside the existing analytics tracker. Loaded via next/script with
the same data-domains guard as the main tracker so it only fires on the
production hostnames — staging runs the same image under a different
host and must not feed the heatmap.

Cookieless: recorder.js uses Umami's server-side session model (no
cookie or local storage on the device), so it adds no new PECR
consent trigger.

Verified with tsc --noEmit and next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 16:03:18 +01:00
tudor 3132f07af2 Merge pull request 'fix(detail): compare each SATs bar to its own national benchmark' (#76) from fix/sats-per-level-national into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 20s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #76
2026-07-21 13:56:55 +00:00
TudorandClaude Opus 4.8 8a9ba30cc2 fix(detail): compare each SATs bar to its own national benchmark
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 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The KS2 SATs chart drew a single national-average line spanning the
full height of each subject's chart area, positioned at the national
*expected* value. But the area stacks two bars — Expected and Exceeding
— and the higher-standard/greater-depth national is a very different,
much lower figure (e.g. reading higher standard ~29% vs expected ~75%).
So the line crossed the Exceeding bar at the wrong place, making every
school's exceeding result look far below national when it wasn't.

The per-subject higher-standard nationals were already computed in the
fact_ks2_national_averages mart; they just weren't serialized. Fix:

- backend: add reading_high_pct, writing_gd_pct (writing = greater
  depth) and maths_high_pct to the national-averages payload.
- SchoolDetailView: pass a nationalExceedingPct per subject, mapping
  writing to the greater-depth figure.
- SatsChart: replace the single full-height line with a national marker
  on each bar's own track (coral tick + "nat X%" in the bar header), so
  Expected and Exceeding each sit against the correct benchmark.

KS2 only; the secondary Attainment 8 chart already uses one line for
one measure and is untouched.

Verified: tsc --noEmit, next build, and backend pytest (national
averages marts, incl. a new test guarding the per-subject nationals).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 14:55:43 +01:00
tudor 388b496cc5 Merge pull request 'feat(admissions): surface easy-to-miss deadlines as callout boxes' (#75) from fix/rankings-admissions-accuracy into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #75
2026-07-21 11:27:20 +00:00
tudor 5981916b82 Merge pull request 'fix(rankings,admissions): correct education-accuracy audit findings' (#74) from fix/rankings-admissions-accuracy into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 50s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 44s
Reviewed-on: #74
2026-07-21 11:16:14 +00:00
10 changed files with 166 additions and 55 deletions
+4
View File
@@ -807,6 +807,10 @@ async def get_la_averages(request: Request):
_KS2_NATIONAL_METRICS = [ _KS2_NATIONAL_METRICS = [
"rwm_expected_pct", "rwm_high_pct", "rwm_expected_pct", "rwm_high_pct",
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct", "reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
# Per-subject higher-standard nationals: reading/maths reach the "higher
# standard" in the tests; writing is teacher-assessed at "greater depth"
# (writing_gd_pct). Needed so each SATs bar compares to its own benchmark.
"reading_high_pct", "writing_gd_pct", "maths_high_pct",
"gps_expected_pct", "gps_high_pct", "science_expected_pct", "gps_expected_pct", "gps_high_pct", "science_expected_pct",
"reading_avg_score", "maths_avg_score", "gps_avg_score", "reading_avg_score", "maths_avg_score", "gps_avg_score",
"reading_progress", "writing_progress", "maths_progress", "reading_progress", "writing_progress", "maths_progress",
@@ -25,6 +25,11 @@ class _Ks2Row:
year = LATEST year = LATEST
rwm_expected_pct = 62.1 rwm_expected_pct = 62.1
gps_expected_pct = 72.0 gps_expected_pct = 72.0
# Per-subject higher-standard nationals — reading/maths reach the higher
# standard, writing is teacher-assessed at greater depth (writing_gd_pct).
reading_high_pct = 29.0
writing_gd_pct = 13.0
maths_high_pct = 24.0
class _Ks4Row: class _Ks4Row:
@@ -84,6 +89,16 @@ def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02 assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
def test_per_subject_higher_standard_nationals_are_surfaced(payload):
# The SATs chart compares each bar to its own benchmark, so the per-subject
# higher-standard / greater-depth nationals must reach the payload — not
# only the combined rwm_high_pct.
body = payload(_StubSession)
assert body["primary"]["reading_high_pct"] == 29.0
assert body["primary"]["writing_gd_pct"] == 13.0
assert body["primary"]["maths_high_pct"] == 24.0
def test_ks4_secondary_empty_when_mart_missing(payload): def test_ks4_secondary_empty_when_mart_missing(payload):
# No computed stand-in: the UI labels national figures as official DfE # No computed stand-in: the UI labels national figures as official DfE
# data, so an empty mart must yield an empty secondary series. # data, so an empty mart must yield an empty secondary series.
@@ -41,6 +41,30 @@ describe('buildCompareChart', () => {
} }
}); });
it('ignores a no-results school whose only row has a null year', () => {
// A school with no performance rows comes back from /api/compare with a
// single phantom yearly_data row (LEFT JOIN) where year and every metric
// are null. That null year must NOT pollute the axis: Math.trunc(null) is
// 0, and filling from year 0 blows past the real years, blanking every
// school's line. Regression guard for "add a no-data school → chart empty".
const withNoData = {
...THREE_SCHOOLS,
'4': school(4, [[null as unknown as number, null]]),
};
const list = [...SCHOOL_LIST, { urn: 4, school_name: 'School 4' }];
const chart = buildCompareChart(withNoData, list, 'rwm_expected_pct');
// The real years still drive the axis; the phantom year 0 is gone.
expect(chart.years).toContain(201819);
expect(chart.years).toContain(202425);
expect(chart.years).not.toContain(0);
// The three real schools still render their lines.
for (const urn of [1, 2, 3]) {
const ds = chart.schoolDatasets[urn - 1];
expect(ds.data.some((v) => v != null)).toBe(true);
}
});
it('handles float years from the API (202425.0 style)', () => { it('handles float years from the API (202425.0 style)', () => {
const floaty = { const floaty = {
'1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]), '1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]),
+8
View File
@@ -85,6 +85,14 @@ export default function RootLayout({
data-performance="true" data-performance="true"
strategy="afterInteractive" strategy="afterInteractive"
/> />
{/* Heatmap / session recorder — same data-domains guard so staging
(same image, different host) never feeds the heatmap. */}
<Script
src="https://analytics.schoolcompare.co.uk/recorder.js"
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
strategy="afterInteractive"
/>
</head> </head>
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}> <body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
<div className="noise-overlay" /> <div className="noise-overlay" />
+31 -15
View File
@@ -44,30 +44,46 @@
background: var(--bg-secondary, #f3ede4); background: var(--bg-secondary, #f3ede4);
} }
/* ── National average marker ── */ /* ── Per-bar national average marker ──
.natLine { Each bar compares against its own benchmark (expected vs higher standard /
greater depth), so the marker sits on the individual bar's track rather than
as one line spanning both bars. */
.natTick {
position: absolute; position: absolute;
top: 0; top: -3px;
height: calc(100% - 20px); bottom: -3px;
width: 1.5px; width: 2px;
background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */ transform: translateX(-50%);
z-index: 2; background: var(--accent-coral, #e07256);
border-radius: 2px;
z-index: 4;
pointer-events: none; pointer-events: none;
} }
.natPill { .natTick::before {
content: '';
position: absolute; position: absolute;
top: -10px; top: -3px;
left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-coral, #e07256); background: var(--accent-coral, #e07256);
color: #fff; }
.barHeaderRight {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.natLabel {
font-size: 0.55rem; font-size: 0.55rem;
font-weight: 700; font-weight: 700;
padding: 0.1rem 0.35rem; color: var(--accent-coral-dark, #b04a2e);
border-radius: 4px; text-transform: uppercase;
white-space: nowrap; letter-spacing: 0.03em;
z-index: 3;
letter-spacing: 0.02em;
} }
/* ── Bar rows ── */ /* ── Bar rows ── */
+52 -34
View File
@@ -8,6 +8,9 @@ interface SubjectData {
expectedPct: number | null; expectedPct: number | null;
exceedingPct: number | null; exceedingPct: number | null;
nationalExpectedPct: number | null; nationalExpectedPct: number | null;
/** National higher-standard / greater-depth benchmark for the exceeding
* bar — a different figure from the expected national. */
nationalExceedingPct: number | null;
} }
interface SatsChartProps { interface SatsChartProps {
@@ -17,11 +20,45 @@ interface SatsChartProps {
const RULER_TICKS = [0, 25, 50, 75, 100]; const RULER_TICKS = [0, 25, 50, 75, 100];
const GRIDLINE_POSITIONS = [25, 50, 75]; const GRIDLINE_POSITIONS = [25, 50, 75];
/** One results bar with a national marker positioned on its own track, so the
* expected and exceeding bars each compare against their own benchmark. */
function BarRow({
label,
value,
national,
barRef,
barClass,
}: {
label: string;
value: number;
national: number | null;
barRef: React.RefObject<HTMLDivElement | null>;
barClass: string;
}) {
return (
<div className={styles.barRow}>
<div className={styles.barHeader}>
<span className={styles.barLabelSuffix}>{label}</span>
<span className={styles.barHeaderRight}>
{national != null && <span className={styles.natLabel}>nat {national.toFixed(0)}%</span>}
<span className={styles.barLabel}>{value.toFixed(0)}%</span>
</span>
</div>
<div className={styles.barTrack}>
<div ref={barRef} className={`${styles.bar} ${barClass}`} data-width={value} />
{national != null && (
<div className={styles.natTick} style={{ left: `${national}%` }} aria-hidden="true" />
)}
</div>
</div>
);
}
function SubjectColumn({ subject }: { subject: SubjectData }) { function SubjectColumn({ subject }: { subject: SubjectData }) {
const expectedRef = useRef<HTMLDivElement>(null); const expectedRef = useRef<HTMLDivElement>(null);
const exceedingRef = useRef<HTMLDivElement>(null); const exceedingRef = useRef<HTMLDivElement>(null);
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject; const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
// Animate bars on mount // Animate bars on mount
useEffect(() => { useEffect(() => {
@@ -51,44 +88,25 @@ function SubjectColumn({ subject }: { subject: SubjectData }) {
))} ))}
</div> </div>
{/* National average marker */} {/* Bars — each carries its own national marker */}
{nationalExpectedPct != null && (
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
</div>
)}
{/* Bars */}
<div className={styles.barGroup}> <div className={styles.barGroup}>
{expectedPct != null && ( {expectedPct != null && (
<div className={styles.barRow}> <BarRow
<div className={styles.barHeader}> label="Expected"
<span className={styles.barLabelSuffix}>Expected</span> value={expectedPct}
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span> national={nationalExpectedPct}
</div> barRef={expectedRef}
<div className={styles.barTrack}> barClass={styles.barExpected}
<div
ref={expectedRef}
className={`${styles.bar} ${styles.barExpected}`}
data-width={expectedPct}
/> />
</div>
</div>
)} )}
{exceedingPct != null && ( {exceedingPct != null && (
<div className={styles.barRow}> <BarRow
<div className={styles.barHeader}> label="Exceeding"
<span className={styles.barLabelSuffix}>Exceeding</span> value={exceedingPct}
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span> national={nationalExceedingPct}
</div> barRef={exceedingRef}
<div className={styles.barTrack}> barClass={styles.barExceeding}
<div
ref={exceedingRef}
className={`${styles.bar} ${styles.barExceeding}`}
data-width={exceedingPct}
/> />
</div>
</div>
)} )}
</div> </div>
@@ -135,7 +153,7 @@ export default function SatsChart({ subjects }: SatsChartProps) {
Exceeding / high score Exceeding / high score
</div> </div>
<div className={styles.legendItem}> <div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} /> <div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
National average National average
</div> </div>
</div> </div>
@@ -855,7 +855,10 @@
/* Charts Section */ /* Charts Section */
.chartContainer { .chartContainer {
width: 100%; width: 100%;
height: 280px; /* Taller on desktop so the trend lines have vertical room to separate
and read clearly. Mobile overrides this to height:auto below (the
max-width:768px query), so this only affects desktop. */
height: 380px;
position: relative; position: relative;
} }
@@ -752,18 +752,22 @@ export function SchoolDetailView({
expectedPct: latestResults.reading_expected_pct, expectedPct: latestResults.reading_expected_pct,
exceedingPct: latestResults.reading_high_pct, exceedingPct: latestResults.reading_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
}, },
{ {
name: 'Writing', name: 'Writing',
expectedPct: latestResults.writing_expected_pct, expectedPct: latestResults.writing_expected_pct,
exceedingPct: latestResults.writing_high_pct, exceedingPct: latestResults.writing_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
// Writing's higher level is teacher-assessed "greater depth".
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct,
}, },
{ {
name: 'Maths', name: 'Maths',
expectedPct: latestResults.maths_expected_pct, expectedPct: latestResults.maths_expected_pct,
exceedingPct: latestResults.maths_high_pct, exceedingPct: latestResults.maths_high_pct,
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct, nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct,
}, },
]} ]}
/> />
@@ -490,7 +490,10 @@
/* ── Charts & Map ────────────────────────────────────── */ /* ── Charts & Map ────────────────────────────────────── */
.chartContainer { .chartContainer {
width: 100%; width: 100%;
height: 280px; /* Taller on desktop so the trend lines have vertical room to separate
and read clearly. Mobile overrides this to height:auto below (the
max-width:768px query), so this only affects desktop. */
height: 380px;
position: relative; position: relative;
} }
+18 -2
View File
@@ -10,6 +10,17 @@
import type { ComparisonData } from './types'; import type { ComparisonData } from './types';
/**
* A yearly row only counts once it carries a real academic year. A school with
* no performance data still comes back from /api/compare with a single phantom
* row (the dim_school LEFT JOIN) where `year` is null — and Math.trunc(null) is
* 0, which would seed the axis at year 0 and, via fillAcademicYears, blow past
* every real year and blank all schools' lines. Drop those rows up front.
*/
function hasYear(row: { year: number }): boolean {
return typeof row.year === 'number' && Number.isFinite(row.year);
}
/** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */ /** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */
function nextAcademicYear(year: number): number { function nextAcademicYear(year: number): number {
const start = Math.floor(year / 100); const start = Math.floor(year / 100);
@@ -66,14 +77,19 @@ export function buildCompareChart(
nationalByYear?: Record<number, number | null | undefined>, nationalByYear?: Record<number, number | null | undefined>,
): CompareChart { ): CompareChart {
const rawYears = schools.flatMap( const rawYears = schools.flatMap(
(s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [], (s) =>
comparisonData[String(s.urn)]?.yearly_data.filter(hasYear).map((d) => Math.trunc(d.year)) ??
[],
); );
const years = fillAcademicYears(rawYears); const years = fillAcademicYears(rawYears);
const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => { const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => {
const rows = comparisonData[String(school.urn)]?.yearly_data ?? []; const rows = comparisonData[String(school.urn)]?.yearly_data ?? [];
const byYear = new Map<number, Record<string, unknown>>(); 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>); for (const row of rows) {
if (!hasYear(row)) continue;
byYear.set(Math.trunc(row.year), row as unknown as Record<string, unknown>);
}
return { return {
label: school.school_name, label: school.school_name,
data: years.map((year) => { data: years.map((year) => {