2026-02-02 20:34:35 +00:00
|
|
|
/**
|
|
|
|
|
* SchoolDetailView Component
|
|
|
|
|
* Displays comprehensive school information with performance charts
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
'use client';
|
|
|
|
|
|
2026-03-23 21:31:28 +00:00
|
|
|
import { useRouter } from 'next/navigation';
|
2026-02-02 20:34:35 +00:00
|
|
|
import { useComparison } from '@/hooks/useComparison';
|
|
|
|
|
import { PerformanceChart } from './PerformanceChart';
|
|
|
|
|
import { SchoolMap } from './SchoolMap';
|
feat(data): integrate 9 UK government data sources via Kestra
Adds a full data integration pipeline for enriching school profiles with
supplementary data from Ofsted, GIAS, EES, IDACI, and FBIT.
Backend:
- Bump SCHEMA_VERSION to 3; add 8 new DB tables (ofsted_inspections,
ofsted_parent_view, school_census, admissions, sen_detail, phonics,
school_deprivation, school_finance) plus GIAS columns on schools
- Expose all supplementary data via GET /api/schools/{urn}
- Enrich school list responses with ofsted_grade + ofsted_date
Integrator (new service):
- FastAPI HTTP microservice; Kestra calls POST /run/{source}
- 9 source modules: ofsted, gias, parent_view, census, admissions,
sen_detail, phonics, idaci, finance
- 9 Kestra flow YAMLs with scheduled triggers and 3× retry
Frontend:
- SchoolRow: colour-coded Ofsted badge (Outstanding/Good/RI/Inadequate)
- SchoolDetailView: 7 new sections — Ofsted sub-judgements, Parent View
survey bars, Admissions, Pupils & Inclusion / SEN, Phonics, Deprivation
Context, Finances
- types.ts: 8 new interfaces + extended School/SchoolDetailsResponse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:44:04 +00:00
|
|
|
import type {
|
|
|
|
|
School, SchoolResult, AbsenceData,
|
|
|
|
|
OfstedInspection, OfstedParentView, SchoolCensus,
|
|
|
|
|
SchoolAdmissions, SenDetail, Phonics,
|
|
|
|
|
SchoolDeprivation, SchoolFinance,
|
|
|
|
|
} from '@/lib/types';
|
2026-02-02 20:34:35 +00:00
|
|
|
import { formatPercentage, formatProgress, calculateTrend } from '@/lib/utils';
|
|
|
|
|
import styles from './SchoolDetailView.module.css';
|
|
|
|
|
|
feat(data): integrate 9 UK government data sources via Kestra
Adds a full data integration pipeline for enriching school profiles with
supplementary data from Ofsted, GIAS, EES, IDACI, and FBIT.
Backend:
- Bump SCHEMA_VERSION to 3; add 8 new DB tables (ofsted_inspections,
ofsted_parent_view, school_census, admissions, sen_detail, phonics,
school_deprivation, school_finance) plus GIAS columns on schools
- Expose all supplementary data via GET /api/schools/{urn}
- Enrich school list responses with ofsted_grade + ofsted_date
Integrator (new service):
- FastAPI HTTP microservice; Kestra calls POST /run/{source}
- 9 source modules: ofsted, gias, parent_view, census, admissions,
sen_detail, phonics, idaci, finance
- 9 Kestra flow YAMLs with scheduled triggers and 3× retry
Frontend:
- SchoolRow: colour-coded Ofsted badge (Outstanding/Good/RI/Inadequate)
- SchoolDetailView: 7 new sections — Ofsted sub-judgements, Parent View
survey bars, Admissions, Pupils & Inclusion / SEN, Phonics, Deprivation
Context, Finances
- types.ts: 8 new interfaces + extended School/SchoolDetailsResponse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:44:04 +00:00
|
|
|
const OFSTED_LABELS: Record<number, string> = {
|
|
|
|
|
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
interface SchoolDetailViewProps {
|
|
|
|
|
schoolInfo: School;
|
|
|
|
|
yearlyData: SchoolResult[];
|
|
|
|
|
absenceData: AbsenceData | null;
|
feat(data): integrate 9 UK government data sources via Kestra
Adds a full data integration pipeline for enriching school profiles with
supplementary data from Ofsted, GIAS, EES, IDACI, and FBIT.
Backend:
- Bump SCHEMA_VERSION to 3; add 8 new DB tables (ofsted_inspections,
ofsted_parent_view, school_census, admissions, sen_detail, phonics,
school_deprivation, school_finance) plus GIAS columns on schools
- Expose all supplementary data via GET /api/schools/{urn}
- Enrich school list responses with ofsted_grade + ofsted_date
Integrator (new service):
- FastAPI HTTP microservice; Kestra calls POST /run/{source}
- 9 source modules: ofsted, gias, parent_view, census, admissions,
sen_detail, phonics, idaci, finance
- 9 Kestra flow YAMLs with scheduled triggers and 3× retry
Frontend:
- SchoolRow: colour-coded Ofsted badge (Outstanding/Good/RI/Inadequate)
- SchoolDetailView: 7 new sections — Ofsted sub-judgements, Parent View
survey bars, Admissions, Pupils & Inclusion / SEN, Phonics, Deprivation
Context, Finances
- types.ts: 8 new interfaces + extended School/SchoolDetailsResponse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:44:04 +00:00
|
|
|
ofsted: OfstedInspection | null;
|
|
|
|
|
parentView: OfstedParentView | null;
|
|
|
|
|
census: SchoolCensus | null;
|
|
|
|
|
admissions: SchoolAdmissions | null;
|
|
|
|
|
senDetail: SenDetail | null;
|
|
|
|
|
phonics: Phonics | null;
|
|
|
|
|
deprivation: SchoolDeprivation | null;
|
|
|
|
|
finance: SchoolFinance | null;
|
2026-02-02 20:34:35 +00:00
|
|
|
}
|
|
|
|
|
|
feat(data): integrate 9 UK government data sources via Kestra
Adds a full data integration pipeline for enriching school profiles with
supplementary data from Ofsted, GIAS, EES, IDACI, and FBIT.
Backend:
- Bump SCHEMA_VERSION to 3; add 8 new DB tables (ofsted_inspections,
ofsted_parent_view, school_census, admissions, sen_detail, phonics,
school_deprivation, school_finance) plus GIAS columns on schools
- Expose all supplementary data via GET /api/schools/{urn}
- Enrich school list responses with ofsted_grade + ofsted_date
Integrator (new service):
- FastAPI HTTP microservice; Kestra calls POST /run/{source}
- 9 source modules: ofsted, gias, parent_view, census, admissions,
sen_detail, phonics, idaci, finance
- 9 Kestra flow YAMLs with scheduled triggers and 3× retry
Frontend:
- SchoolRow: colour-coded Ofsted badge (Outstanding/Good/RI/Inadequate)
- SchoolDetailView: 7 new sections — Ofsted sub-judgements, Parent View
survey bars, Admissions, Pupils & Inclusion / SEN, Phonics, Deprivation
Context, Finances
- types.ts: 8 new interfaces + extended School/SchoolDetailsResponse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:44:04 +00:00
|
|
|
export function SchoolDetailView({
|
|
|
|
|
schoolInfo, yearlyData, absenceData,
|
|
|
|
|
ofsted, parentView, census, admissions, senDetail, phonics, deprivation, finance,
|
|
|
|
|
}: SchoolDetailViewProps) {
|
2026-03-23 21:31:28 +00:00
|
|
|
const router = useRouter();
|
2026-02-02 20:34:35 +00:00
|
|
|
const { addSchool, removeSchool, isSelected } = useComparison();
|
|
|
|
|
const isInComparison = isSelected(schoolInfo.urn);
|
|
|
|
|
|
|
|
|
|
// Get latest results
|
|
|
|
|
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
|
|
|
|
|
|
|
|
|
// Handle add/remove from comparison
|
|
|
|
|
const handleComparisonToggle = () => {
|
|
|
|
|
if (isInComparison) {
|
|
|
|
|
removeSchool(schoolInfo.urn);
|
|
|
|
|
} else {
|
|
|
|
|
addSchool(schoolInfo);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={styles.container}>
|
2026-03-23 21:31:28 +00:00
|
|
|
{/* Back Navigation */}
|
|
|
|
|
<div className={styles.backNav}>
|
|
|
|
|
<button onClick={() => router.back()} className={styles.backButton}>← Back</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
{/* Header Section */}
|
|
|
|
|
<header className={styles.header}>
|
|
|
|
|
<div className={styles.headerContent}>
|
|
|
|
|
<div className={styles.titleSection}>
|
|
|
|
|
<h1 className={styles.schoolName}>{schoolInfo.school_name}</h1>
|
|
|
|
|
<div className={styles.meta}>
|
|
|
|
|
{schoolInfo.local_authority && (
|
|
|
|
|
<span className={styles.metaItem}>
|
2026-02-02 22:34:14 +00:00
|
|
|
{schoolInfo.local_authority}
|
2026-02-02 20:34:35 +00:00
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{schoolInfo.school_type && (
|
|
|
|
|
<span className={styles.metaItem}>
|
2026-02-02 22:34:14 +00:00
|
|
|
{schoolInfo.school_type}
|
2026-02-02 20:34:35 +00:00
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{schoolInfo.address && (
|
|
|
|
|
<p className={styles.address}>
|
|
|
|
|
{schoolInfo.address}
|
|
|
|
|
{schoolInfo.postcode && `, ${schoolInfo.postcode}`}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.actions}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleComparisonToggle}
|
|
|
|
|
className={isInComparison ? styles.btnRemove : styles.btnAdd}
|
|
|
|
|
>
|
|
|
|
|
{isInComparison ? '✓ In Comparison' : '+ Add to Compare'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{/* Latest Results Summary */}
|
|
|
|
|
{latestResults && (
|
|
|
|
|
<section className={styles.summary}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>
|
|
|
|
|
Latest Results ({latestResults.year})
|
|
|
|
|
</h2>
|
|
|
|
|
<div className={styles.metricsGrid}>
|
|
|
|
|
{latestResults.rwm_expected_pct !== null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>
|
|
|
|
|
RWM Expected Standard
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.metricValue}>
|
|
|
|
|
{formatPercentage(latestResults.rwm_expected_pct)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{latestResults.rwm_high_pct !== null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>
|
|
|
|
|
RWM Higher Standard
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.metricValue}>
|
|
|
|
|
{formatPercentage(latestResults.rwm_high_pct)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{latestResults.reading_progress !== null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>
|
|
|
|
|
Reading Progress
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.metricValue}>
|
|
|
|
|
{formatProgress(latestResults.reading_progress)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{latestResults.writing_progress !== null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>
|
|
|
|
|
Writing Progress
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.metricValue}>
|
|
|
|
|
{formatProgress(latestResults.writing_progress)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{latestResults.maths_progress !== null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>
|
|
|
|
|
Maths Progress
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.metricValue}>
|
|
|
|
|
{formatProgress(latestResults.maths_progress)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
</div>
|
2026-03-23 21:31:28 +00:00
|
|
|
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
|
|
|
|
|
<p className={styles.progressNote}>Progress scores: 0 = national average. Positive = above average, negative = below average.</p>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Map */}
|
|
|
|
|
{schoolInfo.latitude && schoolInfo.longitude && (
|
|
|
|
|
<section className={styles.mapSection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Location</h2>
|
|
|
|
|
<div className={styles.mapContainer}>
|
|
|
|
|
<SchoolMap
|
|
|
|
|
schools={[schoolInfo]}
|
|
|
|
|
center={[schoolInfo.latitude, schoolInfo.longitude]}
|
|
|
|
|
zoom={15}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-02 20:34:35 +00:00
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Performance Over Time */}
|
|
|
|
|
{yearlyData.length > 0 && (
|
|
|
|
|
<section className={styles.chartsSection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
|
|
|
|
<div className={styles.chartContainer}>
|
|
|
|
|
<PerformanceChart
|
|
|
|
|
data={yearlyData}
|
|
|
|
|
schoolName={schoolInfo.school_name}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Detailed Metrics */}
|
|
|
|
|
{latestResults && (
|
|
|
|
|
<section className={styles.detailedMetrics}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Detailed Metrics</h2>
|
2026-02-04 12:14:28 +00:00
|
|
|
<div className={styles.metricGroupsGrid}>
|
|
|
|
|
{/* Reading Metrics */}
|
|
|
|
|
<div className={styles.metricGroup}>
|
|
|
|
|
<h3 className={styles.metricGroupTitle}>Reading</h3>
|
|
|
|
|
<div className={styles.metricTable}>
|
|
|
|
|
{latestResults.reading_expected_pct !== null && (
|
|
|
|
|
<div className={styles.metricRow}>
|
|
|
|
|
<span className={styles.metricName}>Expected</span>
|
|
|
|
|
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_expected_pct)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{latestResults.reading_high_pct !== null && (
|
|
|
|
|
<div className={styles.metricRow}>
|
|
|
|
|
<span className={styles.metricName}>Higher</span>
|
|
|
|
|
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_high_pct)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{latestResults.reading_progress !== null && (
|
|
|
|
|
<div className={styles.metricRow}>
|
|
|
|
|
<span className={styles.metricName}>Progress</span>
|
|
|
|
|
<span className={styles.metricValue}>{formatProgress(latestResults.reading_progress)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{latestResults.reading_avg_score !== null && (
|
|
|
|
|
<div className={styles.metricRow}>
|
|
|
|
|
<span className={styles.metricName}>Avg Score</span>
|
|
|
|
|
<span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-02 20:34:35 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-04 12:14:28 +00:00
|
|
|
{/* Writing Metrics */}
|
|
|
|
|
<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</span>
|
|
|
|
|
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_expected_pct)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{latestResults.writing_high_pct !== null && (
|
|
|
|
|
<div className={styles.metricRow}>
|
|
|
|
|
<span className={styles.metricName}>Higher</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</span>
|
|
|
|
|
<span className={styles.metricValue}>{formatProgress(latestResults.writing_progress)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-02 20:34:35 +00:00
|
|
|
</div>
|
|
|
|
|
|
2026-02-04 12:14:28 +00:00
|
|
|
{/* Maths Metrics */}
|
|
|
|
|
<div className={styles.metricGroup}>
|
|
|
|
|
<h3 className={styles.metricGroupTitle}>Maths</h3>
|
|
|
|
|
<div className={styles.metricTable}>
|
|
|
|
|
{latestResults.maths_expected_pct !== null && (
|
|
|
|
|
<div className={styles.metricRow}>
|
|
|
|
|
<span className={styles.metricName}>Expected</span>
|
|
|
|
|
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_expected_pct)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{latestResults.maths_high_pct !== null && (
|
|
|
|
|
<div className={styles.metricRow}>
|
|
|
|
|
<span className={styles.metricName}>Higher</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</span>
|
|
|
|
|
<span className={styles.metricValue}>{formatProgress(latestResults.maths_progress)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{latestResults.maths_avg_score !== null && (
|
|
|
|
|
<div className={styles.metricRow}>
|
|
|
|
|
<span className={styles.metricName}>Avg Score</span>
|
|
|
|
|
<span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-02-02 20:34:35 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Absence Data */}
|
|
|
|
|
{absenceData && (
|
|
|
|
|
<section className={styles.absenceSection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Absence Data</h2>
|
|
|
|
|
<div className={styles.absenceGrid}>
|
|
|
|
|
{absenceData.overall_absence_rate !== null && (
|
|
|
|
|
<div className={styles.absenceCard}>
|
|
|
|
|
<div className={styles.absenceLabel}>Overall Absence Rate</div>
|
|
|
|
|
<div className={styles.absenceValue}>{formatPercentage(absenceData.overall_absence_rate)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{absenceData.persistent_absence_rate !== null && (
|
|
|
|
|
<div className={styles.absenceCard}>
|
|
|
|
|
<div className={styles.absenceLabel}>Persistent Absence</div>
|
|
|
|
|
<div className={styles.absenceValue}>{formatPercentage(absenceData.persistent_absence_rate)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* All Years Data Table */}
|
|
|
|
|
{yearlyData.length > 0 && (
|
|
|
|
|
<section className={styles.historySection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Historical Data</h2>
|
|
|
|
|
<div className={styles.tableWrapper}>
|
|
|
|
|
<table className={styles.dataTable}>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Year</th>
|
|
|
|
|
<th>RWM Expected</th>
|
|
|
|
|
<th>RWM Higher</th>
|
|
|
|
|
<th>Reading Progress</th>
|
|
|
|
|
<th>Writing Progress</th>
|
|
|
|
|
<th>Maths Progress</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{yearlyData.map((result) => (
|
|
|
|
|
<tr key={result.year}>
|
|
|
|
|
<td className={styles.yearCell}>{result.year}</td>
|
|
|
|
|
<td>{result.rwm_expected_pct !== null ? formatPercentage(result.rwm_expected_pct) : '-'}</td>
|
|
|
|
|
<td>{result.rwm_high_pct !== null ? formatPercentage(result.rwm_high_pct) : '-'}</td>
|
|
|
|
|
<td>{result.reading_progress !== null ? formatProgress(result.reading_progress) : '-'}</td>
|
|
|
|
|
<td>{result.writing_progress !== null ? formatProgress(result.writing_progress) : '-'}</td>
|
|
|
|
|
<td>{result.maths_progress !== null ? formatProgress(result.maths_progress) : '-'}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
feat(data): integrate 9 UK government data sources via Kestra
Adds a full data integration pipeline for enriching school profiles with
supplementary data from Ofsted, GIAS, EES, IDACI, and FBIT.
Backend:
- Bump SCHEMA_VERSION to 3; add 8 new DB tables (ofsted_inspections,
ofsted_parent_view, school_census, admissions, sen_detail, phonics,
school_deprivation, school_finance) plus GIAS columns on schools
- Expose all supplementary data via GET /api/schools/{urn}
- Enrich school list responses with ofsted_grade + ofsted_date
Integrator (new service):
- FastAPI HTTP microservice; Kestra calls POST /run/{source}
- 9 source modules: ofsted, gias, parent_view, census, admissions,
sen_detail, phonics, idaci, finance
- 9 Kestra flow YAMLs with scheduled triggers and 3× retry
Frontend:
- SchoolRow: colour-coded Ofsted badge (Outstanding/Good/RI/Inadequate)
- SchoolDetailView: 7 new sections — Ofsted sub-judgements, Parent View
survey bars, Admissions, Pupils & Inclusion / SEN, Phonics, Deprivation
Context, Finances
- types.ts: 8 new interfaces + extended School/SchoolDetailsResponse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:44:04 +00:00
|
|
|
|
|
|
|
|
{/* Ofsted Section */}
|
|
|
|
|
{ofsted && (
|
|
|
|
|
<section className={styles.supplementarySection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Ofsted Inspection</h2>
|
|
|
|
|
<div className={styles.ofstedHeader}>
|
|
|
|
|
<span className={`${styles.ofstedGrade} ${styles[`ofstedGrade${ofsted.overall_effectiveness}`]}`}>
|
|
|
|
|
{ofsted.overall_effectiveness ? OFSTED_LABELS[ofsted.overall_effectiveness] : 'Not rated'}
|
|
|
|
|
</span>
|
|
|
|
|
{ofsted.inspection_date && (
|
|
|
|
|
<span className={styles.ofstedDate}>
|
|
|
|
|
Inspected: {new Date(ofsted.inspection_date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.metricsGrid}>
|
|
|
|
|
{[
|
|
|
|
|
{ label: 'Quality of Education', value: ofsted.quality_of_education },
|
|
|
|
|
{ label: 'Behaviour & Attitudes', value: ofsted.behaviour_attitudes },
|
|
|
|
|
{ label: 'Personal Development', value: ofsted.personal_development },
|
|
|
|
|
{ label: 'Leadership & Management', value: ofsted.leadership_management },
|
|
|
|
|
...(ofsted.early_years_provision != null ? [{ label: 'Early Years', value: ofsted.early_years_provision }] : []),
|
|
|
|
|
].map(({ label, value }) => value != null && (
|
|
|
|
|
<div key={label} className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>{label}</div>
|
|
|
|
|
<div className={`${styles.metricValue} ${styles[`ofstedGrade${value}`]}`}>
|
|
|
|
|
{OFSTED_LABELS[value]}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{ofsted.inspection_type && (
|
|
|
|
|
<p className={styles.ofstedType}>{ofsted.inspection_type}</p>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* What Parents Think */}
|
|
|
|
|
{parentView && parentView.total_responses != null && parentView.total_responses > 0 && (
|
|
|
|
|
<section className={styles.supplementarySection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>What Parents Think</h2>
|
|
|
|
|
<p className={styles.supplementarySubtitle}>
|
|
|
|
|
Based on {parentView.total_responses.toLocaleString()} parent responses to the Ofsted Parent View survey.
|
|
|
|
|
</p>
|
|
|
|
|
<div className={styles.parentViewGrid}>
|
|
|
|
|
{[
|
|
|
|
|
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
|
|
|
|
|
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
|
|
|
|
|
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
|
|
|
|
|
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
|
|
|
|
|
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
|
|
|
|
|
{ label: 'School looks after wellbeing', pct: parentView.q_wellbeing_pct },
|
|
|
|
|
{ label: 'Led and managed effectively', pct: parentView.q_leadership_pct },
|
|
|
|
|
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
|
|
|
|
|
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
|
|
|
|
|
].filter(q => q.pct != null).map(({ label, pct }) => (
|
|
|
|
|
<div key={label} className={styles.parentViewRow}>
|
|
|
|
|
<span className={styles.parentViewLabel}>{label}</span>
|
|
|
|
|
<div className={styles.parentViewBar}>
|
|
|
|
|
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
|
|
|
|
|
</div>
|
|
|
|
|
<span className={styles.parentViewPct}>{pct}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Admissions */}
|
|
|
|
|
{admissions && (
|
|
|
|
|
<section className={styles.supplementarySection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Admissions ({admissions.year})</h2>
|
|
|
|
|
<div className={styles.metricsGrid}>
|
|
|
|
|
{admissions.published_admission_number != null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>Places available</div>
|
|
|
|
|
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{admissions.total_applications != null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>Applications received</div>
|
|
|
|
|
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{admissions.first_preference_offers_pct != null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>Got first choice</div>
|
|
|
|
|
<div className={styles.metricValue}>{admissions.first_preference_offers_pct}%</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{admissions.oversubscribed != null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>Oversubscribed</div>
|
|
|
|
|
<div className={styles.metricValue}>{admissions.oversubscribed ? 'Yes' : 'No'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Pupils & Inclusion (Census + SEN) */}
|
|
|
|
|
{(census || senDetail) && (
|
|
|
|
|
<section className={styles.supplementarySection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Pupils & Inclusion</h2>
|
|
|
|
|
<div className={styles.metricsGrid}>
|
|
|
|
|
{census?.class_size_avg != null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>Average class size</div>
|
|
|
|
|
<div className={styles.metricValue}>{census.class_size_avg.toFixed(1)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{senDetail && (
|
|
|
|
|
<>
|
|
|
|
|
<h3 className={styles.subSectionTitle}>Primary SEN Needs (latest year)</h3>
|
|
|
|
|
<div className={styles.metricsGrid}>
|
|
|
|
|
{[
|
|
|
|
|
{ label: 'Speech & Language', pct: senDetail.primary_need_speech_pct },
|
|
|
|
|
{ label: 'Autism (ASD)', pct: senDetail.primary_need_autism_pct },
|
|
|
|
|
{ label: 'Learning Difficulties', pct: senDetail.primary_need_mld_pct },
|
|
|
|
|
{ label: 'Specific Learning (Dyslexia etc.)', pct: senDetail.primary_need_spld_pct },
|
|
|
|
|
{ label: 'Social, Emotional & Mental Health', pct: senDetail.primary_need_semh_pct },
|
|
|
|
|
{ label: 'Physical / Sensory', pct: senDetail.primary_need_physical_pct },
|
|
|
|
|
].filter(n => n.pct != null).map(({ label, pct }) => (
|
|
|
|
|
<div key={label} className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>{label}</div>
|
|
|
|
|
<div className={styles.metricValue}>{pct}%</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Year 1 Phonics */}
|
|
|
|
|
{phonics && phonics.year1_phonics_pct != null && (
|
|
|
|
|
<section className={styles.supplementarySection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Year 1 Phonics ({phonics.year})</h2>
|
|
|
|
|
<div className={styles.metricsGrid}>
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>Reached expected standard</div>
|
|
|
|
|
<div className={styles.metricValue}>{formatPercentage(phonics.year1_phonics_pct)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
{phonics.year2_phonics_pct != null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>Year 2 (re-takers) standard</div>
|
|
|
|
|
<div className={styles.metricValue}>{formatPercentage(phonics.year2_phonics_pct)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Deprivation Context */}
|
|
|
|
|
{deprivation && deprivation.idaci_decile != null && (
|
|
|
|
|
<section className={styles.supplementarySection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Deprivation Context</h2>
|
|
|
|
|
<div className={styles.metricsGrid}>
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>Area deprivation decile</div>
|
|
|
|
|
<div className={styles.metricValue}>{deprivation.idaci_decile} / 10</div>
|
|
|
|
|
<div className={styles.metricHint}>
|
|
|
|
|
1 = most deprived, 10 = least deprived
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{deprivation.idaci_score != null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>IDACI score</div>
|
|
|
|
|
<div className={styles.metricValue}>{deprivation.idaci_score.toFixed(3)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Finances */}
|
|
|
|
|
{finance && finance.per_pupil_spend != null && (
|
|
|
|
|
<section className={styles.supplementarySection}>
|
|
|
|
|
<h2 className={styles.sectionTitle}>Finances ({finance.year})</h2>
|
|
|
|
|
<div className={styles.metricsGrid}>
|
|
|
|
|
{finance.per_pupil_spend != null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>Spend per pupil</div>
|
|
|
|
|
<div className={styles.metricValue}>£{Math.round(finance.per_pupil_spend).toLocaleString()}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{finance.teacher_cost_pct != null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>Teacher costs</div>
|
|
|
|
|
<div className={styles.metricValue}>{finance.teacher_cost_pct.toFixed(1)}% of budget</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{finance.staff_cost_pct != null && (
|
|
|
|
|
<div className={styles.metricCard}>
|
|
|
|
|
<div className={styles.metricLabel}>All staff costs</div>
|
|
|
|
|
<div className={styles.metricValue}>{finance.staff_cost_pct.toFixed(1)}% of budget</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
2026-02-02 20:34:35 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|