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>
This commit is contained in:
Tudor Sitaru
2026-04-13 14:17:34 +01:00
parent ad2fe5bbef
commit 2c13b21360
3 changed files with 74 additions and 19 deletions
+71 -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,7 @@ 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}
/> />
</div> </div>
<div className={styles.compactList}> <div className={styles.compactList}>
@@ -264,6 +273,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 +288,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 +317,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 +351,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 +386,47 @@ 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' } :
{ 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}>
+2 -1
View File
@@ -27,9 +27,10 @@ interface SchoolMapProps {
zoom?: number; zoom?: number;
referencePoint?: [number, number]; referencePoint?: [number, number];
onMarkerClick?: (school: School) => void; onMarkerClick?: (school: School) => void;
nationalAvgRwm?: number | null;
} }
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick }: SchoolMapProps) { export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm }: SchoolMapProps) {
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
+1 -1
View File
@@ -207,7 +207,7 @@
/* ── vs-national delta line (under RWM metric) ──────────────────────────── */ /* ── vs-national delta line (under RWM metric) ──────────────────────────── */
.vsNational { font-size: 0.7rem; color: var(--accent-teal, #2d7d7d); font-weight: 600; } .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; } .vsNationalNeg { font-size: 0.7rem; color: var(--accent-coral, #e07256); font-weight: 600; }
.vsNationalFlat{ font-size: 0.7rem; color: var(--text-muted, #8a847a); } .vsNationalFlat { font-size: 0.7rem; color: var(--text-muted, #8a847a); }
/* ── Mobile ──────────────────────────────────────────── */ /* ── Mobile ──────────────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 640px) {