diff --git a/backend/app.py b/backend/app.py index 570705e..45ec99a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -683,7 +683,7 @@ async def get_national_averages(request: Request): "reading_avg_score", "maths_avg_score", "gps_avg_score", "reading_progress", "writing_progress", "maths_progress", "overall_absence_pct", "persistent_absence_pct", - "disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", + "disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", "eal_pct", ] ks4_metrics = [ "attainment_8_score", "progress_8_score", diff --git a/nextjs-app/components/SatsChart.module.css b/nextjs-app/components/SatsChart.module.css new file mode 100644 index 0000000..07f1be6 --- /dev/null +++ b/nextjs-app/components/SatsChart.module.css @@ -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; + } +} diff --git a/nextjs-app/components/SatsChart.tsx b/nextjs-app/components/SatsChart.tsx new file mode 100644 index 0000000..fc7106a --- /dev/null +++ b/nextjs-app/components/SatsChart.tsx @@ -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(null); + const exceedingRef = useRef(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 ( +
+
{name}
+
+ {/* Gridlines */} +
+ {GRIDLINE_POSITIONS.map((pct) => ( +
+ ))} +
+ + {/* National average marker */} + {nationalExpectedPct != null && ( +
+
{nationalExpectedPct.toFixed(0)}%
+
+ )} + + {/* Bars */} +
+ {expectedPct != null && ( +
+
+
+ {expectedPct.toFixed(0)}% expected +
+
+ )} + {exceedingPct != null && ( +
+
+
+ {exceedingPct.toFixed(0)}% exceeding +
+
+ )} +
+ + {/* Ruler */} +
+ {RULER_TICKS.map((pct, i) => ( + +
+
+ {pct}% +
+ + ))} +
+
+
+ ); +} + +export default function SatsChart({ subjects }: SatsChartProps) { + const visibleSubjects = subjects.filter( + (s) => s.expectedPct != null || s.exceedingPct != null + ); + + if (visibleSubjects.length === 0) return null; + + return ( +
+
+ {visibleSubjects.map((subject) => ( + + ))} +
+
+
+
+ Expected standard +
+
+
+ Exceeding / high score +
+
+
+ National average +
+
+
+ ); +} diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index 76cfab1..e9270d0 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -894,3 +894,105 @@ 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); +} diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index ce1f150..83814e7 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -22,6 +22,7 @@ import { buildOfstedHeroChip, } from '@/lib/utils'; import { DeltaChip } from './DeltaChip'; +import SatsChart from './SatsChart'; import styles from './SchoolDetailView.module.css'; const OFSTED_LABELS: Record = { @@ -561,127 +562,61 @@ export function SchoolDetailView({ )}
-
-
-

Reading

-
- {latestResults.reading_expected_pct !== null && ( -
- Expected level - - {formatPercentage(latestResults.reading_expected_pct)} - {primaryAvg.reading_expected_pct != null && ( - - )} - -
- )} - {latestResults.reading_high_pct !== null && ( -
- Exceeding - {formatPercentage(latestResults.reading_high_pct)} -
- )} - {latestResults.reading_progress !== null && ( -
- - Progress score - - - + + + {/* Progress scores row */} + {(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && ( +
+

Progress Scores

+
+ {latestResults.reading_progress != null && ( +
+ Reading + {formatProgress(latestResults.reading_progress)}
)} - {latestResults.reading_avg_score !== null && ( -
- - Average score - - - {latestResults.reading_avg_score.toFixed(1)} -
- )} -
-
- -
-

Writing

-
- {latestResults.writing_expected_pct !== null && ( -
- Expected level - - {formatPercentage(latestResults.writing_expected_pct)} - {primaryAvg.writing_expected_pct != null && ( - - )} - -
- )} - {latestResults.writing_high_pct !== null && ( -
- Exceeding - {formatPercentage(latestResults.writing_high_pct)} -
- )} - {latestResults.writing_progress !== null && ( -
- - Progress score - - - + {latestResults.writing_progress != null && ( +
+ Writing + {formatProgress(latestResults.writing_progress)}
)} -
-
- -
-

Maths

-
- {latestResults.maths_expected_pct !== null && ( -
- Expected level - - {formatPercentage(latestResults.maths_expected_pct)} - {primaryAvg.maths_expected_pct != null && ( - - )} - -
- )} - {latestResults.maths_high_pct !== null && ( -
- Exceeding - {formatPercentage(latestResults.maths_high_pct)} -
- )} - {latestResults.maths_progress !== null && ( -
- - Progress score - - - + {latestResults.maths_progress != null && ( +
+ Maths + {formatProgress(latestResults.maths_progress)}
)} - {latestResults.maths_avg_score !== null && ( -
- - Average score - - - {latestResults.maths_avg_score.toFixed(1)} -
- )}
-
+ )} {(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (

@@ -852,7 +787,20 @@ export function SchoolDetailView({ {admissions && (

How Hard to Get Into This School ({formatAcademicYear(admissions.year)})

- {admissions.oversubscribed != null && ( + {admissions.first_preference_applications != null && admissions.published_admission_number != null && ( +
+
+ {admissions.published_admission_number} places for {admissions.first_preference_applications} first-choice applications +
+
+
+
+
+ )} + {admissions.first_preference_applications == null && admissions.oversubscribed != null && (
{admissions.oversubscribed ? '⚠ Oversubscribed' @@ -905,7 +853,15 @@ export function SchoolDetailView({ English as an additional language
-
{formatPercentage(latestResults.eal_pct)}
+
+ {formatPercentage(latestResults.eal_pct)} + {primaryAvg.eal_pct != null && ( + + )} +
+ {primaryAvg.eal_pct != null && ( +
National avg: {primaryAvg.eal_pct.toFixed(0)}%
+ )}
)} {latestResults?.sen_support_pct != null && ( @@ -1034,8 +990,8 @@ export function SchoolDetailView({ />
{yearlyData.length > 1 && ( - <> -

Detailed year-by-year figures

+
+ View raw year-by-year data
@@ -1084,7 +1040,7 @@ export function SchoolDetailView({
- +
)} )} diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index 330cc88..8a65f9a 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -71,6 +71,13 @@ export function formatPercentage(value: number | null | undefined, decimals: num 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) */