feat: national average reference line now tracks per year on history chart
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 24s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 51s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m52s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s

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 <noreply@anthropic.com>
This commit is contained in:
Tudor Sitaru
2026-04-09 13:55:14 +01:00
parent 23f881b797
commit a3cfffa4d0
5 changed files with 57 additions and 10 deletions
+15 -3
View File
@@ -676,9 +676,6 @@ async def get_national_averages(request: Request):
if df.empty: if df.empty:
return {"primary": {}, "secondary": {}} return {"primary": {}, "secondary": {}}
latest_year = int(df["year"].max())
df_latest = df[df["year"] == latest_year]
ks2_metrics = [ ks2_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",
@@ -703,15 +700,30 @@ async def get_national_averages(request: Request):
out[col] = round(float(val.mean()), 2) out[col] = round(float(val.mean()), 2)
return out return out
latest_year = int(df["year"].max())
df_latest = df[df["year"] == latest_year]
# Primary: schools where KS2 data is non-null # Primary: schools where KS2 data is non-null
primary_df = df_latest[df_latest["rwm_expected_pct"].notna()] primary_df = df_latest[df_latest["rwm_expected_pct"].notna()]
# Secondary: schools where KS4 data is non-null # Secondary: schools where KS4 data is non-null
secondary_df = df_latest[df_latest["attainment_8_score"].notna()] 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 { return {
"year": latest_year, "year": latest_year,
"primary": _means(primary_df, ks2_metrics), "primary": _means(primary_df, ks2_metrics),
"secondary": _means(secondary_df, ks4_metrics), "secondary": _means(secondary_df, ks4_metrics),
"by_year": by_year,
} }
+34 -6
View File
@@ -24,14 +24,22 @@ import styles from './PerformanceChart.module.css';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
interface NationalByYear {
year: number;
primary: Record<string, number>;
secondary: Record<string, number>;
}
interface PerformanceChartProps { interface PerformanceChartProps {
data: SchoolResult[]; data: SchoolResult[];
schoolName: string; schoolName: string;
isSecondary?: boolean; 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; 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; 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 // Academic years when SATs/GCSEs were cancelled due to COVID
@@ -42,10 +50,30 @@ export function PerformanceChart({
isSecondary = false, isSecondary = false,
nationalRwmAvg, nationalRwmAvg,
nationalAtt8Avg, nationalAtt8Avg,
nationalByYear,
}: PerformanceChartProps) { }: PerformanceChartProps) {
const sortedData = [...data].sort((a, b) => a.year - b.year); const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => formatAcademicYear(d.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) ────────────────────────────────────── // ── Trend summary (primary only) ──────────────────────────────────────
const trendSummary = (() => { const trendSummary = (() => {
if (isSecondary) return null; if (isSecondary) return null;
@@ -114,10 +142,10 @@ export function PerformanceChart({
hidden: true, hidden: true,
yAxisID: 'y1', yAxisID: 'y1',
}, },
...(nationalAtt8Avg != null ? [{ ...(hasNatAtt8 ? [{
...refLineStyle, ...refLineStyle,
label: 'National average', label: 'National average',
data: sortedData.map(() => nationalAtt8Avg!), data: natRefAtt8,
yAxisID: 'y', yAxisID: 'y',
} as ChartDataset<'line'>] : []), } as ChartDataset<'line'>] : []),
] : [ ] : [
@@ -142,10 +170,10 @@ export function PerformanceChart({
pointRadius: 3, pointRadius: 3,
yAxisID: 'y', yAxisID: 'y',
}, },
...(nationalRwmAvg != null ? [{ ...(hasNatRwm ? [{
...refLineStyle, ...refLineStyle,
label: 'National average', label: 'National average',
data: sortedData.map(() => nationalRwmAvg!), data: natRefRwm,
yAxisID: 'y', yAxisID: 'y',
} as ChartDataset<'line'>] : []), } as ChartDataset<'line'>] : []),
{ {
@@ -1030,6 +1030,7 @@ export function SchoolDetailView({
isSecondary={isSecondary} isSecondary={isSecondary}
nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null} nationalRwmAvg={isPrimary ? (primaryAvg.rwm_expected_pct ?? null) : null}
nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null} nationalAtt8Avg={isSecondary ? (secondaryAvg.attainment_8_score ?? null) : null}
nationalByYear={nationalAvg?.by_year}
/> />
</div> </div>
{yearlyData.length > 1 && ( {yearlyData.length > 1 && (
@@ -618,6 +618,7 @@ export function SecondarySchoolDetailView({
schoolName={schoolInfo.school_name} schoolName={schoolInfo.school_name}
isSecondary={true} isSecondary={true}
nationalAtt8Avg={heroAtt8Nat} nationalAtt8Avg={heroAtt8Nat}
nationalByYear={nationalAvg?.by_year}
/> />
</div> </div>
</> </>
+6 -1
View File
@@ -351,12 +351,17 @@ export interface Filters {
admissions_policies: string[]; admissions_policies: string[];
} }
export interface NationalAverages { export interface NationalAveragesYear {
year: number; year: number;
primary: Record<string, number>; primary: Record<string, number>;
secondary: Record<string, number>; secondary: Record<string, number>;
} }
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 { export interface LAaveragesResponse {
year: number; year: number;
secondary: { secondary: {