From a3cfffa4d0049ce33ac50a8589389be2e2d70d3b Mon Sep 17 00:00:00 2001 From: Tudor Sitaru Date: Thu, 9 Apr 2026 13:55:14 +0100 Subject: [PATCH] feat: national average reference line now tracks per year on history chart Previously the dashed reference line was a flat horizontal at the latest year's national average across all historical data, implying the national figure was constant. Now the backend returns per-year averages in `by_year` and the chart maps each data year to its own national average, so the reference line correctly reflects how the national picture changed over time (including COVID recovery dip/recovery). - backend: /api/national-averages now includes `by_year` list alongside existing `year`/`primary`/`secondary` latest-year snapshot - types: NationalAverages extended with `by_year: NationalAveragesYear[]` - PerformanceChart: accepts `nationalByYear` prop; builds per-year series aligned to school data years, falling back to scalar prop if absent - SchoolDetailView + SecondarySchoolDetailView: pass `nationalAvg.by_year` Co-Authored-By: Claude Sonnet 4.6 --- backend/app.py | 18 +++++++-- nextjs-app/components/PerformanceChart.tsx | 40 ++++++++++++++++--- nextjs-app/components/SchoolDetailView.tsx | 1 + .../components/SecondarySchoolDetailView.tsx | 1 + nextjs-app/lib/types.ts | 7 +++- 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/backend/app.py b/backend/app.py index 08895be..1d86a6f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -676,9 +676,6 @@ async def get_national_averages(request: Request): if df.empty: return {"primary": {}, "secondary": {}} - latest_year = int(df["year"].max()) - df_latest = df[df["year"] == latest_year] - ks2_metrics = [ "rwm_expected_pct", "rwm_high_pct", "reading_expected_pct", "writing_expected_pct", "maths_expected_pct", @@ -703,15 +700,30 @@ async def get_national_averages(request: Request): out[col] = round(float(val.mean()), 2) return out + latest_year = int(df["year"].max()) + df_latest = df[df["year"] == latest_year] + # Primary: schools where KS2 data is non-null primary_df = df_latest[df_latest["rwm_expected_pct"].notna()] # Secondary: schools where KS4 data is non-null secondary_df = df_latest[df_latest["attainment_8_score"].notna()] + # Per-year averages for every year in the dataset (used by chart reference lines) + by_year = [] + for yr in sorted(df["year"].dropna().unique()): + yr = int(yr) + df_yr = df[df["year"] == yr] + by_year.append({ + "year": yr, + "primary": _means(df_yr[df_yr["rwm_expected_pct"].notna()], ks2_metrics), + "secondary": _means(df_yr[df_yr["attainment_8_score"].notna()], ks4_metrics), + }) + return { "year": latest_year, "primary": _means(primary_df, ks2_metrics), "secondary": _means(secondary_df, ks4_metrics), + "by_year": by_year, } diff --git a/nextjs-app/components/PerformanceChart.tsx b/nextjs-app/components/PerformanceChart.tsx index 300a3f1..4ca0a0f 100644 --- a/nextjs-app/components/PerformanceChart.tsx +++ b/nextjs-app/components/PerformanceChart.tsx @@ -24,14 +24,22 @@ import styles from './PerformanceChart.module.css'; ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); +interface NationalByYear { + year: number; + primary: Record; + secondary: Record; +} + interface PerformanceChartProps { data: SchoolResult[]; schoolName: string; isSecondary?: boolean; - /** National average RWM expected % — rendered as a dashed reference line */ + /** National average RWM expected % for the latest year — fallback if no by_year data */ nationalRwmAvg?: number | null; - /** National average Attainment 8 — rendered as a dashed reference line */ + /** National average Attainment 8 for the latest year — fallback if no by_year data */ nationalAtt8Avg?: number | null; + /** Per-year national averages — used to draw a changing reference line */ + nationalByYear?: NationalByYear[]; } // Academic years when SATs/GCSEs were cancelled due to COVID @@ -42,10 +50,30 @@ export function PerformanceChart({ isSecondary = false, nationalRwmAvg, nationalAtt8Avg, + nationalByYear, }: PerformanceChartProps) { const sortedData = [...data].sort((a, b) => a.year - b.year); const years = sortedData.map(d => formatAcademicYear(d.year)); + // Build per-year national average series aligned to the school's data years. + // Falls back to a flat line using the scalar prop if by_year isn't available. + 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) ────────────────────────────────────── const trendSummary = (() => { if (isSecondary) return null; @@ -114,10 +142,10 @@ export function PerformanceChart({ hidden: true, yAxisID: 'y1', }, - ...(nationalAtt8Avg != null ? [{ + ...(hasNatAtt8 ? [{ ...refLineStyle, label: 'National average', - data: sortedData.map(() => nationalAtt8Avg!), + data: natRefAtt8, yAxisID: 'y', } as ChartDataset<'line'>] : []), ] : [ @@ -142,10 +170,10 @@ export function PerformanceChart({ pointRadius: 3, yAxisID: 'y', }, - ...(nationalRwmAvg != null ? [{ + ...(hasNatRwm ? [{ ...refLineStyle, label: 'National average', - data: sortedData.map(() => nationalRwmAvg!), + data: natRefRwm, yAxisID: 'y', } as ChartDataset<'line'>] : []), { diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 99782dd..ce1f150 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -1030,6 +1030,7 @@ export function SchoolDetailView({ isSecondary={isSecondary} nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null} nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null} + nationalByYear={nationalAvg?.by_year} /> {yearlyData.length > 1 && ( diff --git a/nextjs-app/components/SecondarySchoolDetailView.tsx b/nextjs-app/components/SecondarySchoolDetailView.tsx index 7219f82..00e124a 100644 --- a/nextjs-app/components/SecondarySchoolDetailView.tsx +++ b/nextjs-app/components/SecondarySchoolDetailView.tsx @@ -618,6 +618,7 @@ export function SecondarySchoolDetailView({ schoolName={schoolInfo.school_name} isSecondary={true} nationalAtt8Avg={heroAtt8Nat} + nationalByYear={nationalAvg?.by_year} /> diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 7ead4bf..eddb1b1 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -351,12 +351,17 @@ export interface Filters { admissions_policies: string[]; } -export interface NationalAverages { +export interface NationalAveragesYear { year: number; primary: Record; secondary: Record; } +export interface NationalAverages extends NationalAveragesYear { + /** Per-year averages for every year in the dataset, used for chart reference lines. */ + by_year: NationalAveragesYear[]; +} + export interface LAaveragesResponse { year: number; secondary: {