diff --git a/mockups/admissions-chartjs-check.html b/mockups/admissions-chartjs-check.html new file mode 100644 index 0000000..860bcdb --- /dev/null +++ b/mockups/admissions-chartjs-check.html @@ -0,0 +1,73 @@ + + + + + +Admissions trend — Chart.js render check + + + + + +
+
+
+
How Hard to Get Into This School
+
+
+
First-choice offer rate
+
+

This year (2026/27), 30 families put it first for 30 places — 101 applications in total.

+
+
+ + + diff --git a/nextjs-app/components/AdmissionsTrendChart.module.css b/nextjs-app/components/AdmissionsTrendChart.module.css new file mode 100644 index 0000000..2140d63 --- /dev/null +++ b/nextjs-app/components/AdmissionsTrendChart.module.css @@ -0,0 +1,10 @@ +.wrapper { + width: 100%; + height: 200px; +} + +@media (max-width: 640px) { + .wrapper { + height: 180px; + } +} diff --git a/nextjs-app/components/AdmissionsTrendChart.tsx b/nextjs-app/components/AdmissionsTrendChart.tsx new file mode 100644 index 0000000..cd80661 --- /dev/null +++ b/nextjs-app/components/AdmissionsTrendChart.tsx @@ -0,0 +1,87 @@ +'use client'; + +/** + * AdmissionsTrendChart + * Compact line chart of the first-choice offer rate across admissions years. + * Renders nothing unless at least two years carry an offer-rate value. + */ + +import { Line } from 'react-chartjs-2'; +import { ChartOptions } from 'chart.js'; +import '@/lib/chartSetup'; +import { formatAcademicYear } from '@/lib/utils'; +import type { SchoolAdmissions } from '@/lib/types'; +import styles from './AdmissionsTrendChart.module.css'; + +export default function AdmissionsTrendChart({ history }: { history: SchoolAdmissions[] }) { + const pts = history.filter((h) => h.first_preference_offer_pct != null); + if (pts.length < 2) return null; + + const labels = pts.map((p) => formatAcademicYear(p.year)); + const values = pts.map((p) => p.first_preference_offer_pct as number); + const lastIdx = pts.length - 1; + + // Auto-scale with headroom so variation is visible, clamped to 0–100. + const lo = Math.min(...values); + const hi = Math.max(...values); + const padded = Math.max(5, Math.round((hi - lo) * 0.25)); + const yMin = Math.max(0, Math.floor((lo - padded) / 5) * 5); + const yMax = Math.min(100, Math.ceil((hi + padded) / 5) * 5); + + const options: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { display: false }, + title: { display: false }, + tooltip: { + backgroundColor: 'rgba(26,22,18,0.92)', + padding: 10, + titleFont: { size: 12 }, + bodyFont: { size: 12 }, + callbacks: { + label: (ctx) => (ctx.parsed.y == null ? '' : `First-choice offers: ${Math.round(ctx.parsed.y)}%`), + }, + }, + }, + scales: { + y: { + min: yMin, + max: yMax, + grid: { color: 'rgba(0,0,0,0.05)' }, + ticks: { font: { size: 11 }, maxTicksLimit: 5, callback: (v) => `${v}%` }, + }, + x: { + grid: { display: false }, + ticks: { font: { size: 11 }, autoSkip: true, maxRotation: 0, autoSkipPadding: 16 }, + }, + }, + }; + + const data = { + labels, + datasets: [ + { + label: 'First-choice offer rate', + data: values, + borderColor: '#e07256', + backgroundColor: 'rgba(224,114,86,0.10)', + borderWidth: 2.5, + tension: 0.3, + fill: true, + pointRadius: pts.map((_, i) => (i === lastIdx ? 5 : 3)), + pointBackgroundColor: '#e07256', + pointBorderColor: '#fff', + pointBorderWidth: pts.map((_, i) => (i === lastIdx ? 2 : 0)), + pointHoverRadius: 6, + }, + ], + }; + + return ( +
+ +
+ ); +} diff --git a/nextjs-app/components/SchoolDetailView.module.css b/nextjs-app/components/SchoolDetailView.module.css index aabce08..9c03702 100644 --- a/nextjs-app/components/SchoolDetailView.module.css +++ b/nextjs-app/components/SchoolDetailView.module.css @@ -1381,49 +1381,6 @@ margin-bottom: 0.5rem; } -.admissionsChart { - width: 100%; - height: auto; - display: block; -} - -.admissionsGrid { - stroke: var(--border-color, #e5dfd5); - stroke-width: 1; -} - -.admissionsAxis { - font-size: 12.5px; - fill: var(--text-muted, #6d685f); - font-family: var(--font-dm-sans), "DM Sans", sans-serif; -} - -.admissionsLine { - stroke: var(--accent-coral, #e07256); - stroke-width: 3; -} - -.admissionsDot { - fill: var(--accent-coral, #e07256); -} - -.admissionsDotLast { - fill: var(--accent-coral, #e07256); - stroke: var(--bg-card, #fff); - stroke-width: 2; -} - -.admissionsPtLabel { - font-size: 13px; - font-weight: 700; - fill: var(--text-primary, #1a1612); - font-family: var(--font-dm-sans), "DM Sans", sans-serif; -} - -.admissionsPtLabel[data-last="true"] { - fill: var(--accent-coral-dark, #c45a3f); -} - .admissionsTrendSummary { font-size: 1rem; color: var(--text-secondary, #5c564d); diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 57633c8..7a37e36 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -28,6 +28,7 @@ const PerformanceChart = dynamic( { ssr: false }, ); const SatsChart = dynamic(() => import('./SatsChart'), { ssr: false }); +const AdmissionsTrendChart = dynamic(() => import('./AdmissionsTrendChart'), { ssr: false }); import { track, getNavigationSource } from '@/lib/analytics'; import styles from './SchoolDetailView.module.css'; @@ -58,62 +59,6 @@ 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 ( - - {gridVals.map((gv) => ( - - - {gv}% - - ))} - - {pts.map((p, i) => { - const isLast = i === pts.length - 1; - return ( - - - {Math.round(p.v)}% - {formatAcademicYear(p.year)} - - ); - })} - - ); -} - interface SchoolDetailViewProps { schoolInfo: School; yearlyData: SchoolResult[]; @@ -1003,7 +948,7 @@ export function SchoolDetailView({ {showAdmissionsTrend && (