Files
school_compare/nextjs-app/components/AdmissionsView.tsx
T
Tudor Sitaru 795e2bae35
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 20s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
fix(ui): countdown shows Today correctly — use < not <= in daysUntil
<= caused today's date to roll forward to next year (returning 365).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 12:28:31 +01:00

292 lines
12 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';
}
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. Use school detail pages on SchoolCompare for admissions history.',
},
{
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.',
},
{
date: '31 October',
title: 'Application deadline',
body: 'Submit your ranked list of up to six schools. Councils treat all preferences equally — list schools in the genuine order you want them, not strategically.',
highlight: 'deadline',
},
{
date: '1 March',
title: 'National Offer Day',
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
highlight: 'offer',
},
{
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.',
},
{
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.',
},
];
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.',
},
{
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.',
},
{
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',
},
{
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',
},
{
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.',
},
{
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.',
},
];
interface Tip {
heading: string;
body: string;
}
const TIPS: Tip[] = [
{
heading: 'Equal preference rule',
body: 'Councils rank offers by your eligibility for each school, not by the order you listed them. You cannot game the system — put schools in the order you actually 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 ────────────────────────────────────────── */
export function AdmissionsView() {
const [chipDays, setChipDays] = useState<(number | null)[]>(CHIPS.map(() => null));
useEffect(() => {
setChipDays(CHIPS.map(c => daysUntil(c.month, c.day)));
}, []);
return (
<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>
{/* Secondary track */}
<section className={styles.track}>
<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>
</div>
</li>
))}
</ol>
</section>
{/* Primary track */}
<section className={styles.track}>
<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>
</div>
</li>
))}
</ol>
</section>
{/* Tips */}
<section className={styles.tips}>
<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>
);
}