Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bf2e8f262 | |||
| 8ce34b3ecc | |||
| 9c50c49e1f | |||
| 177571f411 | |||
| 51310160a8 | |||
| 2c13b21360 | |||
| ad2fe5bbef | |||
| 58f8eae997 | |||
| 44fdcfa18b | |||
| b1e025d468 | |||
| 9ebb421307 | |||
| 8a6758b591 | |||
| 6d02d366ce |
+1
-1
@@ -683,7 +683,7 @@ async def get_national_averages(request: Request):
|
|||||||
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
||||||
"reading_progress", "writing_progress", "maths_progress",
|
"reading_progress", "writing_progress", "maths_progress",
|
||||||
"overall_absence_pct", "persistent_absence_pct",
|
"overall_absence_pct", "persistent_absence_pct",
|
||||||
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct",
|
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", "eal_pct",
|
||||||
]
|
]
|
||||||
ks4_metrics = [
|
ks4_metrics = [
|
||||||
"attainment_8_score", "progress_8_score",
|
"attainment_8_score", "progress_8_score",
|
||||||
|
|||||||
+12
-3
@@ -130,9 +130,9 @@ _MAIN_QUERY = text("""
|
|||||||
s.total_pupils AS gias_total_pupils,
|
s.total_pupils AS gias_total_pupils,
|
||||||
s.headteacher_name,
|
s.headteacher_name,
|
||||||
s.website,
|
s.website,
|
||||||
s.ofsted_grade,
|
foi.ofsted_grade,
|
||||||
s.ofsted_date,
|
foi.ofsted_date,
|
||||||
s.ofsted_framework,
|
foi.ofsted_framework,
|
||||||
l.local_authority_name AS local_authority,
|
l.local_authority_name AS local_authority,
|
||||||
l.local_authority_code,
|
l.local_authority_code,
|
||||||
l.address_line1 AS address1,
|
l.address_line1 AS address1,
|
||||||
@@ -201,6 +201,15 @@ _MAIN_QUERY = text("""
|
|||||||
FROM marts.dim_school s
|
FROM marts.dim_school s
|
||||||
JOIN marts.dim_location l ON s.urn = l.urn
|
JOIN marts.dim_location l ON s.urn = l.urn
|
||||||
LEFT JOIN marts.fact_performance p ON s.urn = p.urn
|
LEFT JOIN marts.fact_performance p ON s.urn = p.urn
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DISTINCT ON (urn)
|
||||||
|
urn,
|
||||||
|
overall_effectiveness AS ofsted_grade,
|
||||||
|
inspection_date AS ofsted_date,
|
||||||
|
framework AS ofsted_framework
|
||||||
|
FROM marts.fact_ofsted_inspection
|
||||||
|
ORDER BY urn, inspection_date DESC NULLS LAST
|
||||||
|
) foi ON s.urn = foi.urn
|
||||||
ORDER BY s.school_name, p.year
|
ORDER BY s.school_name, p.year
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|||||||
@@ -547,6 +547,7 @@ SCHOOL_COLUMNS = [
|
|||||||
"admissions_policy",
|
"admissions_policy",
|
||||||
"ofsted_grade",
|
"ofsted_grade",
|
||||||
"ofsted_date",
|
"ofsted_date",
|
||||||
|
"ofsted_framework",
|
||||||
"latitude",
|
"latitude",
|
||||||
"longitude",
|
"longitude",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 0–90 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 & 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(() => {
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/* SatsChart — Cascade bar chart for KS2 SATs results */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Individual subject column ── */
|
||||||
|
.subjectChart {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subjectName {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartArea {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gridlines ── */
|
||||||
|
.gridlines {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridline {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── National average marker ── */
|
||||||
|
.natLine {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: calc(100% - 20px);
|
||||||
|
width: 1.5px;
|
||||||
|
background: rgba(224, 114, 86, 0.25); /* --accent-coral at 25% */
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.natPill {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 3;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bar rows ── */
|
||||||
|
.barGroup {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barExpected {
|
||||||
|
background: var(--accent-teal-light, #3a9e9e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barExceeding {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barLabel {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barLabelSuffix {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ruler ── */
|
||||||
|
.ruler {
|
||||||
|
position: relative;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulerTick {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulerLabel {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anchor first/last labels to edges */
|
||||||
|
.rulerLabelFirst {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulerLabelLast {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legend ── */
|
||||||
|
.legend {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendSwatch {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import styles from './SatsChart.module.css';
|
||||||
|
|
||||||
|
interface SubjectData {
|
||||||
|
name: string;
|
||||||
|
expectedPct: number | null;
|
||||||
|
exceedingPct: number | null;
|
||||||
|
nationalExpectedPct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SatsChartProps {
|
||||||
|
subjects: SubjectData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULER_TICKS = [0, 25, 50, 75, 100];
|
||||||
|
const GRIDLINE_POSITIONS = [25, 50, 75];
|
||||||
|
|
||||||
|
function SubjectColumn({ subject }: { subject: SubjectData }) {
|
||||||
|
const expectedRef = useRef<HTMLDivElement>(null);
|
||||||
|
const exceedingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
|
||||||
|
|
||||||
|
// Animate bars on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const bars = [expectedRef.current, exceedingRef.current];
|
||||||
|
bars.forEach((bar) => {
|
||||||
|
if (!bar) return;
|
||||||
|
const target = bar.dataset.width;
|
||||||
|
bar.style.width = '0%';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
bar.style.width = `${target}%`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [expectedPct, exceedingPct]);
|
||||||
|
|
||||||
|
if (expectedPct == null && exceedingPct == null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.subjectChart}>
|
||||||
|
<div className={styles.subjectName}>{name}</div>
|
||||||
|
<div className={styles.chartArea}>
|
||||||
|
{/* Gridlines */}
|
||||||
|
<div className={styles.gridlines}>
|
||||||
|
{GRIDLINE_POSITIONS.map((pct) => (
|
||||||
|
<div key={pct} className={styles.gridline} style={{ left: `${pct}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* National average marker */}
|
||||||
|
{nationalExpectedPct != null && (
|
||||||
|
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
|
||||||
|
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bars */}
|
||||||
|
<div className={styles.barGroup}>
|
||||||
|
{expectedPct != null && (
|
||||||
|
<div className={styles.barRow}>
|
||||||
|
<div
|
||||||
|
ref={expectedRef}
|
||||||
|
className={`${styles.bar} ${styles.barExpected}`}
|
||||||
|
data-width={expectedPct}
|
||||||
|
/>
|
||||||
|
<div className={styles.barLabel}>
|
||||||
|
{expectedPct.toFixed(0)}% <span className={styles.barLabelSuffix}>expected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exceedingPct != null && (
|
||||||
|
<div className={styles.barRow}>
|
||||||
|
<div
|
||||||
|
ref={exceedingRef}
|
||||||
|
className={`${styles.bar} ${styles.barExceeding}`}
|
||||||
|
data-width={exceedingPct}
|
||||||
|
/>
|
||||||
|
<div className={styles.barLabel}>
|
||||||
|
{exceedingPct.toFixed(0)}% <span className={styles.barLabelSuffix}>exceeding</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ruler */}
|
||||||
|
<div className={styles.ruler}>
|
||||||
|
{RULER_TICKS.map((pct, i) => (
|
||||||
|
<span key={pct}>
|
||||||
|
<div className={styles.rulerTick} style={{ left: `${pct}%` }} />
|
||||||
|
<div
|
||||||
|
className={`${styles.rulerLabel} ${i === 0 ? styles.rulerLabelFirst : ''} ${i === RULER_TICKS.length - 1 ? styles.rulerLabelLast : ''}`}
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
>
|
||||||
|
{pct}%
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SatsChart({ subjects }: SatsChartProps) {
|
||||||
|
const visibleSubjects = subjects.filter(
|
||||||
|
(s) => s.expectedPct != null || s.exceedingPct != null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visibleSubjects.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.container}>
|
||||||
|
{visibleSubjects.map((subject) => (
|
||||||
|
<SubjectColumn key={subject.name} subject={subject} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
|
||||||
|
Expected standard
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
|
||||||
|
Exceeding / high score
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
|
||||||
|
National average
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -894,3 +894,105 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Progress scores row (below SatsChart) ── */
|
||||||
|
.progressScoresRow {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressScoresGrid {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressScoreItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressScoreLabel {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressScoreValue {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Admissions progress bar ── */
|
||||||
|
.admissionsBarWrap {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsBarLabel {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsBarLabel strong {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsBarTrack {
|
||||||
|
height: 20px;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsBarFill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsBarOversubscribed {
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsBarUndersubscribed {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── History accordion ── */
|
||||||
|
.historyDisclosure {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyToggle {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyToggle::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyToggle::before {
|
||||||
|
content: '▸';
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyDisclosure[open] > .historyToggle::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
buildOfstedHeroChip,
|
buildOfstedHeroChip,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
import SatsChart from './SatsChart';
|
||||||
import styles from './SchoolDetailView.module.css';
|
import styles from './SchoolDetailView.module.css';
|
||||||
|
|
||||||
const OFSTED_LABELS: Record<number, string> = {
|
const OFSTED_LABELS: Record<number, string> = {
|
||||||
@@ -561,127 +562,61 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.metricGroupsGrid} style={{ marginTop: '1rem' }}>
|
<SatsChart
|
||||||
<div className={styles.metricGroup}>
|
subjects={[
|
||||||
<h3 className={styles.metricGroupTitle}>Reading</h3>
|
{
|
||||||
<div className={styles.metricTable}>
|
name: 'Reading',
|
||||||
{latestResults.reading_expected_pct !== null && (
|
expectedPct: latestResults.reading_expected_pct,
|
||||||
<div className={styles.metricRow}>
|
exceedingPct: latestResults.reading_high_pct,
|
||||||
<span className={styles.metricName}>Expected level</span>
|
nationalExpectedPct: primaryAvg.reading_expected_pct,
|
||||||
<span className={styles.metricValue}>
|
},
|
||||||
{formatPercentage(latestResults.reading_expected_pct)}
|
{
|
||||||
{primaryAvg.reading_expected_pct != null && (
|
name: 'Writing',
|
||||||
<DeltaChip value={latestResults.reading_expected_pct} baseline={primaryAvg.reading_expected_pct} unit="pts" size="sm" />
|
expectedPct: latestResults.writing_expected_pct,
|
||||||
)}
|
exceedingPct: latestResults.writing_high_pct,
|
||||||
</span>
|
nationalExpectedPct: primaryAvg.writing_expected_pct,
|
||||||
</div>
|
},
|
||||||
)}
|
{
|
||||||
{latestResults.reading_high_pct !== null && (
|
name: 'Maths',
|
||||||
<div className={styles.metricRow}>
|
expectedPct: latestResults.maths_expected_pct,
|
||||||
<span className={styles.metricName}>Exceeding</span>
|
exceedingPct: latestResults.maths_high_pct,
|
||||||
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_high_pct)}</span>
|
nationalExpectedPct: primaryAvg.maths_expected_pct,
|
||||||
</div>
|
},
|
||||||
)}
|
]}
|
||||||
{latestResults.reading_progress !== null && (
|
/>
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>
|
{/* Progress scores row */}
|
||||||
Progress score
|
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
||||||
<MetricTooltip metricKey="reading_progress" />
|
<div className={styles.progressScoresRow}>
|
||||||
</span>
|
<h3 className={styles.subSectionTitle}>Progress Scores</h3>
|
||||||
<span className={`${styles.metricValue} ${progressClass(latestResults.reading_progress)}`}>
|
<div className={styles.progressScoresGrid}>
|
||||||
|
{latestResults.reading_progress != null && (
|
||||||
|
<div className={styles.progressScoreItem}>
|
||||||
|
<span className={styles.progressScoreLabel}>Reading</span>
|
||||||
|
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.reading_progress)}`}>
|
||||||
{formatProgress(latestResults.reading_progress)}
|
{formatProgress(latestResults.reading_progress)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults.reading_avg_score !== null && (
|
{latestResults.writing_progress != null && (
|
||||||
<div className={styles.metricRow}>
|
<div className={styles.progressScoreItem}>
|
||||||
<span className={styles.metricName}>
|
<span className={styles.progressScoreLabel}>Writing</span>
|
||||||
Average score
|
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.writing_progress)}`}>
|
||||||
<MetricTooltip metricKey="reading_avg_score" />
|
|
||||||
</span>
|
|
||||||
<span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.metricGroup}>
|
|
||||||
<h3 className={styles.metricGroupTitle}>Writing</h3>
|
|
||||||
<div className={styles.metricTable}>
|
|
||||||
{latestResults.writing_expected_pct !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>Expected level</span>
|
|
||||||
<span className={styles.metricValue}>
|
|
||||||
{formatPercentage(latestResults.writing_expected_pct)}
|
|
||||||
{primaryAvg.writing_expected_pct != null && (
|
|
||||||
<DeltaChip value={latestResults.writing_expected_pct} baseline={primaryAvg.writing_expected_pct} unit="pts" size="sm" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.writing_high_pct !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>Exceeding</span>
|
|
||||||
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_high_pct)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.writing_progress !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>
|
|
||||||
Progress score
|
|
||||||
<MetricTooltip metricKey="writing_progress" />
|
|
||||||
</span>
|
|
||||||
<span className={`${styles.metricValue} ${progressClass(latestResults.writing_progress)}`}>
|
|
||||||
{formatProgress(latestResults.writing_progress)}
|
{formatProgress(latestResults.writing_progress)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{latestResults.maths_progress != null && (
|
||||||
</div>
|
<div className={styles.progressScoreItem}>
|
||||||
|
<span className={styles.progressScoreLabel}>Maths</span>
|
||||||
<div className={styles.metricGroup}>
|
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.maths_progress)}`}>
|
||||||
<h3 className={styles.metricGroupTitle}>Maths</h3>
|
|
||||||
<div className={styles.metricTable}>
|
|
||||||
{latestResults.maths_expected_pct !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>Expected level</span>
|
|
||||||
<span className={styles.metricValue}>
|
|
||||||
{formatPercentage(latestResults.maths_expected_pct)}
|
|
||||||
{primaryAvg.maths_expected_pct != null && (
|
|
||||||
<DeltaChip value={latestResults.maths_expected_pct} baseline={primaryAvg.maths_expected_pct} unit="pts" size="sm" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.maths_high_pct !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>Exceeding</span>
|
|
||||||
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_high_pct)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.maths_progress !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>
|
|
||||||
Progress score
|
|
||||||
<MetricTooltip metricKey="maths_progress" />
|
|
||||||
</span>
|
|
||||||
<span className={`${styles.metricValue} ${progressClass(latestResults.maths_progress)}`}>
|
|
||||||
{formatProgress(latestResults.maths_progress)}
|
{formatProgress(latestResults.maths_progress)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults.maths_avg_score !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>
|
|
||||||
Average score
|
|
||||||
<MetricTooltip metricKey="maths_avg_score" />
|
|
||||||
</span>
|
|
||||||
<span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
|
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
|
||||||
<p className={styles.progressNote}>
|
<p className={styles.progressNote}>
|
||||||
@@ -852,7 +787,20 @@ export function SchoolDetailView({
|
|||||||
{admissions && (
|
{admissions && (
|
||||||
<section id="admissions" className={styles.card}>
|
<section id="admissions" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({formatAcademicYear(admissions.year)})</h2>
|
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({formatAcademicYear(admissions.year)})</h2>
|
||||||
{admissions.oversubscribed != null && (
|
{admissions.first_preference_applications != null && admissions.published_admission_number != null && (
|
||||||
|
<div className={styles.admissionsBarWrap}>
|
||||||
|
<div className={styles.admissionsBarLabel}>
|
||||||
|
<strong>{admissions.published_admission_number}</strong> places for <strong>{admissions.first_preference_applications}</strong> first-choice applications
|
||||||
|
</div>
|
||||||
|
<div className={styles.admissionsBarTrack}>
|
||||||
|
<div
|
||||||
|
className={`${styles.admissionsBarFill} ${admissions.oversubscribed ? styles.admissionsBarOversubscribed : styles.admissionsBarUndersubscribed}`}
|
||||||
|
style={{ width: `${Math.min(100, (admissions.published_admission_number / admissions.first_preference_applications) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{admissions.first_preference_applications == null && admissions.oversubscribed != null && (
|
||||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||||
{admissions.oversubscribed
|
{admissions.oversubscribed
|
||||||
? '⚠ Oversubscribed'
|
? '⚠ Oversubscribed'
|
||||||
@@ -905,7 +853,15 @@ export function SchoolDetailView({
|
|||||||
English as an additional language
|
English as an additional language
|
||||||
<MetricTooltip metricKey="eal_pct" />
|
<MetricTooltip metricKey="eal_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.eal_pct)}</div>
|
<div className={styles.metricValue}>
|
||||||
|
{formatPercentage(latestResults.eal_pct)}
|
||||||
|
{primaryAvg.eal_pct != null && (
|
||||||
|
<DeltaChip value={latestResults.eal_pct} baseline={primaryAvg.eal_pct} unit="pts" size="sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{primaryAvg.eal_pct != null && (
|
||||||
|
<div className={styles.metricHint}>National avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults?.sen_support_pct != null && (
|
{latestResults?.sen_support_pct != null && (
|
||||||
@@ -1034,8 +990,8 @@ export function SchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{yearlyData.length > 1 && (
|
{yearlyData.length > 1 && (
|
||||||
<>
|
<details className={styles.historyDisclosure}>
|
||||||
<p className={styles.historicalSubtitle}>Detailed year-by-year figures</p>
|
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||||
<div className={styles.tableWrapper}>
|
<div className={styles.tableWrapper}>
|
||||||
<table className={styles.dataTable}>
|
<table className={styles.dataTable}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -1084,7 +1040,7 @@ export function SchoolDetailView({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</details>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────── */
|
||||||
|
/* ofsted1–4 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 {
|
||||||
|
|||||||
@@ -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 & M</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className={styles.stat}>
|
|
||||||
<strong className={styles.statValue}>—</strong>
|
|
||||||
<span className={styles.statLabel}>R, W & 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 & 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;
|
||||||
|
|||||||
@@ -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 & 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}>
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export function formatPercentage(value: number | null | undefined, decimals: num
|
|||||||
return `${value.toFixed(decimals)}%`;
|
return `${value.toFixed(decimals)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatWithSuppression(value: number | null | undefined): { display: string; suppressed: boolean } {
|
||||||
|
if (value == null) return { display: '—', suppressed: true };
|
||||||
|
return { display: formatPercentage(value), suppressed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPPRESSED_TOOLTIP = 'Data not available — may be suppressed to protect small cohorts.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a progress score (can be negative)
|
* Format a progress score (can be negative)
|
||||||
*/
|
*/
|
||||||
@@ -570,3 +577,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' };
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ with schools as (
|
|||||||
|
|
||||||
{% set ofsted_relation = adapter.get_relation(
|
{% set ofsted_relation = adapter.get_relation(
|
||||||
database=target.database,
|
database=target.database,
|
||||||
schema=target.schema,
|
schema='intermediate',
|
||||||
identifier='int_ofsted_latest'
|
identifier='int_ofsted_latest'
|
||||||
) %}
|
) %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user