feat(compare): parent-first compare screen assembly
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
This commit is contained in:
@@ -16,8 +16,10 @@ interface ComparePageProps {
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Compare Schools',
|
title: 'Compare Schools',
|
||||||
description: 'Compare KS2 performance across multiple primary schools in England',
|
description:
|
||||||
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
|
'Compare schools in England side by side — Ofsted inspections, KS2 and GCSE results against the England average, admissions odds and school community.',
|
||||||
|
keywords:
|
||||||
|
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||||
|
|||||||
@@ -28,8 +28,15 @@
|
|||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary, #5c564d);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
max-width: 60ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Phase Tabs */
|
/* Phase Tabs */
|
||||||
.phaseTabs {
|
.phaseTabs {
|
||||||
@@ -72,408 +79,87 @@
|
|||||||
background: var(--accent-coral-darker, #9c3f26);
|
background: var(--accent-coral-darker, #9c3f26);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Metric Selector */
|
/* Sticky school bar — column identity while scrolling; horizontal scroll on
|
||||||
.metricSelector {
|
narrow screens */
|
||||||
background: var(--bg-card, white);
|
.schoolBar {
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
position: sticky;
|
||||||
border-radius: 12px;
|
top: 0;
|
||||||
padding: 1.5rem;
|
z-index: 10;
|
||||||
margin-bottom: 2rem;
|
background: var(--bg-primary, #faf7f2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricLabel {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 400px;
|
|
||||||
padding: 0.625rem 1rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect:hover {
|
|
||||||
border-color: var(--accent-coral, #e07256);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-coral, #e07256);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-coral-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect optgroup {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
background: var(--bg-secondary, #f3ede4);
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect option {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text-secondary, #5c564d);
|
|
||||||
padding: 0.375rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Schools Section */
|
|
||||||
.schoolsSection {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolsGrid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolCard {
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
border-left: 3px solid var(--accent-teal, #2d7d7d);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
position: relative;
|
|
||||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolCard:hover {
|
|
||||||
box-shadow: var(--shadow-medium, 0 4px 20px rgba(26, 22, 18, 0.1));
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeButton {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.75rem;
|
|
||||||
right: 0.75rem;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--accent-coral, #e07256);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.removeButton:hover {
|
|
||||||
background: var(--accent-coral-dark, #c45a3f);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolName {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
padding-right: 2rem;
|
|
||||||
line-height: 1.3;
|
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolName a {
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolName a:hover {
|
|
||||||
color: var(--accent-coral-dark, #b04a2e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolMeta {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metaItem {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-secondary, #5c564d);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latestValue {
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
text-align: center;
|
|
||||||
background: var(--bg-secondary, #f3ede4);
|
|
||||||
margin-left: -1.5rem;
|
|
||||||
margin-right: -1.5rem;
|
|
||||||
margin-bottom: -1.5rem;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
border-radius: 0 0 12px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latestLabel {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted, #8a847a);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latestNumber {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent-teal, #2d7d7d);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chart Section */
|
|
||||||
.chartSection {
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle::before {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
width: 4px;
|
|
||||||
height: 1em;
|
|
||||||
background: var(--accent-coral, #e07256);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chartContainer {
|
|
||||||
width: 100%;
|
|
||||||
height: 400px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loadingMessage {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem;
|
|
||||||
color: var(--text-secondary, #5c564d);
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table Section */
|
|
||||||
.tableSection {
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
|
||||||
}
|
|
||||||
|
|
||||||
.tableWrapper {
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
max-width: 100%;
|
padding: 0.75rem 0;
|
||||||
margin-top: 1rem;
|
border-bottom: 1px solid var(--border-light, #e5dfd5);
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Right-edge fade so phone users see the comparison table scrolls.
|
.schoolChip {
|
||||||
Otherwise the wider-than-viewport table silently clips. */
|
flex: 1 1 0;
|
||||||
@media (max-width: 640px) {
|
min-width: 180px;
|
||||||
.tableWrapper {
|
background: var(--bg-card, white);
|
||||||
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
border: 1px solid var(--border-light, #e5dfd5);
|
||||||
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
border-top: 3px solid var(--accent-coral, #e07256);
|
||||||
}
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow-soft, 0 2px 8px rgba(26, 22, 18, 0.06));
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable {
|
.chipDot {
|
||||||
width: 100%;
|
width: 11px;
|
||||||
border-collapse: separate;
|
height: 11px;
|
||||||
border-spacing: 0;
|
border-radius: 50%;
|
||||||
font-size: 0.9375rem;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable thead {
|
.chipText {
|
||||||
background: var(--bg-secondary, #f3ede4);
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable th {
|
.chipName {
|
||||||
padding: 1rem;
|
display: block;
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.25;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
text-decoration: none;
|
||||||
background: var(--bg-secondary, #f3ede4);
|
}
|
||||||
|
|
||||||
|
.chipName:hover {
|
||||||
|
color: var(--accent-coral-dark, #b04a2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipMeta {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-transform: uppercase;
|
overflow: hidden;
|
||||||
font-size: 0.75rem;
|
text-overflow: ellipsis;
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable td {
|
.chipRemove {
|
||||||
padding: 1rem;
|
margin-left: auto;
|
||||||
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
border: none;
|
||||||
color: var(--text-secondary, #5c564d);
|
|
||||||
text-align: left;
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sticky first column (Year) so labels remain visible while scrolling */
|
|
||||||
.comparisonTable th:first-child,
|
|
||||||
.comparisonTable td:first-child {
|
|
||||||
position: sticky;
|
|
||||||
left: 0;
|
|
||||||
z-index: 1;
|
|
||||||
box-shadow: 2px 0 4px -2px rgba(26, 22, 18, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.comparisonTable thead th:first-child {
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comparisonTable tbody tr:hover td:first-child {
|
|
||||||
background: var(--bg-secondary, #f3ede4);
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comparisonTable tbody tr:last-child td {
|
.footnote {
|
||||||
border-bottom: none;
|
font-size: 0.78rem;
|
||||||
}
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-top: 2.5rem;
|
||||||
.comparisonTable tbody tr:hover {
|
border-top: 1px solid var(--border-light, #e5dfd5);
|
||||||
background: var(--bg-secondary, #f3ede4);
|
padding-top: 1rem;
|
||||||
}
|
max-width: 75ch;
|
||||||
|
|
||||||
.yearCell {
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--accent-gold, #c9a227);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty State */
|
|
||||||
.emptyState {
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 2rem;
|
|
||||||
background: var(--bg-card, white);
|
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyStateTitle {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #1a1612);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyStateDescription {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--text-secondary, #5c564d);
|
|
||||||
max-width: 400px;
|
|
||||||
margin: 0 auto 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricDescription {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
max-width: 600px;
|
|
||||||
flex-basis: 100%;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progressNote {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-left: 3px solid var(--accent-teal);
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
margin: 0 0 1.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Responsive Design */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.headerContent {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelector {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricSelect {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schoolsGrid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chartSection,
|
|
||||||
.tableSection {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chartContainer {
|
|
||||||
/* Taller than desktop's proportion would suggest: the chip legend row
|
|
||||||
sits inside, and the in-chart title/legend/axis titles are gone, so
|
|
||||||
nearly all of this is plot area. */
|
|
||||||
height: 340px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comparisonTable {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comparisonTable th,
|
|
||||||
.comparisonTable td {
|
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latestValue {
|
|
||||||
margin-left: -1rem;
|
|
||||||
margin-right: -1rem;
|
|
||||||
margin-bottom: -1rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 0 0 8px 5px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* ComparisonView Component
|
* ComparisonView — the parent-first compare screen: a sticky school bar and
|
||||||
* Client-side comparison interface with phase tabs, charts, and tables
|
* six sections (At a glance / Ofsted / Academics / Getting a place / Who
|
||||||
|
* goes there / Explore trends), every number anchored against the England
|
||||||
|
* average or the computed state-school benchmark with provenance-correct
|
||||||
|
* labels. Layout and copy follow the reviewed mockups
|
||||||
|
* (docs/superpowers/specs/mockups/).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
|
|
||||||
const ComparisonChart = dynamic(
|
|
||||||
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
|
|
||||||
{ ssr: false },
|
|
||||||
);
|
|
||||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
import { CompareAtAGlance } from './compare/CompareAtAGlance';
|
||||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
import { CompareOfsted } from './compare/CompareOfsted';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, CHART_TEXT_COLORS, schoolUrl } from '@/lib/utils';
|
import { CompareAcademics } from './compare/CompareAcademics';
|
||||||
|
import { CompareAdmissions } from './compare/CompareAdmissions';
|
||||||
|
import { CompareCommunity } from './compare/CompareCommunity';
|
||||||
|
import { TrendsExplorer, PRIMARY_CATEGORIES, SECONDARY_CATEGORIES } from './compare/TrendsExplorer';
|
||||||
|
import type {
|
||||||
|
Benchmarks,
|
||||||
|
ComparisonData,
|
||||||
|
MetricDefinition,
|
||||||
|
NationalAverages,
|
||||||
|
School,
|
||||||
|
} from '@/lib/types';
|
||||||
|
import { CHART_COLORS, schoolUrl } from '@/lib/utils';
|
||||||
import { fetchComparison } from '@/lib/api';
|
import { fetchComparison } from '@/lib/api';
|
||||||
import { track } from '@/lib/analytics';
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './ComparisonView.module.css';
|
import styles from './ComparisonView.module.css';
|
||||||
|
|
||||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
|
||||||
const SECONDARY_CATEGORIES = ['gcse'];
|
|
||||||
|
|
||||||
const PRIMARY_OPTGROUPS: { label: string; category: string }[] = [
|
|
||||||
{ label: 'Expected Standard', category: 'expected' },
|
|
||||||
{ label: 'Higher Standard', category: 'higher' },
|
|
||||||
{ label: 'Progress Scores', category: 'progress' },
|
|
||||||
{ label: 'Average Scores', category: 'average' },
|
|
||||||
{ label: 'Gender Performance', category: 'gender' },
|
|
||||||
{ label: 'Equity (Disadvantaged)', category: 'equity' },
|
|
||||||
{ label: 'School Context', category: 'context' },
|
|
||||||
{ label: 'Absence', category: 'absence' },
|
|
||||||
{ label: '3-Year Trends', category: 'trends' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [
|
|
||||||
{ label: 'GCSE Performance', category: 'gcse' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ComparisonViewProps {
|
interface ComparisonViewProps {
|
||||||
initialData: Record<string, ComparisonData> | null;
|
initialData: Record<string, ComparisonData> | null;
|
||||||
initialUrns: number[];
|
initialUrns: number[];
|
||||||
@@ -58,11 +49,13 @@ export function ComparisonView({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { selectedSchools, removeSchool, addSchool, replaceSchools, isInitialized } = useComparison();
|
const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
|
||||||
|
|
||||||
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [comparisonData, setComparisonData] = useState(initialData);
|
const [comparisonData, setComparisonData] = useState(initialData);
|
||||||
|
const [nationalAverages, setNationalAverages] = useState<NationalAverages | undefined>();
|
||||||
|
const [benchmarks, setBenchmarks] = useState<Benchmarks | undefined>();
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
const [shareConfirm, setShareConfirm] = useState(false);
|
||||||
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||||
// Tracks whether the user has explicitly clicked a phase tab.
|
// Tracks whether the user has explicitly clicked a phase tab.
|
||||||
@@ -77,18 +70,18 @@ export function ComparisonView({
|
|||||||
if (!isInitialized) return;
|
if (!isInitialized) return;
|
||||||
if (initialUrns.length > 0 && initialData) {
|
if (initialUrns.length > 0 && initialData) {
|
||||||
const urlSchools = initialUrns
|
const urlSchools = initialUrns
|
||||||
.map(urn => initialData[String(urn)]?.school_info)
|
.map((urn) => initialData[String(urn)]?.school_info)
|
||||||
.filter((info): info is NonNullable<typeof info> => Boolean(info));
|
.filter((info): info is NonNullable<typeof info> => Boolean(info));
|
||||||
const sameSet =
|
const sameSet =
|
||||||
urlSchools.length === selectedSchools.length &&
|
urlSchools.length === selectedSchools.length &&
|
||||||
urlSchools.every(s => selectedSchools.some(sel => sel.urn === s.urn));
|
urlSchools.every((s) => selectedSchools.some((sel) => sel.urn === s.urn));
|
||||||
if (urlSchools.length > 0 && !sameSet) {
|
if (urlSchools.length > 0 && !sameSet) {
|
||||||
replaceSchools(urlSchools);
|
replaceSchools(urlSchools);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [isInitialized]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Sync URL with selected schools
|
// Sync URL with selected schools + metric, and (re)fetch the comparison.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urns = selectedSchools.map((s) => s.urn).join(',');
|
const urns = selectedSchools.map((s) => s.urn).join(',');
|
||||||
const params = new URLSearchParams(searchParams);
|
const params = new URLSearchParams(searchParams);
|
||||||
@@ -104,20 +97,23 @@ export function ComparisonView({
|
|||||||
const newUrl = `${pathname}?${params.toString()}`;
|
const newUrl = `${pathname}?${params.toString()}`;
|
||||||
router.replace(newUrl, { scroll: false });
|
router.replace(newUrl, { scroll: false });
|
||||||
|
|
||||||
// Fetch comparison data
|
|
||||||
if (selectedSchools.length > 0) {
|
if (selectedSchools.length > 0) {
|
||||||
fetchComparison(urns, { cache: 'no-store' })
|
fetchComparison(urns, { cache: 'no-store' })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setComparisonData(data.comparison);
|
setComparisonData(data.comparison);
|
||||||
|
setNationalAverages(data.national_averages);
|
||||||
|
setBenchmarks(data.benchmarks);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
// Keep whatever we already have (SSR data or a previous fetch) rather
|
// Keep whatever we already have (SSR data or a previous fetch) rather
|
||||||
// than blanking the chart — a transient refetch failure shouldn't
|
// than blanking the page — a transient refetch failure shouldn't
|
||||||
// destroy a working comparison the user is looking at.
|
// destroy a working comparison the user is looking at.
|
||||||
console.error('Failed to fetch comparison:', err);
|
console.error('Failed to fetch comparison:', err);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setComparisonData(null);
|
setComparisonData(null);
|
||||||
|
setNationalAverages(undefined);
|
||||||
|
setBenchmarks(undefined);
|
||||||
}
|
}
|
||||||
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
}, [selectedSchools, selectedMetric, pathname, searchParams, router]);
|
||||||
|
|
||||||
@@ -128,27 +124,22 @@ export function ComparisonView({
|
|||||||
if (info?.rwm_expected_pct != null) return 'primary';
|
if (info?.rwm_expected_pct != null) return 'primary';
|
||||||
// Fallback: check yearly data
|
// Fallback: check yearly data
|
||||||
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
|
const yearlyData = comparisonData?.[school.urn]?.yearly_data;
|
||||||
if (yearlyData?.some((d: any) => d.attainment_8_score != null)) return 'secondary';
|
if (yearlyData?.some((d) => d.attainment_8_score != null)) return 'secondary';
|
||||||
return 'primary';
|
return 'primary';
|
||||||
};
|
};
|
||||||
|
|
||||||
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
|
const primarySchools = selectedSchools.filter((s) => classifySchool(s) === 'primary');
|
||||||
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
|
const secondarySchools = selectedSchools.filter((s) => classifySchool(s) === 'secondary');
|
||||||
|
|
||||||
// Auto-select tab with more schools and sync the metric to match the detected phase.
|
// Auto-select tab with more schools and sync the metric to match the phase.
|
||||||
// This fixes the case where the URL carries a primary metric (e.g. rwm_expected_pct)
|
|
||||||
// but the shortlisted schools are secondary — the phase tab switches but the metric
|
|
||||||
// needs to follow, otherwise all secondary cards show "–" for a primary-only field.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!comparisonData || selectedSchools.length === 0) return;
|
if (!comparisonData || selectedSchools.length === 0) return;
|
||||||
if (phaseLockedByUser.current) return;
|
if (phaseLockedByUser.current) return;
|
||||||
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
|
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
|
||||||
setComparePhase(newPhase);
|
setComparePhase(newPhase);
|
||||||
// Only reset the metric when it doesn't belong to the newly detected phase.
|
|
||||||
// This preserves a correct metric that came from the URL (e.g. metric=attainment_8_score).
|
|
||||||
const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
|
const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
|
||||||
const metricFitsPhase = metrics.some(
|
const metricFitsPhase = metrics.some(
|
||||||
(m) => m.key === selectedMetric && phaseCategories.includes(m.category)
|
(m) => m.key === selectedMetric && phaseCategories.includes(m.category),
|
||||||
);
|
);
|
||||||
if (!metricFitsPhase) {
|
if (!metricFitsPhase) {
|
||||||
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||||
@@ -158,29 +149,24 @@ export function ComparisonView({
|
|||||||
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
||||||
phaseLockedByUser.current = true;
|
phaseLockedByUser.current = true;
|
||||||
setComparePhase(phase);
|
setComparePhase(phase);
|
||||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
setSelectedMetric(phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||||
setSelectedMetric(defaultMetric);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// compare_viewed: fire once after the page has its first selection.
|
// compare_viewed: fire once after the page has its first selection.
|
||||||
// We watch `selectedSchools.length` going from 0 → ≥1 so the event is
|
|
||||||
// sent only when there's actual content to view, not for empty arrivals.
|
|
||||||
const compareViewedRef = useRef(false);
|
const compareViewedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (compareViewedRef.current) return;
|
if (compareViewedRef.current) return;
|
||||||
if (selectedSchools.length === 0) return;
|
if (selectedSchools.length === 0) return;
|
||||||
compareViewedRef.current = true;
|
compareViewedRef.current = true;
|
||||||
const primaryCount = selectedSchools.filter(s => s.phase?.toLowerCase().includes('primary')).length;
|
const primaryCount = selectedSchools.filter((s) =>
|
||||||
|
s.phase?.toLowerCase().includes('primary'),
|
||||||
|
).length;
|
||||||
const secondaryCount = selectedSchools.length - primaryCount;
|
const secondaryCount = selectedSchools.length - primaryCount;
|
||||||
const phaseMix = primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
|
const phaseMix =
|
||||||
|
primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
|
||||||
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
|
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
|
||||||
}, [selectedSchools]);
|
}, [selectedSchools]);
|
||||||
|
|
||||||
const handleMetricChange = (metric: string) => {
|
|
||||||
track('compare_metric_changed', { metric, phase: comparePhase });
|
|
||||||
setSelectedMetric(metric);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveSchool = (urn: number) => {
|
const handleRemoveSchool = (urn: number) => {
|
||||||
removeSchool(urn);
|
removeSchool(urn);
|
||||||
track('compare_school_removed', { urn, from: 'compare' });
|
track('compare_school_removed', { urn, from: 'compare' });
|
||||||
@@ -191,21 +177,22 @@ export function ComparisonView({
|
|||||||
const count = selectedSchools.length;
|
const count = selectedSchools.length;
|
||||||
const shareData = {
|
const shareData = {
|
||||||
title: 'School comparison · SchoolCompare',
|
title: 'School comparison · SchoolCompare',
|
||||||
text: count > 0
|
text:
|
||||||
|
count > 0
|
||||||
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||||
: 'SchoolCompare',
|
: 'SchoolCompare',
|
||||||
url,
|
url,
|
||||||
};
|
};
|
||||||
// Prefer the native share sheet on platforms that support it (iOS / Android).
|
if (
|
||||||
// canShare is feature-detected because Safari iOS exposes share() but
|
typeof navigator !== 'undefined' &&
|
||||||
// some configurations refuse the payload.
|
navigator.share &&
|
||||||
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
(!navigator.canShare || navigator.canShare(shareData))
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
await navigator.share(shareData);
|
await navigator.share(shareData);
|
||||||
track('compare_shared', { method: 'native', school_count: count });
|
track('compare_shared', { method: 'native', school_count: count });
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User cancelled — bail silently. Any other error falls through to clipboard.
|
|
||||||
if ((err as DOMException)?.name === 'AbortError') return;
|
if ((err as DOMException)?.name === 'AbortError') return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,27 +201,22 @@ export function ComparisonView({
|
|||||||
track('compare_shared', { method: 'clipboard', school_count: count });
|
track('compare_shared', { method: 'clipboard', school_count: count });
|
||||||
setShareConfirm(true);
|
setShareConfirm(true);
|
||||||
setTimeout(() => setShareConfirm(false), 2000);
|
setTimeout(() => setShareConfirm(false), 2000);
|
||||||
} catch { /* fallback: do nothing */ }
|
} catch {
|
||||||
|
/* fallback: do nothing */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPrimary = comparePhase === 'primary';
|
const isPrimary = comparePhase === 'primary';
|
||||||
const allowedCategories = isPrimary ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
|
||||||
const optgroups = isPrimary ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
|
||||||
const filteredMetrics = metrics.filter(m => allowedCategories.includes(m.category));
|
|
||||||
const activeSchools = isPrimary ? primarySchools : secondarySchools;
|
const activeSchools = isPrimary ? primarySchools : secondarySchools;
|
||||||
|
|
||||||
// Get metric definition
|
|
||||||
const currentMetricDef = metrics.find((m) => m.key === selectedMetric);
|
|
||||||
const metricLabel = currentMetricDef?.label || selectedMetric;
|
|
||||||
|
|
||||||
// No schools selected
|
|
||||||
if (selectedSchools.length === 0) {
|
if (selectedSchools.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1>Compare Schools</h1>
|
<h1>Compare Schools</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Add schools to your comparison basket to see side-by-side performance data
|
Add schools to your comparison basket to see them side by side — inspection results,
|
||||||
|
academics, admissions and community.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -252,39 +234,46 @@ export function ComparisonView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filtered comparison data for active phase
|
// Build filtered comparison data for the active phase
|
||||||
const activeComparisonData: Record<string, ComparisonData> = {};
|
const activeComparisonData: Record<string, ComparisonData> = {};
|
||||||
if (comparisonData) {
|
if (comparisonData) {
|
||||||
activeSchools.forEach(s => {
|
activeSchools.forEach((s) => {
|
||||||
if (comparisonData[s.urn]) {
|
if (comparisonData[s.urn]) {
|
||||||
activeComparisonData[s.urn] = comparisonData[s.urn];
|
activeComparisonData[s.urn] = comparisonData[s.urn];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const hasData = Object.keys(activeComparisonData).length > 0;
|
||||||
// Get years for table
|
|
||||||
const years =
|
|
||||||
Object.keys(activeComparisonData).length > 0
|
|
||||||
? activeComparisonData[Object.keys(activeComparisonData)[0]].yearly_data.map((d) => d.year)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Header */}
|
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.headerContent}>
|
<div className={styles.headerContent}>
|
||||||
<div>
|
<div>
|
||||||
<h1>Compare Schools</h1>
|
<h1>Compare Schools</h1>
|
||||||
<p className={styles.subtitle}>
|
<p className={styles.subtitle}>
|
||||||
Comparing {selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''}
|
{selectedSchools.length} school{selectedSchools.length !== 1 ? 's' : ''} side by side
|
||||||
|
— each number anchored against the England average so you can tell at a glance
|
||||||
|
what's typical and what stands out.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
<div className={styles.headerActions}>
|
||||||
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
|
<button onClick={() => setIsModalOpen(true)} className="btn btn-primary">
|
||||||
+ Add School
|
+ Add School
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link">
|
<button onClick={handleShare} className="btn btn-tertiary" title="Copy comparison link">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
>
|
||||||
|
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||||
|
<polyline points="16 6 12 2 8 6" />
|
||||||
|
<line x1="12" y1="2" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
{shareConfirm ? 'Copied!' : 'Share'}
|
{shareConfirm ? 'Copied!' : 'Share'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,6 +281,7 @@ export function ComparisonView({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Phase Tabs */}
|
{/* Phase Tabs */}
|
||||||
|
{secondarySchools.length > 0 && primarySchools.length > 0 && (
|
||||||
<div className={styles.phaseTabs}>
|
<div className={styles.phaseTabs}>
|
||||||
<button
|
<button
|
||||||
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
className={`${styles.phaseTab} ${isPrimary ? styles.phaseTabActive : ''}`}
|
||||||
@@ -306,6 +296,7 @@ export function ComparisonView({
|
|||||||
Secondary ({secondarySchools.length})
|
Secondary ({secondarySchools.length})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSchools.length === 0 ? (
|
{activeSchools.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -318,184 +309,82 @@ export function ComparisonView({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Metric Selector */}
|
{/* Sticky school bar — column identity while scrolling */}
|
||||||
<section className={styles.metricSelector}>
|
<div className={styles.schoolBar} aria-label="Schools in this comparison">
|
||||||
<label htmlFor="metric-select" className={styles.metricLabel}>
|
|
||||||
Select Metric:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="metric-select"
|
|
||||||
value={selectedMetric}
|
|
||||||
onChange={(e) => handleMetricChange(e.target.value)}
|
|
||||||
className={styles.metricSelect}
|
|
||||||
>
|
|
||||||
{optgroups.map(({ label, category }) => {
|
|
||||||
const groupMetrics = filteredMetrics.filter(m => m.category === category);
|
|
||||||
if (groupMetrics.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<optgroup key={category} label={label}>
|
|
||||||
{groupMetrics.map((metric) => (
|
|
||||||
<option key={metric.key} value={metric.key}>{metric.label}</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
{currentMetricDef?.description && (
|
|
||||||
<p className={styles.metricDescription}>{currentMetricDef.description}</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Progress score explanation */}
|
|
||||||
{selectedMetric.includes('progress') && (
|
|
||||||
<p className={styles.progressNote}>
|
|
||||||
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the national average; positive scores are above average.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* School Cards */}
|
|
||||||
<section className={styles.schoolsSection}>
|
|
||||||
<div className={styles.schoolsGrid}>
|
|
||||||
{activeSchools.map((school, index) => (
|
{activeSchools.map((school, index) => (
|
||||||
<div
|
<div
|
||||||
key={school.urn}
|
key={school.urn}
|
||||||
className={styles.schoolCard}
|
className={styles.schoolChip}
|
||||||
style={{ borderLeft: `3px solid ${CHART_COLORS[index % CHART_COLORS.length]}` }}
|
style={{ borderTopColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
className={styles.chipDot}
|
||||||
|
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className={styles.chipText}>
|
||||||
|
<a className={styles.chipName} href={schoolUrl(school.urn, school.school_name)}>
|
||||||
|
{school.school_name}
|
||||||
|
</a>
|
||||||
|
<span className={styles.chipMeta}>
|
||||||
|
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemoveSchool(school.urn)}
|
onClick={() => handleRemoveSchool(school.urn)}
|
||||||
className={styles.removeButton}
|
className={styles.chipRemove}
|
||||||
aria-label={`Remove ${school.school_name}`}
|
aria-label={`Remove ${school.school_name}`}
|
||||||
title="Remove from comparison"
|
title="Remove from comparison"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<h2 className={styles.schoolName}>
|
|
||||||
<a href={schoolUrl(school.urn, school.school_name)}>{school.school_name}</a>
|
|
||||||
</h2>
|
|
||||||
<div className={styles.schoolMeta}>
|
|
||||||
{school.local_authority && (
|
|
||||||
<span className={styles.metaItem}>{school.local_authority}</span>
|
|
||||||
)}
|
|
||||||
{school.school_type && (
|
|
||||||
<span className={styles.metaItem}>{school.school_type}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Latest metric value */}
|
|
||||||
{activeComparisonData[school.urn] && (
|
|
||||||
<div className={styles.latestValue}>
|
|
||||||
<div className={styles.latestLabel}>{metricLabel}</div>
|
|
||||||
{/* Text uses the AA-dark variant; the swatch dot keeps the true series colour */}
|
|
||||||
<div className={styles.latestNumber} style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: '10px',
|
|
||||||
height: '10px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: CHART_COLORS[index % CHART_COLORS.length],
|
|
||||||
marginRight: '0.4rem',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
const yearlyData = activeComparisonData[school.urn].yearly_data;
|
|
||||||
if (yearlyData.length === 0) return '-';
|
|
||||||
|
|
||||||
const latestData = yearlyData[yearlyData.length - 1];
|
|
||||||
const value = latestData[selectedMetric as keyof typeof latestData];
|
|
||||||
|
|
||||||
if (value === null || value === undefined) return '-';
|
|
||||||
|
|
||||||
if (selectedMetric.includes('progress')) {
|
|
||||||
return formatProgress(value as number);
|
|
||||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
|
||||||
return formatPercentage(value as number);
|
|
||||||
} else {
|
|
||||||
return typeof value === 'number' ? value.toFixed(1) : String(value);
|
|
||||||
}
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Comparison Chart */}
|
{hasData && (
|
||||||
{Object.keys(activeComparisonData).length > 0 ? (
|
<>
|
||||||
<section className={styles.chartSection}>
|
<CompareAtAGlance
|
||||||
<h2 className={styles.sectionTitle}>Performance Over Time</h2>
|
|
||||||
<div className={styles.chartContainer}>
|
|
||||||
<ComparisonChart
|
|
||||||
comparisonData={activeComparisonData}
|
|
||||||
schools={activeSchools}
|
schools={activeSchools}
|
||||||
|
data={activeComparisonData}
|
||||||
|
nationalAverages={nationalAverages}
|
||||||
|
benchmarks={benchmarks}
|
||||||
|
/>
|
||||||
|
<CompareOfsted schools={activeSchools} data={activeComparisonData} />
|
||||||
|
<CompareAcademics
|
||||||
|
schools={activeSchools}
|
||||||
|
data={activeComparisonData}
|
||||||
|
nationalAverages={nationalAverages}
|
||||||
|
benchmarks={benchmarks}
|
||||||
|
/>
|
||||||
|
<CompareAdmissions schools={activeSchools} data={activeComparisonData} />
|
||||||
|
<CompareCommunity
|
||||||
|
schools={activeSchools}
|
||||||
|
data={activeComparisonData}
|
||||||
|
benchmarks={benchmarks}
|
||||||
|
/>
|
||||||
|
<TrendsExplorer
|
||||||
|
schools={activeSchools}
|
||||||
|
data={activeComparisonData}
|
||||||
|
metrics={metrics}
|
||||||
metric={selectedMetric}
|
metric={selectedMetric}
|
||||||
metricLabel={metricLabel}
|
onMetricChange={setSelectedMetric}
|
||||||
|
isPrimaryPhase={isPrimary}
|
||||||
|
nationalAverages={nationalAverages}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : activeSchools.length > 0 ? (
|
|
||||||
<section className={styles.chartSection}>
|
|
||||||
<LoadingSkeleton type="list" />
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Comparison Table */}
|
<p className={styles.footnote}>
|
||||||
{Object.keys(activeComparisonData).length > 0 && years.length > 0 && (
|
Sources: DfE Compare School Performance (KS2/KS4 results), Ofsted inspection
|
||||||
<section className={styles.tableSection}>
|
outcomes, DfE school admissions data, school census. England averages for test
|
||||||
<h2 className={styles.sectionTitle}>Detailed Comparison</h2>
|
results are official DfE figures; other benchmarks are state-school averages
|
||||||
<div className={styles.tableWrapper}>
|
computed from our dataset. Following DfE practice, figures based on 5 or fewer
|
||||||
<table className={styles.comparisonTable}>
|
pupils are suppressed and shown as "no data".
|
||||||
<thead>
|
</p>
|
||||||
<tr>
|
</>
|
||||||
<th>Year</th>
|
|
||||||
{activeSchools.map((school) => (
|
|
||||||
<th key={school.urn}>{school.school_name}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{years.map((year) => (
|
|
||||||
<tr key={year}>
|
|
||||||
<td className={styles.yearCell}>{formatAcademicYear(year)}</td>
|
|
||||||
{activeSchools.map((school) => {
|
|
||||||
const schoolData = activeComparisonData[school.urn];
|
|
||||||
if (!schoolData) return <td key={school.urn}>-</td>;
|
|
||||||
|
|
||||||
const yearData = schoolData.yearly_data.find((d) => d.year === year);
|
|
||||||
if (!yearData) return <td key={school.urn}>-</td>;
|
|
||||||
|
|
||||||
const value = yearData[selectedMetric as keyof typeof yearData];
|
|
||||||
|
|
||||||
if (value === null || value === undefined) {
|
|
||||||
return <td key={school.urn}>-</td>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let displayValue: string;
|
|
||||||
if (selectedMetric.includes('progress')) {
|
|
||||||
displayValue = formatProgress(value as number);
|
|
||||||
} else if (selectedMetric.includes('pct') || selectedMetric.includes('rate')) {
|
|
||||||
displayValue = formatPercentage(value as number);
|
|
||||||
} else {
|
|
||||||
displayValue = typeof value === 'number' ? value.toFixed(1) : String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <td key={school.urn}>{displayValue}</td>;
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* School Search Modal */}
|
|
||||||
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
<SchoolSearchModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
import { progressBand } from '@/lib/compareLogic';
|
import { progressBand } from '@/lib/compareLogic';
|
||||||
@@ -53,19 +52,20 @@ export function TrendsExplorer({
|
|||||||
schools,
|
schools,
|
||||||
data,
|
data,
|
||||||
metrics,
|
metrics,
|
||||||
initialMetric,
|
metric,
|
||||||
|
onMetricChange,
|
||||||
isPrimaryPhase,
|
isPrimaryPhase,
|
||||||
nationalAverages,
|
nationalAverages,
|
||||||
}: {
|
}: {
|
||||||
schools: School[];
|
schools: School[];
|
||||||
data: Record<string, ComparisonData>;
|
data: Record<string, ComparisonData>;
|
||||||
metrics: MetricDefinition[];
|
metrics: MetricDefinition[];
|
||||||
initialMetric: string;
|
/** Controlled: the page owns the metric so the URL contract survives. */
|
||||||
|
metric: string;
|
||||||
|
onMetricChange: (metric: string) => void;
|
||||||
isPrimaryPhase: boolean;
|
isPrimaryPhase: boolean;
|
||||||
nationalAverages?: NationalAverages;
|
nationalAverages?: NationalAverages;
|
||||||
}) {
|
}) {
|
||||||
const [metric, setMetric] = useState(initialMetric);
|
|
||||||
|
|
||||||
const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
const allowedCategories = isPrimaryPhase ? PRIMARY_CATEGORIES : SECONDARY_CATEGORIES;
|
||||||
const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
const optgroups = isPrimaryPhase ? PRIMARY_OPTGROUPS : SECONDARY_OPTGROUPS;
|
||||||
const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category));
|
const filteredMetrics = metrics.filter((m) => allowedCategories.includes(m.category));
|
||||||
@@ -88,7 +88,7 @@ export function TrendsExplorer({
|
|||||||
|
|
||||||
const handleMetricChange = (next: string) => {
|
const handleMetricChange = (next: string) => {
|
||||||
track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' });
|
track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' });
|
||||||
setMetric(next);
|
onMetricChange(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ciKeys = PROGRESS_CI[metric];
|
const ciKeys = PROGRESS_CI[metric];
|
||||||
|
|||||||
Reference in New Issue
Block a user