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