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:
@@ -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}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user