PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m59s
The admissions card measured 1503px on a live school page — half the height of every section put together, and nearly three times the next largest — with its default view rendering as four tiles adrift in about 1080px of blank card. The cause was a layout trick meeting content it was never sized for. The admissions views are stacked in one grid cell so switching them never shifts layout, and the hidden ones keep their box: only visibility is dropped. That works while the views are comparable. The distance view added in #102 carries a chart, a table and a map, came to 1402px against the tile grid's 316px, and pinned every other view to its height — including the one that renders by default, which nobody had clicked. Rather than only unpinning it, the detail moves out. Every other topic on the page is a section with a nav entry, and "how close did we need to live, and would we have got in?" is a topic, not a variant reading of the intake figures. The headline number stays on the Admissions tile where the intake story is; the record behind it now lives in a Distance section directly below. admissions 1503px -> 554px distance new -> 743px (median section on the page is ~528px) Three further changes, each of which also makes the content better rather than only shorter: * The map renders on request. Before a postcode is entered it is a circle drawn round a school, and it costs a Leaflet bundle and 240px to say so; a successful check opens it automatically, which is the point at which it starts answering something. Map height 320px -> 240px. * The chart appears only at the four published years that let the summary state a direction. Below that we already refuse to call the series a trend, and a line through three points asserts one regardless of what the sentence beneath it admits. The table carries those years anyway, with the reasons a line cannot show. * Three caveat paragraphs become one. They said walking-route twice and made the same point about priorities in two voices. It now sits in CutoffDistanceDetail rather than inside the check, so it still renders for a school with coordinates missing, where there is a table but no map and no check. The new e2e guard asserts no section exceeds 2.5x the median section height. Measuring the card's internals cannot catch this: the tile grid is flex: 1, so it absorbs the stretch and every box still looks full. The first version of this test targeted an arbitrary primary, passed against the live bug, and proved nothing; pointed at a school that actually holds cut-off history it fails on staging with "#admissions is 1459px against a 526px median". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
227 lines
8.2 KiB
TypeScript
227 lines
8.2 KiB
TypeScript
'use client';
|
||
|
||
/**
|
||
* CutoffMapPanel — "Where the last place went".
|
||
*
|
||
* The rings and the postcode check live in one component because they are one
|
||
* question asked twice: the form answers "how far are we?" and the map shows
|
||
* that answer against the cut-offs. Entering a postcode drops a pin on the same
|
||
* rings rather than producing a separate verdict elsewhere on the page.
|
||
*
|
||
* The map is not rendered until asked for. Before a postcode is entered it is a
|
||
* circle drawn round a school, and it costs a Leaflet bundle and 240px of
|
||
* section height to say that; a successful check opens it automatically,
|
||
* because that is the point at which it starts answering something.
|
||
*
|
||
* The postcode never leaves the browser except to postcodes.io for a lat/long,
|
||
* and nothing is stored — this is a client-side measurement, not a lookup
|
||
* against the family.
|
||
*/
|
||
|
||
import { useState, useMemo, type FormEvent } from 'react';
|
||
import dynamic from 'next/dynamic';
|
||
import type { School } from '@/lib/types';
|
||
import { geocodePostcode, calculateDistance } from '@/lib/api';
|
||
import { isValidPostcode } from '@/lib/utils';
|
||
import {
|
||
compareToCutoffs,
|
||
type CutoffYearRow, type CutoffCheckResult, type CutoffVerdict,
|
||
} from './lastDistanceOffered';
|
||
import type { CutoffRing } from '../LeafletCutoffMapInner';
|
||
import styles from './schoolSections.module.css';
|
||
|
||
const CutoffMap = dynamic(() => import('../LeafletCutoffMapInner'), {
|
||
ssr: false,
|
||
loading: () => <div className={styles.cutoffMapSkeleton} aria-hidden="true" />,
|
||
});
|
||
|
||
const VERDICT_MARK: Record<CutoffVerdict, string> = {
|
||
inside: '✓',
|
||
outside: '✕',
|
||
'too-close': '≈',
|
||
unknown: '–',
|
||
};
|
||
|
||
const VERDICT_TITLE: Record<CutoffVerdict, string> = {
|
||
inside: 'Inside that year’s cut-off',
|
||
outside: 'Outside that year’s cut-off',
|
||
'too-close': 'Too close to the cut-off to say',
|
||
unknown: 'No published figure for this year',
|
||
};
|
||
|
||
// Written out rather than composed from the verdict string. A computed
|
||
// `styles[...]` key silently yields undefined when a class is renamed, and an
|
||
// unstyled "outside" chip would look exactly like an "inside" one.
|
||
const VERDICT_CLASS: Record<CutoffVerdict, string> = {
|
||
inside: styles.cutoffYearInside,
|
||
outside: styles.cutoffYearOutside,
|
||
'too-close': styles.cutoffYearTooClose,
|
||
unknown: styles.cutoffYearUnknown,
|
||
};
|
||
|
||
export function CutoffMapPanel({
|
||
schoolInfo,
|
||
rows,
|
||
}: {
|
||
schoolInfo: School;
|
||
rows: CutoffYearRow[];
|
||
}) {
|
||
const [postcode, setPostcode] = useState('');
|
||
const [home, setHome] = useState<{ lat: number; lng: number } | null>(null);
|
||
const [result, setResult] = useState<CutoffCheckResult | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
/* The map is not rendered until it is asked for. It costs a Leaflet bundle
|
||
and 240px of section height, and before a postcode is entered it is a
|
||
circle drawn round a school — it only starts answering a question once
|
||
there is a home to put beside the rings. A successful check therefore
|
||
opens it automatically. */
|
||
const [mapOpen, setMapOpen] = useState(false);
|
||
|
||
const lat = schoolInfo.latitude;
|
||
const lng = schoolInfo.longitude;
|
||
|
||
const rings: CutoffRing[] = useMemo(() => {
|
||
const published = rows.filter((r) => r.distanceM != null);
|
||
return published.map((r, i) => ({
|
||
year: r.year,
|
||
radiusM: r.distanceM as number,
|
||
// rows are newest-first, so the first published row is the current year.
|
||
latest: i === 0,
|
||
}));
|
||
}, [rows]);
|
||
|
||
// Without coordinates there is nothing to draw rings around and nothing to
|
||
// measure a postcode against.
|
||
if (lat == null || lng == null || rings.length === 0) return null;
|
||
|
||
const onCheck = async (e: FormEvent) => {
|
||
e.preventDefault();
|
||
const value = postcode.trim();
|
||
if (!value) return;
|
||
|
||
if (!isValidPostcode(value)) {
|
||
setError('That does not look like a UK postcode. Try one like SE23 3NA.');
|
||
setResult(null);
|
||
setHome(null);
|
||
return;
|
||
}
|
||
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const point = await geocodePostcode(value);
|
||
if (!point) {
|
||
setError('We could not find that postcode. Check it and try again.');
|
||
setResult(null);
|
||
setHome(null);
|
||
return;
|
||
}
|
||
setHome({ lat: point.latitude, lng: point.longitude });
|
||
setMapOpen(true);
|
||
// calculateDistance returns kilometres; everything here is metres.
|
||
const metres = calculateDistance(point.latitude, point.longitude, lat, lng) * 1000;
|
||
setResult(compareToCutoffs(metres, rows));
|
||
} catch {
|
||
setError('Something went wrong looking up that postcode. Try again in a moment.');
|
||
setResult(null);
|
||
setHome(null);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const latestRing = rings[0];
|
||
|
||
return (
|
||
<div className={styles.cutoffMapPanel}>
|
||
{/* No "Where the last place went" heading above this any more. It
|
||
introduced a map that now sits behind a button, so it named nothing,
|
||
and it echoed the section title directly above it. */}
|
||
<div className={styles.cutoffCheck}>
|
||
<h3 className={styles.cutoffCheckTitle}>How far away are you?</h3>
|
||
<p className={styles.cutoffCheckSub}>
|
||
Straight-line distance from your postcode, compared with each year’s
|
||
cut-off. Not stored.
|
||
</p>
|
||
|
||
<form className={styles.cutoffCheckForm} onSubmit={onCheck}>
|
||
<label className={styles.srOnly} htmlFor="cutoff-postcode">Your postcode</label>
|
||
<input
|
||
id="cutoff-postcode"
|
||
type="text"
|
||
inputMode="text"
|
||
autoComplete="postal-code"
|
||
spellCheck={false}
|
||
placeholder="e.g. SE23 3NA"
|
||
value={postcode}
|
||
onChange={(e) => setPostcode(e.target.value)}
|
||
className={styles.cutoffCheckInput}
|
||
/>
|
||
<button type="submit" className={styles.cutoffCheckButton} disabled={busy}>
|
||
{busy ? 'Checking…' : 'Check'}
|
||
</button>
|
||
</form>
|
||
|
||
{error && <p className={styles.cutoffCheckError} role="alert">{error}</p>}
|
||
|
||
{result && (
|
||
<div className={styles.cutoffCheckResult} role="status">
|
||
<p className={styles.cutoffCheckHeadline}>{result.headline}</p>
|
||
{result.detail && <p className={styles.cutoffCheckDetail}>{result.detail}</p>}
|
||
|
||
<ul className={styles.cutoffYearStrip}>
|
||
{[...result.years].reverse().map((y) => (
|
||
<li
|
||
key={y.year}
|
||
className={`${styles.cutoffYearChip} ${VERDICT_CLASS[y.verdict]}`}
|
||
title={VERDICT_TITLE[y.verdict]}
|
||
>
|
||
<span className={styles.cutoffYearChipYear}>{y.year}</span>
|
||
<span aria-hidden="true">{VERDICT_MARK[y.verdict]}</span>
|
||
<span className={styles.srOnly}>{VERDICT_TITLE[y.verdict]}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{mapOpen ? (
|
||
<div className={styles.cutoffMapReveal}>
|
||
<div className={styles.cutoffMapFigure}>
|
||
<CutoffMap lat={lat} lng={lng} rings={rings} home={home} interactive={false} />
|
||
</div>
|
||
<ul className={styles.cutoffMapLegend}>
|
||
<li>
|
||
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchNow}`} aria-hidden="true" />
|
||
{latestRing.year} cut-off
|
||
</li>
|
||
{rings.length > 1 && (
|
||
<li>
|
||
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchPast}`} aria-hidden="true" />
|
||
Earlier years, {rings[rings.length - 1].year}–{rings[1].year}
|
||
</li>
|
||
)}
|
||
{home && (
|
||
<li>
|
||
<span className={`${styles.cutoffSwatch} ${styles.cutoffSwatchHome}`} aria-hidden="true" />
|
||
Your postcode
|
||
</li>
|
||
)}
|
||
</ul>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className={styles.cutoffMapToggle}
|
||
onClick={() => setMapOpen(true)}
|
||
>
|
||
Show these distances on a map
|
||
</button>
|
||
)}
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|