Files
school_compare/nextjs-app/components/AdmissionsView.tsx
T
TudorandClaude Opus 4.8 15da060e4a
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m1s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 42s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
feat(admissions): surface easy-to-miss deadlines as callout boxes
The selective-school registration and faith-school SIF warnings were
added as prose in the criteria steps, where they read as ordinary
sentences and were easy to skim past. Pulled them into structured
callout boxes with an amber "note" treatment (using the page's own
gold accent tokens), a warning glyph, and a bold lead-in — so the two
deadlines parents most often miss actually stand out.

- Added a `callouts` field to Step; render as a callout group between
  the body and the tool link (both primary and secondary timelines).
- Reverted the warning text back out of the step bodies so it isn't
  duplicated.

Verified with tsc --noEmit and next build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-21 12:22:49 +01:00

438 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
import styles from './AdmissionsView.module.css';
/* ─── Date helpers ─────────────────────────────────────── */
function daysUntil(month: number, day: number): number {
const today = new Date();
today.setHours(0, 0, 0, 0);
const y = today.getFullYear();
let target = new Date(y, month - 1, day);
if (target < today) target = new Date(y + 1, month - 1, day);
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
}
function nextDate(month: number, day: number): Date {
const today = new Date();
today.setHours(0, 0, 0, 0);
const y = today.getFullYear();
const target = new Date(y, month - 1, day);
if (target <= today) return new Date(y + 1, month - 1, day);
return target;
}
function fmtDate(month: number, day: number): string {
return nextDate(month, day).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
/* ─── Data ─────────────────────────────────────────────── */
interface Chip {
type: 'deadline' | 'offer';
track: string;
milestone: string;
month: number;
day: number;
}
const CHIPS: Chip[] = [
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
{ type: 'deadline', track: 'Primary · Deadline', milestone: 'Primary applications close', month: 1, day: 15 },
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
];
interface Step {
date?: string;
title: string;
body: string;
highlight?: 'deadline' | 'offer';
/* Easy-to-miss, deadline-sensitive warnings surfaced as callout boxes
rather than buried in the body prose. */
callouts?: { title: string; body: string }[];
/* How SchoolCompare helps at this stage of the journey */
tool?: { label: string; href: string };
}
const SECONDARY_STEPS: Step[] = [
{
title: 'Check entry criteria',
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely.',
callouts: [
{
title: 'Selective schools register earlier',
body: 'Grammar and other selective schools need separate entrance-test registration months earlier — often by September, well before the 31 October deadline.',
},
{
title: 'Faith schools may need an extra form',
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, on top of the council application.',
},
],
tool: { label: 'Find schools & view their admissions history', href: '/' },
},
{
date: 'September',
title: 'Portal opens',
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
tool: { label: 'Build and compare your shortlist', href: '/compare' },
},
{
date: '31 October',
title: 'Application deadline',
body: 'Submit your ranked list of schools — most councils allow three to six preferences (London boroughs typically six). Councils treat all preferences equally, so list schools in the genuine order you want them, not strategically.',
highlight: 'deadline',
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
},
{
date: '1 March',
title: 'National Offer Day',
body: 'Your allocated school appears on the council\'s online admissions portal and is sent by email. Release times are set by each council — often from late afternoon, though some publish overnight — so check your council\'s page rather than refreshing at midnight.',
highlight: 'offer',
tool: { label: 'Look up your allocated school', href: '/' },
},
{
date: '~15 March',
title: 'Accept or decline',
body: 'Respond by the deadline your council gives — typically around 15 March. Accepting does not prevent you from keeping a place on a waiting list for a preferred school.',
tool: { label: 'Weigh your offer against your other choices', href: '/compare' },
},
{
title: 'Appeals',
body: 'If unsuccessful, you can appeal within 20 school days of the refusal letter. Secondary appeals consider whether prejudice to the school outweighs your case — success rates vary.',
tool: { label: 'Gather performance evidence for your case', href: '/rankings' },
},
];
const PRIMARY_STEPS: Step[] = [
{
title: 'Research entry criteria',
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
callouts: [
{
title: 'Faith schools may need an extra form',
body: 'Many faith schools require a supplementary information form (SIF) sent directly to the school, in addition to the council application.',
},
],
tool: { label: 'Find schools & view their admissions history', href: '/' },
},
{
date: 'September',
title: 'Portal opens',
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
tool: { label: 'Build and compare your shortlist', href: '/compare' },
},
{
date: '15 January',
title: 'Application deadline',
body: 'List up to 36 schools (the number varies by council) in genuine preference order. The equal preference rule means all preferences are considered before any offers are made.',
highlight: 'deadline',
tool: { label: 'Compare performance to order your preferences', href: '/rankings' },
},
{
date: '16 April',
title: 'National Offer Day',
body: 'Results are published online. Reception offers are sent on 16 April (or the next working day if that falls on a weekend or bank holiday).',
highlight: 'offer',
tool: { label: 'Look up your allocated school', href: '/' },
},
{
date: '~1 May',
title: 'Accept or decline',
body: 'Respond by your council\'s deadline, typically around 1 May. Accepting secures the place while you wait to see if a preferred school\'s waiting list moves.',
tool: { label: 'Weigh your offer against your other choices', href: '/compare' },
},
{
title: 'Appeals',
body: 'Infant class-size appeals (Reception to Year 2) have a very narrow legal test and a low success rate. For Year 3+, appeals follow the same process as secondary.',
tool: { label: 'Gather performance evidence for your case', href: '/rankings' },
},
];
interface Tip {
heading: string;
body: string;
}
const TIPS: Tip[] = [
{
heading: 'Equal preference rule',
body: 'Councils consider every school you list before making any offer, then give you the highest-ranked one you qualify for. Your order only decides which qualifying school you get — you can\'t game it, so list schools in the order you genuinely want them.',
},
{
heading: 'Late applications go to the back',
body: 'Submit before the deadline even if your child does not turn the required age until later in the year. Late applicants are only considered after all on-time applications.',
},
{
heading: 'Waiting lists',
body: 'You can go on waiting lists for multiple schools simultaneously. Lists are ordered by admissions criteria, not when you joined. They can move significantly over the summer.',
},
];
/* ─── Component ────────────────────────────────────────── */
const NAV_ITEMS = [
{ id: 'primary', label: 'Primary' },
{ id: 'secondary', label: 'Secondary' },
{ id: 'tips', label: 'Tips' },
] as const;
type NavId = typeof NAV_ITEMS[number]['id'];
export function AdmissionsView() {
const [chipDays, setChipDays] = useState<(number | null)[]>(CHIPS.map(() => null));
const [activeId, setActiveId] = useState<NavId>('primary');
useEffect(() => {
setChipDays(CHIPS.map(c => daysUntil(c.month, c.day)));
}, []);
useEffect(() => {
const sections = NAV_ITEMS
.map(item => document.getElementById(item.id))
.filter((el): el is HTMLElement => el !== null);
if (sections.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter(e => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visible[0]) setActiveId(visible[0].target.id as NavId);
},
{ rootMargin: '-30% 0px -55% 0px', threshold: [0, 0.25, 0.5, 0.75, 1] },
);
sections.forEach(s => observer.observe(s));
return () => observer.disconnect();
}, []);
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, id: NavId) => {
e.preventDefault();
const el = document.getElementById(id);
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - 16;
window.scrollTo({ top, behavior: 'smooth' });
setActiveId(id);
};
return (
<div className={styles.shell}>
{/* In-page nav — sticky left rail on desktop, sticky top pills on mobile */}
<aside className={styles.nav} aria-label="On this page">
<div className={styles.navLabel}>On this page</div>
<ul className={styles.navList}>
{NAV_ITEMS.map(item => {
const isActive = activeId === item.id;
return (
<li key={item.id}>
<a
href={`#${item.id}`}
onClick={(e) => handleNavClick(e, item.id)}
className={[styles.navLink, isActive ? styles.navLinkActive : ''].filter(Boolean).join(' ')}
aria-current={isActive ? 'true' : undefined}
>
<span className={styles.navDot} aria-hidden="true" />
<span>{item.label}</span>
</a>
</li>
);
})}
</ul>
</aside>
<div className={styles.page}>
{/* Hero */}
<section className={styles.hero}>
<span className={styles.eyebrow}>
<span className={styles.eyebrowDot} aria-hidden="true" />
England · Primary &amp; Secondary
</span>
<h1 className={styles.heroTitle}>School Admissions Guide</h1>
<p className={styles.heroSub}>
Everything parents need to know about applying for a school place in England from opening dates to National Offer Day, with live countdowns to every key milestone.
</p>
</section>
{/* Countdown strip */}
<section className={styles.countdownSection}>
<div className={styles.stripHeader}>
<span className={styles.stripLabel}>Days until next milestone</span>
</div>
<div className={styles.countdownRail}>
{CHIPS.map((chip, i) => {
const days = chipDays[i];
const isUrgent = days !== null && days <= 14;
const chipClass = [
styles.chip,
chip.type === 'deadline' ? styles.chipDeadline : styles.chipOffer,
isUrgent ? styles.chipUrgent : '',
].filter(Boolean).join(' ');
return (
<div key={chip.milestone} className={chipClass}>
<span className={[styles.chipTrack, chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer].join(' ')}>
<span className={styles.chipTrackDot} aria-hidden="true" />
{chip.track}
</span>
<div>
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
</div>
<div className={styles.chipMilestone}>{chip.milestone}</div>
<div className={styles.chipDate}>{days !== null ? fmtDate(chip.month, chip.day) : ''}</div>
</div>
);
})}
</div>
</section>
{/* Primary track */}
<section id="primary" className={styles.track} style={{ scrollMarginTop: '1rem' }}>
<div className={styles.trackHeader}>
<div className={styles.trackHeaderLeft}>
<span className={styles.trackKicker}>Reception entry</span>
<h2 className={styles.trackTitle}>Primary school admissions</h2>
<p className={styles.trackSub}>For children starting Reception (Year R) in September. Applications are submitted in the autumn of the year before entry.</p>
</div>
<div className={styles.trackDates}>
<div className={styles.trackDateRow}>
<span className={styles.trackDateLabel}>Deadline</span>
<span className={styles.trackDateVal}>{fmtDate(1, 15)}</span>
</div>
<div className={styles.trackDateRow}>
<span className={styles.trackDateLabel}>Offer Day</span>
<span className={styles.trackDateVal}>{fmtDate(4, 16)}</span>
</div>
</div>
</div>
<ol className={styles.timeline}>
{PRIMARY_STEPS.map((step, i) => (
<li key={i} className={[styles.step, step.highlight === 'deadline' ? styles.stepDeadline : step.highlight === 'offer' ? styles.stepOffer : ''].filter(Boolean).join(' ')}>
<div className={styles.stepDotCol}>
<div className={styles.stepDot} />
{i < PRIMARY_STEPS.length - 1 && <div className={styles.stepLine} />}
</div>
<div className={styles.stepContent}>
{step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div>
<p className={styles.stepBody}>{step.body}</p>
{step.callouts && step.callouts.length > 0 && (
<div className={styles.calloutGroup}>
{step.callouts.map((c) => (
<div key={c.title} className={styles.callout}>
<span className={styles.calloutIcon} aria-hidden="true">!</span>
<div className={styles.calloutText}>
<div className={styles.calloutTitle}>{c.title}</div>
<p className={styles.calloutBody}>{c.body}</p>
</div>
</div>
))}
</div>
)}
{step.tool && (
<a
href={step.tool.href}
className={styles.stepTool}
data-umami-event="admissions_tool_clicked"
data-umami-event-target={step.tool.href}
>
{step.tool.label}
<span className={styles.stepToolArrow} aria-hidden="true"></span>
</a>
)}
</div>
</li>
))}
</ol>
</section>
{/* Secondary track */}
<section id="secondary" className={styles.track} style={{ scrollMarginTop: '1rem' }}>
<div className={styles.trackHeader}>
<div className={styles.trackHeaderLeft}>
<span className={styles.trackKicker}>Year 7 entry</span>
<h2 className={styles.trackTitle}>Secondary school admissions</h2>
<p className={styles.trackSub}>For children starting secondary school (Year 7) in September. Applications are submitted in the autumn of Year 6.</p>
</div>
<div className={styles.trackDates}>
<div className={styles.trackDateRow}>
<span className={styles.trackDateLabel}>Deadline</span>
<span className={styles.trackDateVal}>{fmtDate(10, 31)}</span>
</div>
<div className={styles.trackDateRow}>
<span className={styles.trackDateLabel}>Offer Day</span>
<span className={styles.trackDateVal}>{fmtDate(3, 1)}</span>
</div>
</div>
</div>
<ol className={styles.timeline}>
{SECONDARY_STEPS.map((step, i) => (
<li key={i} className={[styles.step, step.highlight === 'deadline' ? styles.stepDeadline : step.highlight === 'offer' ? styles.stepOffer : ''].filter(Boolean).join(' ')}>
<div className={styles.stepDotCol}>
<div className={styles.stepDot} />
{i < SECONDARY_STEPS.length - 1 && <div className={styles.stepLine} />}
</div>
<div className={styles.stepContent}>
{step.date && <div className={styles.stepDate}>{step.date}</div>}
<div className={styles.stepTitle}>{step.title}</div>
<p className={styles.stepBody}>{step.body}</p>
{step.callouts && step.callouts.length > 0 && (
<div className={styles.calloutGroup}>
{step.callouts.map((c) => (
<div key={c.title} className={styles.callout}>
<span className={styles.calloutIcon} aria-hidden="true">!</span>
<div className={styles.calloutText}>
<div className={styles.calloutTitle}>{c.title}</div>
<p className={styles.calloutBody}>{c.body}</p>
</div>
</div>
))}
</div>
)}
{step.tool && (
<a
href={step.tool.href}
className={styles.stepTool}
data-umami-event="admissions_tool_clicked"
data-umami-event-target={step.tool.href}
>
{step.tool.label}
<span className={styles.stepToolArrow} aria-hidden="true"></span>
</a>
)}
</div>
</li>
))}
</ol>
</section>
{/* Tips */}
<section id="tips" className={styles.tips} style={{ scrollMarginTop: '1rem' }}>
<h2 className={styles.tipsHeading}>Three things most parents get wrong</h2>
<div className={styles.tipsGrid}>
{TIPS.map((tip, i) => (
<div key={i} className={styles.tipCard}>
<div className={styles.tipNumber}>{String(i + 1).padStart(2, '0')}</div>
<h3 className={styles.tipHeading}>{tip.heading}</h3>
<p className={styles.tipBody}>{tip.body}</p>
</div>
))}
</div>
</section>
</div>
</div>
);
}