feat(admissions): surface multi-year admissions trend on school detail
Build and Push Docker Images / Build Backend (FastAPI) (pull_request) Successful in 22s
Build and Push Docker Images / Build Frontend (Next.js) (pull_request) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (pull_request) Successful in 11s
Build and Push Docker Images / Trigger Portainer Update (pull_request) Has been skipped
Build and Push Docker Images / Build Backend (FastAPI) (pull_request) Successful in 22s
Build and Push Docker Images / Build Frontend (Next.js) (pull_request) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (pull_request) Successful in 11s
Build and Push Docker Images / Trigger Portainer Update (pull_request) Has been skipped
The school detail page only showed the latest admissions year. We store
every year, which is more decision-relevant for parents (the trend and its
consistency matter more than a single noisy year).
Backend now returns the full admissions_history (oldest first) alongside the
existing latest-year object. The primary SchoolDetailView gains a header
toggle ("This year | N-year trend") that swaps the Q&A for an SVG sparkline
of the first-choice offer rate. The toggle only appears when >=2 years carry
an offer rate; otherwise it falls back to the single-year card. Both views
share one CSS-grid cell so switching causes no layout shift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,62 @@ function progressClass(val: number | null | undefined): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact SVG sparkline of the first-choice offer rate across admissions years.
|
||||
* Renders nothing unless at least two years carry an offer-rate value.
|
||||
*/
|
||||
function OfferRateTrend({ history }: { history: SchoolAdmissions[] }) {
|
||||
const pts = history
|
||||
.filter((h) => h.first_preference_offer_pct != null)
|
||||
.map((h) => ({ year: h.year, v: h.first_preference_offer_pct as number }));
|
||||
if (pts.length < 2) return null;
|
||||
|
||||
const W = 520, H = 118;
|
||||
const padL = 44, padR = 20, padT = 16, padB = 34;
|
||||
const plotW = W - padL - padR, plotH = H - padT - padB;
|
||||
|
||||
const values = pts.map((p) => p.v);
|
||||
let lo = Math.max(0, Math.floor(Math.min(...values) / 10) * 10);
|
||||
let hi = Math.min(100, Math.ceil(Math.max(...values) / 10) * 10);
|
||||
// Guarantee a minimum span so small year-to-year moves aren't exaggerated.
|
||||
if (hi - lo < 30) {
|
||||
hi = Math.min(100, lo + 30);
|
||||
if (hi - lo < 30) lo = Math.max(0, hi - 30);
|
||||
}
|
||||
|
||||
const x = (i: number) => padL + (plotW * i) / (pts.length - 1);
|
||||
const y = (v: number) => padT + plotH * (1 - (v - lo) / (hi - lo));
|
||||
const gridVals = [hi, Math.round((hi + lo) / 2), lo];
|
||||
const polyline = pts.map((p, i) => `${x(i)},${y(p.v)}`).join(' ');
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={styles.admissionsChart}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label={`First-choice offer rate from ${formatAcademicYear(pts[0].year)} to ${formatAcademicYear(pts[pts.length - 1].year)}`}
|
||||
>
|
||||
{gridVals.map((gv) => (
|
||||
<g key={gv}>
|
||||
<line x1={padL} y1={y(gv)} x2={W - padR} y2={y(gv)} className={styles.admissionsGrid} />
|
||||
<text x={padL - 8} y={y(gv) + 4} textAnchor="end" className={styles.admissionsAxis}>{gv}%</text>
|
||||
</g>
|
||||
))}
|
||||
<polyline points={polyline} fill="none" className={styles.admissionsLine} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{pts.map((p, i) => {
|
||||
const isLast = i === pts.length - 1;
|
||||
return (
|
||||
<g key={p.year}>
|
||||
<circle cx={x(i)} cy={y(p.v)} r={isLast ? 6 : 5} className={isLast ? styles.admissionsDotLast : styles.admissionsDot} />
|
||||
<text x={x(i)} y={y(p.v) - 9} textAnchor="middle" className={styles.admissionsPtLabel} data-last={isLast}>{Math.round(p.v)}%</text>
|
||||
<text x={x(i)} y={H - 12} textAnchor="middle" className={styles.admissionsAxis}>{formatAcademicYear(p.year)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
interface SchoolDetailViewProps {
|
||||
schoolInfo: School;
|
||||
yearlyData: SchoolResult[];
|
||||
@@ -66,6 +122,7 @@ interface SchoolDetailViewProps {
|
||||
parentView: OfstedParentView | null;
|
||||
census: SchoolCensus | null;
|
||||
admissions: SchoolAdmissions | null;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
senDetail: SenDetail | null;
|
||||
phonics: Phonics | null;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
@@ -74,13 +131,17 @@ interface SchoolDetailViewProps {
|
||||
|
||||
export function SchoolDetailView({
|
||||
schoolInfo, yearlyData, absenceData,
|
||||
ofsted, parentView, census, admissions, senDetail, phonics, deprivation, finance,
|
||||
ofsted, parentView, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
|
||||
}: SchoolDetailViewProps) {
|
||||
const router = useRouter();
|
||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||
const isInComparison = isSelected(schoolInfo.urn);
|
||||
|
||||
const [activeSection, setActiveSection] = useState<string>('');
|
||||
const [admissionsView, setAdmissionsView] = useState<'year' | 'trend'>('year');
|
||||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||||
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
||||
const sectionNavRef = useRef<HTMLElement | null>(null);
|
||||
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
||||
|
||||
@@ -880,60 +941,80 @@ export function SchoolDetailView({
|
||||
{/* How Hard to Get In */}
|
||||
{admissions && (
|
||||
<section id="admissions" className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({formatAcademicYear(admissions.year)})</h2>
|
||||
<div className={styles.admissionsHeader}>
|
||||
<h2 className={styles.sectionTitle}>
|
||||
How Hard to Get Into This School{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}
|
||||
</h2>
|
||||
{showAdmissionsTrend && (
|
||||
<div className={styles.admissionsSeg} role="group" aria-label="Admissions view">
|
||||
<button type="button" aria-pressed={admissionsView === 'year'} onClick={() => setAdmissionsView('year')}>
|
||||
This year
|
||||
</button>
|
||||
<button type="button" aria-pressed={admissionsView === 'trend'} onClick={() => setAdmissionsView('trend')}>
|
||||
{admissionsHistory.length}-year trend
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{admissions.oversubscribed != null && (
|
||||
<div className={styles.admissionsVerdict}>
|
||||
<div className={styles.admissionsVerdictHeadline}>
|
||||
This school is{' '}
|
||||
<span className={admissions.oversubscribed ? styles.admissionsVerdictOver : styles.admissionsVerdictUnder}>
|
||||
{admissions.oversubscribed ? 'oversubscribed' : 'not oversubscribed'}
|
||||
</span>
|
||||
.
|
||||
</div>
|
||||
<div className={styles.admissionsVerdictSub}>
|
||||
{admissions.oversubscribed ? 'Demand exceeds capacity.' : 'Supply meets demand.'}
|
||||
</div>
|
||||
<div className={styles.admissionsViewport}>
|
||||
{/* This-year Q&A */}
|
||||
<div className={styles.admissionsViewYear} hidden={showAdmissionsTrend && admissionsView !== 'year'}>
|
||||
<dl className={styles.admissionsQa}>
|
||||
{admissions.places_offered != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many places were offered?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>{admissions.places_offered}</dd>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_applications != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many families wanted this school first?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>{admissions.first_preference_applications}</dd>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offer_pct != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many got their first choice?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>
|
||||
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
||||
<>
|
||||
{admissions.first_preference_offers}
|
||||
<span className={styles.admissionsQaAnswerSub}>
|
||||
of {admissions.first_preference_applications} ({formatPercentage(admissions.first_preference_offer_pct)})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
formatPercentage(admissions.first_preference_offer_pct)
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many applied in total?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>{admissions.total_applications.toLocaleString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className={styles.admissionsQa}>
|
||||
{admissions.places_offered != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many places were offered?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>{admissions.places_offered}</dd>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_applications != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many families wanted this school first?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>{admissions.first_preference_applications}</dd>
|
||||
</div>
|
||||
)}
|
||||
{admissions.first_preference_offer_pct != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many got their first choice?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>
|
||||
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
||||
<>
|
||||
{admissions.first_preference_offers}
|
||||
<span className={styles.admissionsQaAnswerSub}>
|
||||
of {admissions.first_preference_applications} ({formatPercentage(admissions.first_preference_offer_pct)})
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
formatPercentage(admissions.first_preference_offer_pct)
|
||||
{/* Multi-year trend */}
|
||||
{showAdmissionsTrend && (
|
||||
<div className={styles.admissionsViewTrend} hidden={admissionsView !== 'trend'}>
|
||||
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
||||
<OfferRateTrend history={admissionsHistory} />
|
||||
<p className={styles.admissionsTrendSummary}>
|
||||
This year ({formatAcademicYear(admissions.year)}),{' '}
|
||||
{admissions.first_preference_applications != null && (
|
||||
<><strong>{admissions.first_preference_applications}</strong> families put it first for </>
|
||||
)}
|
||||
</dd>
|
||||
{admissions.places_offered != null && <><strong>{admissions.places_offered}</strong> places</>}
|
||||
{admissions.total_applications != null && ` — ${admissions.total_applications.toLocaleString()} applications in total`}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{admissions.total_applications != null && (
|
||||
<div className={styles.admissionsQaRow}>
|
||||
<dt className={styles.admissionsQaQuestion}>How many applied in total?</dt>
|
||||
<dd className={styles.admissionsQaAnswer}>{admissions.total_applications.toLocaleString()}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user