11 Commits

Author SHA1 Message Date
Tudor Sitaru 9c50c49e1f fix(map): add effect deps, escape HTML in popup, document Att8 delta threshold
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 24s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
2026-04-13 14:32:55 +01:00
Tudor Sitaru 177571f411 feat(map): rebuild popup as mini card with Ofsted badge and headline metric
Replaces the bare Leaflet popup with a mini card showing school name,
3-state Ofsted badge (OEIF grade / ReportCard / pending), phase tag,
headline metric (Att8 for secondary, RWM% for primary) with delta vs
LA/national average, and a styled View Details button. Threads
nationalAvgRwm and laAverages from HomeView → SchoolMap → LeafletMapInner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:29:55 +01:00
Tudor Sitaru 51310160a8 fix(home): suppress unused nationalAvgRwm param, add ofstedPending badge branch 2026-04-13 14:26:55 +01:00
Tudor Sitaru 2c13b21360 feat(home): fetch national averages, wire to SchoolRow and CompactSchoolItem
- Add nationalAvgRwm state fetched from /api/national-averages on mount
- Pass nationalAvgRwm to SchoolRow (vs-national delta now active in list view)
- Pass nationalAvgRwm to SchoolMap (prop accepted, threaded to Task 7)
- Redesign CompactSchoolItem: Ofsted badge + single headline metric + delta
- Fix stray backslash in SchoolRow.module.css .vsNationalFlat selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:17:34 +01:00
Tudor Sitaru ad2fe5bbef fix(list): remove no-op null coerce and stale comment in SecondarySchoolRow 2026-04-13 14:11:05 +01:00
Tudor Sitaru 58f8eae997 feat(list): remove eng&maths stat, use buildOfstedListBadge in SecondarySchoolRow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:08:16 +01:00
Tudor Sitaru 44fdcfa18b feat(list): redesign primary school card — single metric, vs-national delta, fix label 2026-04-13 13:59:26 +01:00
Tudor Sitaru b1e025d468 style: add ofstedRc, ofstedPending, vsNational CSS classes to row modules 2026-04-13 13:58:46 +01:00
Tudor Sitaru 9ebb421307 fix(utils): tighten ofsted_grade type in buildOfstedListBadge 2026-04-13 13:56:58 +01:00
Tudor Sitaru 8a6758b591 feat(utils): add buildOfstedListBadge helper and fetchNationalAverages
- Add ofsted_framework field to School type
- Add OfstedListBadge interface and buildOfstedListBadge pure function to utils.ts
- Add fetchNationalAverages API function that calls GET /api/national-averages
- Add test suite for buildOfstedListBadge (all 6 new tests pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:52:05 +01:00
Tudor Sitaru 6d02d366ce feat(api): expose ofsted_framework in school list response 2026-04-13 13:45:50 +01:00
12 changed files with 374 additions and 130 deletions
+1
View File
@@ -547,6 +547,7 @@ SCHOOL_COLUMNS = [
"admissions_policy", "admissions_policy",
"ofsted_grade", "ofsted_grade",
"ofsted_date", "ofsted_date",
"ofsted_framework",
"latitude", "latitude",
"longitude", "longitude",
] ]
+39
View File
@@ -8,6 +8,7 @@ import {
calculateTrend, calculateTrend,
isValidPostcode, isValidPostcode,
debounce, debounce,
buildOfstedListBadge,
} from '@/lib/utils'; } from '@/lib/utils';
describe('formatPercentage', () => { describe('formatPercentage', () => {
@@ -102,3 +103,41 @@ describe('debounce', () => {
jest.useRealTimers(); jest.useRealTimers();
}); });
describe('buildOfstedListBadge', () => {
it('returns grade word + year for OEIF Outstanding', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 1, ofsted_date: '2023-11-15', ofsted_framework: 'OEIF' });
expect(badge.label).toBe('Outstanding · 2023');
expect(badge.cssClass).toBe('ofsted1');
});
it('returns grade word for each OEIF grade', () => {
expect(buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: '2022-05-01' }).label).toBe('Good · 2022');
expect(buildOfstedListBadge({ ofsted_grade: 3, ofsted_date: '2021-01-01' }).label).toBe('Req. Improvement · 2021');
expect(buildOfstedListBadge({ ofsted_grade: 4, ofsted_date: '2020-03-01' }).label).toBe('Inadequate · 2020');
});
it('returns grade word without year when date is missing', () => {
const badge = buildOfstedListBadge({ ofsted_grade: 2, ofsted_date: null });
expect(badge.label).toBe('Good');
expect(badge.cssClass).toBe('ofsted2');
});
it('returns Report Card badge when framework is ReportCard', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: '2025-11-01', ofsted_framework: 'ReportCard' });
expect(badge.label).toBe('Report Card · 2025');
expect(badge.cssClass).toBe('ofstedRc');
});
it('returns pending badge when no grade and no ReportCard framework', () => {
const badge = buildOfstedListBadge({ ofsted_grade: null, ofsted_date: null, ofsted_framework: null });
expect(badge.label).toBe('Not yet inspected');
expect(badge.cssClass).toBe('ofstedPending');
});
it('returns pending badge when all fields are undefined', () => {
const badge = buildOfstedListBadge({});
expect(badge.label).toBe('Not yet inspected');
expect(badge.cssClass).toBe('ofstedPending');
});
});
+73 -17
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation'; import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { FilterBar } from './FilterBar'; import { FilterBar } from './FilterBar';
import { SchoolRow } from './SchoolRow'; import { SchoolRow } from './SchoolRow';
@@ -13,9 +13,9 @@ import { SecondarySchoolRow } from './SecondarySchoolRow';
import { SchoolMap } from './SchoolMap'; import { SchoolMap } from './SchoolMap';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { useComparisonContext } from '@/context/ComparisonContext'; import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages } from '@/lib/api'; import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types'; import type { SchoolsResponse, Filters, School } from '@/lib/types';
import { schoolUrl } from '@/lib/utils'; import { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
import styles from './HomeView.module.css'; import styles from './HomeView.module.css';
interface HomeViewProps { interface HomeViewProps {
@@ -37,6 +37,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
const [hasMore, setHasMore] = useState(initialSchools.total_pages > 1); const [hasMore, setHasMore] = useState(initialSchools.total_pages > 1);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
const [laAverages, setLaAverages] = useState<Record<string, number>>({}); const [laAverages, setLaAverages] = useState<Record<string, number>>({});
const [nationalAvgRwm, setNationalAvgRwm] = useState<number | null>(null);
const [mapSchools, setMapSchools] = useState<School[]>([]); const [mapSchools, setMapSchools] = useState<School[]>([]);
const [isLoadingMap, setIsLoadingMap] = useState(false); const [isLoadingMap, setIsLoadingMap] = useState(false);
const prevSearchParamsRef = useRef(searchParams.toString()); const prevSearchParamsRef = useRef(searchParams.toString());
@@ -90,6 +91,13 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
.catch(() => {}); .catch(() => {});
}, [isSecondaryView, isMixedView]); }, [isSecondaryView, isMixedView]);
// Fetch national averages (supplementary — never blocks render)
useEffect(() => {
fetchNationalAverages()
.then(data => setNationalAvgRwm(data.primary?.rwm_expected_pct ?? null))
.catch(() => {});
}, []);
const handleLoadMore = async () => { const handleLoadMore = async () => {
if (isLoadingMore || !hasMore) return; if (isLoadingMore || !hasMore) return;
setIsLoadingMore(true); setIsLoadingMore(true);
@@ -252,6 +260,8 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
center={initialSchools.location_info?.coordinates} center={initialSchools.location_info?.coordinates}
referencePoint={initialSchools.location_info?.coordinates} referencePoint={initialSchools.location_info?.coordinates}
onMarkerClick={setSelectedMapSchool} onMarkerClick={setSelectedMapSchool}
nationalAvgRwm={nationalAvgRwm}
laAverages={laAverages}
/> />
</div> </div>
<div className={styles.compactList}> <div className={styles.compactList}>
@@ -264,6 +274,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
school={school} school={school}
onAddToCompare={addSchool} onAddToCompare={addSchool}
isInCompare={selectedSchools.some(s => s.urn === school.urn)} isInCompare={selectedSchools.some(s => s.urn === school.urn)}
nationalAvgRwm={nationalAvgRwm}
/> />
</div> </div>
))} ))}
@@ -278,6 +289,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
school={selectedMapSchool} school={selectedMapSchool}
onAddToCompare={addSchool} onAddToCompare={addSchool}
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)} isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
nationalAvgRwm={nationalAvgRwm}
/> />
</div> </div>
</div> </div>
@@ -306,6 +318,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
onAddToCompare={addSchool} onAddToCompare={addSchool}
onRemoveFromCompare={removeSchool} onRemoveFromCompare={removeSchool}
isInCompare={selectedSchools.some(s => s.urn === school.urn)} isInCompare={selectedSchools.some(s => s.urn === school.urn)}
nationalAvgRwm={nationalAvgRwm}
/> />
) )
))} ))}
@@ -339,9 +352,28 @@ interface CompactSchoolItemProps {
school: School; school: School;
onAddToCompare: (school: School) => void; onAddToCompare: (school: School) => void;
isInCompare: boolean; isInCompare: boolean;
nationalAvgRwm?: number | null;
} }
function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoolItemProps) { function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm }: CompactSchoolItemProps) {
const ofstedBadge = buildOfstedListBadge(school);
const isSecondary = school.attainment_8_score != null;
// vs-national delta for primary schools
const rwmDelta =
!isSecondary && school.rwm_expected_pct != null && nationalAvgRwm != null
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
: null;
const deltaStyle: React.CSSProperties =
rwmDelta == null
? {}
: rwmDelta >= 2
? { fontSize: '0.7rem', color: 'var(--accent-teal, #2d7d7d)', fontWeight: 600 }
: rwmDelta <= -2
? { fontSize: '0.7rem', color: 'var(--accent-coral, #e07256)', fontWeight: 600 }
: { fontSize: '0.7rem', color: 'var(--text-muted, #8a847a)' };
return ( return (
<div className={styles.compactItem}> <div className={styles.compactItem}>
<div className={styles.compactItemContent}> <div className={styles.compactItemContent}>
@@ -355,24 +387,48 @@ function CompactSchoolItem({ school, onAddToCompare, isInCompare }: CompactSchoo
</span> </span>
)} )}
</div> </div>
<div className={styles.compactItemMeta}> {/* Ofsted badge */}
{school.school_type && <span>{school.school_type}</span>} <div style={{ marginBottom: '0.25rem' }}>
{school.local_authority && <span>{school.local_authority}</span>} <span
style={{
display: 'inline-block',
padding: '0.0625rem 0.375rem',
fontSize: '0.625rem',
fontWeight: 600,
borderRadius: '3px',
whiteSpace: 'nowrap',
...(ofstedBadge.cssClass === 'ofsted1' ? { background: 'var(--accent-teal-bg)', color: 'var(--accent-teal, #2d7d7d)' } :
ofstedBadge.cssClass === 'ofsted2' ? { background: 'rgba(60,140,60,0.12)', color: '#3c8c3c' } :
ofstedBadge.cssClass === 'ofsted3' ? { background: 'var(--accent-gold-bg)', color: '#b8920e' } :
ofstedBadge.cssClass === 'ofsted4' ? { background: 'var(--accent-coral-bg)', color: 'var(--accent-coral, #e07256)' } :
ofstedBadge.cssClass === 'ofstedRc' ? { background: '#5a3a6e', color: '#fff' } :
ofstedBadge.cssClass === 'ofstedPending' ? { background: '#e0e0e0', color: '#666' } :
{ background: '#e0e0e0', color: '#666' }),
}}
>
{ofstedBadge.label}
</span>
</div> </div>
{/* Headline metric + delta */}
<div className={styles.compactItemStats}> <div className={styles.compactItemStats}>
<span className={styles.compactStat}> <span className={styles.compactStat}>
<strong> <strong>
{school.attainment_8_score != null {isSecondary
? school.attainment_8_score.toFixed(1) ? (school.attainment_8_score != null ? school.attainment_8_score.toFixed(1) : '-')
: school.rwm_expected_pct !== null : (school.rwm_expected_pct != null ? `${school.rwm_expected_pct}%` : '-')}
? `${school.rwm_expected_pct}%` </strong>
: '-'} {' '}
</strong>{' '} {isSecondary ? 'Att 8' : 'RWM'}
{school.attainment_8_score != null ? 'Att 8' : 'RWM'}
</span>
<span className={styles.compactStat}>
<strong>{school.total_pupils || '-'}</strong> pupils
</span> </span>
{rwmDelta != null && (
<span style={deltaStyle}>
{rwmDelta >= 2
? `+${rwmDelta} pts vs national`
: rwmDelta <= -2
? `${rwmDelta} pts vs national`
: '≈ national avg'}
</span>
)}
</div> </div>
</div> </div>
<div className={styles.compactItemActions}> <div className={styles.compactItemActions}>
+103 -10
View File
@@ -25,9 +25,43 @@ interface LeafletMapInnerProps {
zoom: number; zoom: number;
referencePoint?: [number, number]; referencePoint?: [number, number];
onMarkerClick?: (school: School) => void; onMarkerClick?: (school: School) => void;
nationalAvgRwm?: number | null;
laAverages?: Record<string, number | null>;
} }
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick }: LeafletMapInnerProps) { // ---------------------------------------------------------------------------
// Popup helpers (must work in plain JS string templates — no React / CSS Modules)
// ---------------------------------------------------------------------------
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
interface PopupBadge {
label: string;
style: string;
}
function buildPopupBadge(school: School): PopupBadge {
const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null;
const yearStr = year ? ` · ${year}` : '';
if (school.ofsted_grade) {
const labels: Record<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
const colours: Record<number, string> = {
1: 'background:#d4f0ea;color:#2d7d7d',
2: 'background:rgba(60,140,60,0.12);color:#3c8c3c',
3: 'background:#fef3cd;color:#b8920e',
4: 'background:#fde8e0;color:#e07256',
};
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
}
if (school.ofsted_framework === 'ReportCard') {
return { label: `Report Card${yearStr}`, style: 'background:#5a3a6e;color:#fff' };
}
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
}
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
const mapRef = useRef<L.Map | null>(null); const mapRef = useRef<L.Map | null>(null);
const mapContainerRef = useRef<HTMLDivElement>(null); const mapContainerRef = useRef<HTMLDivElement>(null);
const refMarkerRef = useRef<L.Marker | null>(null); const refMarkerRef = useRef<L.Marker | null>(null);
@@ -81,14 +115,73 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current); const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current);
// Create popup content // Create popup content
const popupContent = ` const badge = buildPopupBadge(school);
<div style="min-width: 200px;"> const isSecondary = school.attainment_8_score != null;
<strong style="font-size: 14px; display: block; margin-bottom: 8px;">${school.school_name}</strong>
${school.local_authority ? `<div style="font-size: 12px; color: #666; margin-bottom: 4px;">${school.local_authority}</div>` : ''} // Phase label
${school.school_type ? `<div style="font-size: 12px; color: #666; margin-bottom: 8px;">${school.school_type}</div>` : ''} const rawPhase = (school.phase ?? '').toLowerCase();
<a href="${schoolUrl(school.urn, school.school_name)}" style="display: inline-block; margin-top: 8px; padding: 6px 12px; background: #e07256; color: white; text-decoration: none; border-radius: 4px; font-size: 12px;">View Details</a> const phaseLabel =
</div> rawPhase.includes('secondary') ? 'Secondary' :
`; rawPhase === 'all-through' ? 'All-through' :
rawPhase.includes('primary') ? 'Primary' :
isSecondary ? 'Secondary' : 'Primary';
// Distance string
const distanceStr =
school.distance != null ? ` · ${school.distance.toFixed(1)} mi` : '';
// Headline metric
let metricHtml = '';
if (isSecondary) {
const score = school.attainment_8_score!;
const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null;
let deltaLine = '';
if (laAvg != null) {
const diff = Math.round((score - laAvg) * 10) / 10;
const sign = diff >= 0 ? '+' : '';
// Att8 scores range 090 in 0.1 increments; ±0.5 is meaningful here
// vs primary RWM % where ±2 pts is the threshold
const colour = diff >= 0.5 ? '#2d7d7d' : diff <= -0.5 ? '#e07256' : '#8a847a';
const laName = escapeHtml(school.local_authority ?? 'LA');
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
}
metricHtml = `<div style="margin-bottom:4px">
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${score.toFixed(1)}</span>
<span style="font-size:11px;color:#8a847a;margin-left:4px">Attainment 8</span>
${deltaLine}
</div>`;
} else if (school.rwm_expected_pct != null) {
const rwm = school.rwm_expected_pct;
let deltaLine = '';
if (nationalAvgRwm != null) {
const diff = Math.round(rwm - nationalAvgRwm);
const colour = diff >= 2 ? '#2d7d7d' : diff <= -2 ? '#e07256' : '#8a847a';
const text =
diff >= 2 ? `+${diff} pts vs national` :
diff <= -2 ? `${diff} pts vs national` :
'≈ national avg';
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
}
metricHtml = `<div style="margin-bottom:4px">
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${rwm}%</span>
<span style="font-size:11px;color:#8a847a;margin-left:4px">Reading, Writing &amp; Maths</span>
${deltaLine}
</div>`;
}
const slug = schoolUrl(school.urn, school.school_name);
const popupContent = `<div style="font-family:system-ui,sans-serif;min-width:240px;max-width:280px;padding:0">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px">
<strong style="font-size:13px;color:#1a1612;line-height:1.3">${escapeHtml(school.school_name)}</strong>
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap;flex-shrink:0;${badge.style}">${badge.label}</span>
</div>
<div style="font-size:11px;color:#8a847a;margin-bottom:8px">
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
</div>
${metricHtml}
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:#2d7d7d;color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
</div>`;
marker.bindPopup(popupContent); marker.bindPopup(popupContent);
@@ -114,7 +207,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
return () => { return () => {
// Don't destroy map on every update, just clean markers // Don't destroy map on every update, just clean markers
}; };
}, [schools, center, zoom, referencePoint, onMarkerClick]); }, [schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages]);
// Cleanup map on unmount // Cleanup map on unmount
useEffect(() => { useEffect(() => {
+5 -1
View File
@@ -27,9 +27,11 @@ interface SchoolMapProps {
zoom?: number; zoom?: number;
referencePoint?: [number, number]; referencePoint?: [number, number];
onMarkerClick?: (school: School) => void; onMarkerClick?: (school: School) => void;
nationalAvgRwm?: number | null;
laAverages?: Record<string, number | null>;
} }
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick }: SchoolMapProps) { export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
@@ -89,6 +91,8 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
zoom={zoom} zoom={zoom}
referencePoint={referencePoint} referencePoint={referencePoint}
onMarkerClick={onMarkerClick} onMarkerClick={onMarkerClick}
nationalAvgRwm={nationalAvgRwm}
laAverages={laAverages}
/> />
</div> </div>
); );
@@ -199,6 +199,16 @@
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; } .ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); } .ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
/* ofsted14 already defined above; these cover the two new framework states */
.ofstedRc { background: #5a3a6e; color: #fff; }
.ofstedPending { background: #e0e0e0; color: #666; }
/* ── vs-national delta line (under RWM metric) ──────────────────────────── */
.vsNational { font-size: 0.7rem; color: var(--accent-teal, #2d7d7d); font-weight: 600; }
.vsNationalNeg { font-size: 0.7rem; color: var(--accent-coral, #e07256); font-weight: 600; }
.vsNationalFlat { font-size: 0.7rem; color: var(--text-muted, #8a847a); }
/* ── Mobile ──────────────────────────────────────────── */ /* ── Mobile ──────────────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 640px) {
.row { .row {
+64 -73
View File
@@ -2,30 +2,23 @@
* SchoolRow Component * SchoolRow Component
* Four-line row for primary school search results * Four-line row for primary school search results
* *
* Line 1: School name · Ofsted badge * Line 1: School name · Ofsted badge (framework-aware)
* Line 2: School type · Age range · Denomination · Gender * Line 2: School type · Age range · Denomination · Gender
* Line 3: R,W&M % · Progress score · Pupil count * Line 3: Reading, Writing & Maths % · trend arrow · vs-national delta · Pupils
* Line 4: Local authority · Distance * Line 4: Local authority · Distance
*/ */
import type { School } from '@/lib/types'; import type { School } from '@/lib/types';
import { formatPercentage, formatProgress, calculateTrend, getPhaseStyle, schoolUrl } from '@/lib/utils'; import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge } from '@/lib/utils';
import { progressBand } from '@/lib/metrics';
import styles from './SchoolRow.module.css'; import styles from './SchoolRow.module.css';
const OFSTED_LABELS: Record<number, string> = {
1: 'Outstanding',
2: 'Good',
3: 'Req. Improvement',
4: 'Inadequate',
};
interface SchoolRowProps { interface SchoolRowProps {
school: School; school: School;
isLocationSearch?: boolean; isLocationSearch?: boolean;
isInCompare?: boolean; isInCompare?: boolean;
onAddToCompare?: (school: School) => void; onAddToCompare?: (school: School) => void;
onRemoveFromCompare?: (urn: number) => void; onRemoveFromCompare?: (urn: number) => void;
nationalAvgRwm?: number | null;
} }
export function SchoolRow({ export function SchoolRow({
@@ -34,13 +27,22 @@ export function SchoolRow({
isInCompare = false, isInCompare = false,
onAddToCompare, onAddToCompare,
onRemoveFromCompare, onRemoveFromCompare,
nationalAvgRwm,
}: SchoolRowProps) { }: SchoolRowProps) {
const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct); const trend = calculateTrend(school.rwm_expected_pct, school.prev_rwm_expected_pct);
const phase = getPhaseStyle(school.phase); const phase = getPhaseStyle(school.phase);
const ofstedBadge = buildOfstedListBadge(school);
// Use reading progress as representative; fall back to writing, then maths const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
const progressScore = const showDenomination =
school.reading_progress ?? school.writing_progress ?? school.maths_progress ?? null; school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
// vs-national delta
const rwmDelta =
school.rwm_expected_pct != null && nationalAvgRwm != null
? Math.round(school.rwm_expected_pct - nationalAvgRwm)
: null;
const handleCompareClick = () => { const handleCompareClick = () => {
if (isInCompare) { if (isInCompare) {
@@ -50,11 +52,6 @@ export function SchoolRow({
} }
}; };
const showGender = school.gender && school.gender.toLowerCase() !== 'mixed';
const showDenomination =
school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
return ( return (
<div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}> <div className={`${styles.row} ${phase.key ? styles[`phase${phase.key}`] : ''} ${isInCompare ? styles.rowInCompare : ''}`}>
{/* Left: four content lines */} {/* Left: four content lines */}
@@ -65,16 +62,9 @@ export function SchoolRow({
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}> <a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
{school.school_name} {school.school_name}
</a> </a>
{school.ofsted_grade && ( <span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}> {ofstedBadge.label}
{OFSTED_LABELS[school.ofsted_grade]} </span>
{school.ofsted_date && (
<span className={styles.ofstedDate}>
{' '}({new Date(school.ofsted_date).getFullYear()})
</span>
)}
</span>
)}
</div> </div>
{/* Line 2: Context tags */} {/* Line 2: Context tags */}
@@ -92,50 +82,51 @@ export function SchoolRow({
{/* Line 3: Key stats */} {/* Line 3: Key stats */}
<div className={styles.line3}> <div className={styles.line3}>
{school.rwm_expected_pct != null ? ( <span className={styles.stat}>
<span className={styles.stat}> <strong className={styles.statValue}>
<strong className={styles.statValue}> {school.rwm_expected_pct != null ? formatPercentage(school.rwm_expected_pct, 0) : '—'}
{formatPercentage(school.rwm_expected_pct, 0)} </strong>
</strong> {school.prev_rwm_expected_pct != null && (
{school.prev_rwm_expected_pct != null && ( <span
<span className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`}
className={`${styles.trend} ${styles[`trend${trend.charAt(0).toUpperCase() + trend.slice(1)}`]}`} title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`}
title={`Previous year: ${formatPercentage(school.prev_rwm_expected_pct)}`} >
> {trend === 'up' && (
{trend === 'up' && ( <svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend up">
<svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend up"> <path d="M8 3L14 10H2L8 3Z" fill="currentColor" />
<path d="M8 3L14 10H2L8 3Z" fill="currentColor" /> </svg>
</svg> )}
)} {trend === 'down' && (
{trend === 'down' && ( <svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend down">
<svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend down"> <path d="M8 13L2 6H14L8 13Z" fill="currentColor" />
<path d="M8 13L2 6H14L8 13Z" fill="currentColor" /> </svg>
</svg> )}
)} {trend === 'stable' && (
{trend === 'stable' && ( <svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend stable">
<svg viewBox="0 0 16 16" fill="none" width="9" height="9" aria-label="Trend stable"> <rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor" />
<rect x="2" y="7" width="12" height="2" rx="1" fill="currentColor" /> </svg>
</svg> )}
)}
</span>
)}
<span className={styles.statLabel}>R, W &amp; M</span>
</span>
) : (
<span className={styles.stat}>
<strong className={styles.statValue}></strong>
<span className={styles.statLabel}>R, W &amp; M</span>
</span>
)}
{progressScore != null && (
<span className={styles.stat}>
<strong className={styles.statValue}>{formatProgress(progressScore)}</strong>
<span className={styles.statLabel}>
progress · {progressBand(progressScore)}
</span> </span>
</span> )}
)} <span className={styles.statLabel}>Reading, Writing &amp; Maths</span>
{rwmDelta != null && (
<span
className={
rwmDelta >= 2
? styles.vsNational
: rwmDelta <= -2
? styles.vsNationalNeg
: styles.vsNationalFlat
}
>
{rwmDelta >= 2
? `+${rwmDelta} pts vs national`
: rwmDelta <= -2
? `${rwmDelta} pts vs national`
: '≈ national avg'}
</span>
)}
</span>
{school.total_pupils != null && ( {school.total_pupils != null && (
<span className={styles.stat}> <span className={styles.stat}>
@@ -201,6 +201,10 @@
.ofsted3 { background: var(--accent-gold-bg); color: #b8920e; } .ofsted3 { background: var(--accent-gold-bg); color: #b8920e; }
.ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); } .ofsted4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
/* ── Ofsted badge variants ──────────────────────────────────────────────── */
.ofstedRc { background: #5a3a6e; color: #fff; }
.ofstedPending { background: #e0e0e0; color: #666; }
/* ── Right actions column ────────────────────────────── */ /* ── Right actions column ────────────────────────────── */
.rowActions { .rowActions {
display: flex; display: flex;
+7 -29
View File
@@ -4,23 +4,16 @@
* *
* Line 1: School name · Ofsted badge * Line 1: School name · Ofsted badge
* Line 2: School type · Age range · Gender · Sixth form · Admissions tag * Line 2: School type · Age range · Gender · Sixth form · Admissions tag
* Line 3: Attainment 8 (large) · ±LA avg delta · Eng & Maths 4+ · Pupils * Line 3: Attainment 8 (large) · ±LA avg delta · Pupils
* Line 4: LA name · distance * Line 4: LA name · distance
*/ */
'use client'; 'use client';
import type { School } from '@/lib/types'; import type { School } from '@/lib/types';
import { getPhaseStyle, schoolUrl } from '@/lib/utils'; import { buildOfstedListBadge, getPhaseStyle, schoolUrl } from '@/lib/utils';
import styles from './SecondarySchoolRow.module.css'; import styles from './SecondarySchoolRow.module.css';
const OFSTED_LABELS: Record<number, string> = {
1: 'Outstanding',
2: 'Good',
3: 'Req. Improvement',
4: 'Inadequate',
};
function detectAdmissionsTag(school: School): string | null { function detectAdmissionsTag(school: School): string | null {
const policy = school.admissions_policy?.toLowerCase() ?? ''; const policy = school.admissions_policy?.toLowerCase() ?? '';
if (policy.includes('selective')) return 'Selective'; if (policy.includes('selective')) return 'Selective';
@@ -58,8 +51,9 @@ export function SecondarySchoolRow({
} }
}; };
const ofstedBadge = buildOfstedListBadge(school);
const phase = getPhaseStyle(school.phase); const phase = getPhaseStyle(school.phase);
const att8 = school.attainment_8_score ?? null; const att8 = school.attainment_8_score;
const laDelta = const laDelta =
att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null; att8 != null && laAvgAttainment8 != null ? att8 - laAvgAttainment8 : null;
@@ -77,16 +71,9 @@ export function SecondarySchoolRow({
<a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}> <a href={schoolUrl(school.urn, school.school_name)} className={styles.schoolName}>
{school.school_name} {school.school_name}
</a> </a>
{school.ofsted_grade && ( <span className={`${styles.ofstedBadge} ${styles[ofstedBadge.cssClass]}`}>
<span className={`${styles.ofstedBadge} ${styles[`ofsted${school.ofsted_grade}`]}`}> {ofstedBadge.label}
{OFSTED_LABELS[school.ofsted_grade]} </span>
{school.ofsted_date && (
<span className={styles.ofstedDate}>
{' '}({new Date(school.ofsted_date).getFullYear()})
</span>
)}
</span>
)}
</div> </div>
{/* Line 2: Context tags */} {/* Line 2: Context tags */}
@@ -126,15 +113,6 @@ export function SecondarySchoolRow({
</span> </span>
)} )}
{school.english_maths_standard_pass_pct != null && (
<span className={styles.stat}>
<strong className={styles.statValue}>
{school.english_maths_standard_pass_pct.toFixed(0)}%
</strong>
<span className={styles.statLabel}>Eng &amp; Maths 4+</span>
</span>
)}
{school.total_pupils != null && ( {school.total_pupils != null && (
<span className={styles.stat}> <span className={styles.stat}>
<strong className={styles.statValue}> <strong className={styles.statValue}>
+21
View File
@@ -15,6 +15,7 @@ import type {
RankingsParams, RankingsParams,
APIError, APIError,
LAaveragesResponse, LAaveragesResponse,
NationalAverages,
} from './types'; } from './types';
// ============================================================================ // ============================================================================
@@ -261,6 +262,26 @@ export async function fetchLAaverages(
return handleResponse<LAaveragesResponse>(response); return handleResponse<LAaveragesResponse>(response);
} }
/**
* Fetch official DfE KS2 national averages (primary) and computed KS4 secondary averages.
* Returns latest year snapshot plus per-year history for chart reference lines.
*/
export async function fetchNationalAverages(
options: RequestInit = {}
): Promise<NationalAverages> {
const url = `${API_BASE_URL}/national-averages`;
const response = await fetch(url, {
...options,
next: {
revalidate: 3600,
...options.next,
},
});
return handleResponse<NationalAverages>(response);
}
/** /**
* Fetch database statistics and info * Fetch database statistics and info
*/ */
+1
View File
@@ -67,6 +67,7 @@ export interface School {
// Ofsted (for list view — summary only) // Ofsted (for list view — summary only)
ofsted_grade?: 1 | 2 | 3 | 4 | null; ofsted_grade?: 1 | 2 | 3 | 4 | null;
ofsted_date?: string | null; ofsted_date?: string | null;
ofsted_framework?: string | null;
} }
// ============================================================================ // ============================================================================
+46
View File
@@ -570,3 +570,49 @@ export function buildSchoolSummary(
return parts.join(', ') + '.'; return parts.join(', ') + '.';
} }
// ─── List-level Ofsted badge ──────────────────────────────────────────────────
export interface OfstedListBadge {
/** Display text for the badge (e.g. "Outstanding · 2023", "Report Card · 2025") */
label: string;
/** CSS module class key — one of: ofsted1 | ofsted2 | ofsted3 | ofsted4 | ofstedRc | ofstedPending */
cssClass: string;
}
/**
* Build the Ofsted badge for a school card in the list/map view.
* Three states:
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed
* - ReportCard school (ofsted_framework === 'ReportCard'): "Report Card · YYYY" in purple
* - No inspection: "Not yet inspected" in grey
*/
export function buildOfstedListBadge(school: {
ofsted_grade?: 1 | 2 | 3 | 4 | null;
ofsted_date?: string | null;
ofsted_framework?: string | null;
}): OfstedListBadge {
const year = school.ofsted_date
? new Date(school.ofsted_date).getFullYear()
: null;
const yearStr = year ? ` · ${year}` : '';
if (school.ofsted_grade) {
const labels: Record<number, string> = {
1: 'Outstanding',
2: 'Good',
3: 'Req. Improvement',
4: 'Inadequate',
};
return {
label: `${labels[school.ofsted_grade]}${yearStr}`,
cssClass: `ofsted${school.ofsted_grade}`,
};
}
if (school.ofsted_framework === 'ReportCard') {
return { label: `Report Card${yearStr}`, cssClass: 'ofstedRc' };
}
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
}