Files
school_compare/nextjs-app/components/SatsChart.tsx
Tudor Sitaru 3bf2e8f262
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 19s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 46s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
feat(detail): replace SATs text tables with cascade bar charts, add admissions bar and history accordion
Redesign the School Details page for better parent comprehension:
- New SatsChart component: horizontal cascade bars with ruler scale and
  national average marker (teal/coral palette matching site theme)
- Admissions section: visual progress bar showing 1st-preference demand
  vs available places, colour-coded by oversubscription status
- Historical data: collapse raw year-by-year table behind a disclosure
  element while keeping the performance line chart always visible
- EAL metric: add national average comparison via DeltaChip (backend now
  includes eal_pct in national averages endpoint)
- New formatWithSuppression utility for null/suppressed data handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:22:24 +01:00

139 lines
4.3 KiB
TypeScript

'use client';
import { useEffect, useRef } from 'react';
import styles from './SatsChart.module.css';
interface SubjectData {
name: string;
expectedPct: number | null;
exceedingPct: number | null;
nationalExpectedPct: number | null;
}
interface SatsChartProps {
subjects: SubjectData[];
}
const RULER_TICKS = [0, 25, 50, 75, 100];
const GRIDLINE_POSITIONS = [25, 50, 75];
function SubjectColumn({ subject }: { subject: SubjectData }) {
const expectedRef = useRef<HTMLDivElement>(null);
const exceedingRef = useRef<HTMLDivElement>(null);
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
// Animate bars on mount
useEffect(() => {
const bars = [expectedRef.current, exceedingRef.current];
bars.forEach((bar) => {
if (!bar) return;
const target = bar.dataset.width;
bar.style.width = '0%';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
bar.style.width = `${target}%`;
});
});
});
}, [expectedPct, exceedingPct]);
if (expectedPct == null && exceedingPct == null) return null;
return (
<div className={styles.subjectChart}>
<div className={styles.subjectName}>{name}</div>
<div className={styles.chartArea}>
{/* Gridlines */}
<div className={styles.gridlines}>
{GRIDLINE_POSITIONS.map((pct) => (
<div key={pct} className={styles.gridline} style={{ left: `${pct}%` }} />
))}
</div>
{/* National average marker */}
{nationalExpectedPct != null && (
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
</div>
)}
{/* Bars */}
<div className={styles.barGroup}>
{expectedPct != null && (
<div className={styles.barRow}>
<div
ref={expectedRef}
className={`${styles.bar} ${styles.barExpected}`}
data-width={expectedPct}
/>
<div className={styles.barLabel}>
{expectedPct.toFixed(0)}% <span className={styles.barLabelSuffix}>expected</span>
</div>
</div>
)}
{exceedingPct != null && (
<div className={styles.barRow}>
<div
ref={exceedingRef}
className={`${styles.bar} ${styles.barExceeding}`}
data-width={exceedingPct}
/>
<div className={styles.barLabel}>
{exceedingPct.toFixed(0)}% <span className={styles.barLabelSuffix}>exceeding</span>
</div>
</div>
)}
</div>
{/* Ruler */}
<div className={styles.ruler}>
{RULER_TICKS.map((pct, i) => (
<span key={pct}>
<div className={styles.rulerTick} style={{ left: `${pct}%` }} />
<div
className={`${styles.rulerLabel} ${i === 0 ? styles.rulerLabelFirst : ''} ${i === RULER_TICKS.length - 1 ? styles.rulerLabelLast : ''}`}
style={{ left: `${pct}%` }}
>
{pct}%
</div>
</span>
))}
</div>
</div>
</div>
);
}
export default function SatsChart({ subjects }: SatsChartProps) {
const visibleSubjects = subjects.filter(
(s) => s.expectedPct != null || s.exceedingPct != null
);
if (visibleSubjects.length === 0) return null;
return (
<div>
<div className={styles.container}>
{visibleSubjects.map((subject) => (
<SubjectColumn key={subject.name} subject={subject} />
))}
</div>
<div className={styles.legend}>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
Expected standard
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
Exceeding / high score
</div>
<div className={styles.legendItem}>
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
National average
</div>
</div>
</div>
);
}