Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5b93a2896 |
No files matched your search
@@ -5,6 +5,3 @@ __pycache__/
|
|||||||
pipeline/transform/target/
|
pipeline/transform/target/
|
||||||
pipeline/transform/logs/
|
pipeline/transform/logs/
|
||||||
pipeline/transform/.user.yml
|
pipeline/transform/.user.yml
|
||||||
|
|
||||||
# Playwright MCP scratch output (screenshots, console logs, page snapshots)
|
|
||||||
.playwright-mcp/
|
|
||||||
@@ -632,7 +632,6 @@ async def get_school_details(request: Request, urn: int):
|
|||||||
"census": supplementary.get("census"),
|
"census": supplementary.get("census"),
|
||||||
"admissions": supplementary.get("admissions"),
|
"admissions": supplementary.get("admissions"),
|
||||||
"admissions_history": supplementary.get("admissions_history") or [],
|
"admissions_history": supplementary.get("admissions_history") or [],
|
||||||
"admission_distance": supplementary.get("admission_distance"),
|
|
||||||
"sen_detail": supplementary.get("sen_detail"),
|
"sen_detail": supplementary.get("sen_detail"),
|
||||||
"phonics": supplementary.get("phonics"),
|
"phonics": supplementary.get("phonics"),
|
||||||
"deprivation": supplementary.get("deprivation"),
|
"deprivation": supplementary.get("deprivation"),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from .config import settings
|
|||||||
from .database import SessionLocal, engine
|
from .database import SessionLocal, engine
|
||||||
from .models import (
|
from .models import (
|
||||||
DimSchool, DimLocation, KS2Performance,
|
DimSchool, DimLocation, KS2Performance,
|
||||||
FactOfstedInspection, FactAdmissions, FactAdmissionDistance,
|
FactOfstedInspection, FactAdmissions,
|
||||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||||
)
|
)
|
||||||
from .ofsted_codes import ofsted_page_url, report_card_labels
|
from .ofsted_codes import ofsted_page_url, report_card_labels
|
||||||
@@ -723,17 +723,6 @@ def _admissions_row_dict(a) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _admission_distance_dict(d) -> dict:
|
|
||||||
"""Serialize one fact_admission_distance row for API responses."""
|
|
||||||
return {
|
|
||||||
"year": d.year,
|
|
||||||
"distance_m": d.distance_m,
|
|
||||||
"route_count": d.route_count,
|
|
||||||
"la_name": d.la_name,
|
|
||||||
"distance_unit_raw": d.distance_unit_raw,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _census_dict(pc) -> dict:
|
def _census_dict(pc) -> dict:
|
||||||
return {
|
return {
|
||||||
"year": pc.year,
|
"year": pc.year,
|
||||||
@@ -770,7 +759,6 @@ def _empty_supplementary() -> dict:
|
|||||||
"census": None,
|
"census": None,
|
||||||
"admissions": None,
|
"admissions": None,
|
||||||
"admissions_history": [],
|
"admissions_history": [],
|
||||||
"admission_distance": None,
|
|
||||||
"sen_detail": None,
|
"sen_detail": None,
|
||||||
"phonics": None,
|
"phonics": None,
|
||||||
"deprivation": None,
|
"deprivation": None,
|
||||||
@@ -849,27 +837,6 @@ def get_supplementary_data_batch(db: Session, urns: list[int]) -> dict:
|
|||||||
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
result[urn]["admissions"] = rows_for_urn[-1] if rows_for_urn else None
|
||||||
_safe(_admissions)
|
_safe(_admissions)
|
||||||
|
|
||||||
# Last distance offered — latest year per URN.
|
|
||||||
#
|
|
||||||
# Only the most recent year is served. Coverage is ragged (a school may
|
|
||||||
# have 2021 and 2026 but nothing between), so a history array would invite
|
|
||||||
# a trend line drawn through gaps that are absences of publication rather
|
|
||||||
# than absences of a cut-off.
|
|
||||||
def _admission_distance():
|
|
||||||
rows = (
|
|
||||||
db.query(FactAdmissionDistance)
|
|
||||||
.filter(FactAdmissionDistance.urn.in_(urns))
|
|
||||||
.order_by(FactAdmissionDistance.urn, FactAdmissionDistance.year.desc())
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
seen = set()
|
|
||||||
for d in rows:
|
|
||||||
if d.urn in seen:
|
|
||||||
continue
|
|
||||||
seen.add(d.urn)
|
|
||||||
result[d.urn]["admission_distance"] = _admission_distance_dict(d)
|
|
||||||
_safe(_admission_distance)
|
|
||||||
|
|
||||||
# Deprivation — one row per URN.
|
# Deprivation — one row per URN.
|
||||||
def _deprivation():
|
def _deprivation():
|
||||||
rows = (
|
rows = (
|
||||||
|
|||||||
@@ -188,37 +188,6 @@ class FactAdmissions(Base):
|
|||||||
admissions_policy = Column(String(100))
|
admissions_policy = Column(String(100))
|
||||||
|
|
||||||
|
|
||||||
class FactAdmissionDistance(Base):
|
|
||||||
"""Last distance offered — one row per URN per year.
|
|
||||||
|
|
||||||
Separate from FactAdmissions because the source is separate: EES publishes
|
|
||||||
admissions for the whole country, whereas cut-off distances exist only for
|
|
||||||
the local authorities that choose to publish them (57 at the time of
|
|
||||||
writing), and the two refresh on unrelated timetables.
|
|
||||||
"""
|
|
||||||
__tablename__ = "fact_admission_distance"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_admission_distance_urn_year", "urn", "year"),
|
|
||||||
MARTS,
|
|
||||||
)
|
|
||||||
|
|
||||||
urn = Column(Integer, primary_key=True)
|
|
||||||
year = Column(Integer, primary_key=True)
|
|
||||||
# Straight-line distance in metres from the school to the last home offered
|
|
||||||
# a place that year.
|
|
||||||
distance_m = Column(Float)
|
|
||||||
# How many admission routes (ability bands, separate reception/junior
|
|
||||||
# intakes) were collapsed into distance_m. >1 means the figure is the
|
|
||||||
# furthest of several and the page must say so.
|
|
||||||
route_count = Column(Integer)
|
|
||||||
la_code = Column(Integer)
|
|
||||||
la_name = Column(String(100))
|
|
||||||
# The unit the council published in, so the page can lead with the unit a
|
|
||||||
# parent was given rather than always converting.
|
|
||||||
distance_unit_raw = Column(String(20))
|
|
||||||
source_file = Column(Text)
|
|
||||||
|
|
||||||
|
|
||||||
class FactPupilCharacteristics(Base):
|
class FactPupilCharacteristics(Base):
|
||||||
"""School pupil composition from EES census — one row per URN per year."""
|
"""School pupil composition from EES census — one row per URN per year."""
|
||||||
__tablename__ = "fact_pupil_characteristics"
|
__tablename__ = "fact_pupil_characteristics"
|
||||||
|
|||||||
@@ -69,13 +69,6 @@ def _adm_row(urn, year):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _dist_row(urn, year, distance_m, route_count=1):
|
|
||||||
return types.SimpleNamespace(
|
|
||||||
urn=urn, year=year, distance_m=distance_m, route_count=route_count,
|
|
||||||
la_name="Camden", distance_unit_raw="miles", source_file="camden/guide.pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_one_query_per_table_and_latest_row_per_urn():
|
def test_one_query_per_table_and_latest_row_per_urn():
|
||||||
rows = {
|
rows = {
|
||||||
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
# URN 1 has two Ofsted rows; the batch must keep the most recent (2023).
|
||||||
@@ -85,14 +78,6 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
|||||||
_ofsted_row(2, "2021-06-01", 1),
|
_ofsted_row(2, "2021-06-01", 1),
|
||||||
],
|
],
|
||||||
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
"FactAdmissions": [_adm_row(1, 202526), _adm_row(1, 202627), _adm_row(2, 202627)],
|
||||||
# URN 1 has three years of cut-offs; only the most recent is served.
|
|
||||||
# Deliberately not in year order — the ordering is the query's job.
|
|
||||||
"FactAdmissionDistance": [
|
|
||||||
_dist_row(1, 2026, 529.47),
|
|
||||||
_dist_row(1, 2024, 772.49),
|
|
||||||
_dist_row(1, 2025, 1421.05),
|
|
||||||
_dist_row(2, 2023, 2029.38, route_count=4),
|
|
||||||
],
|
|
||||||
"FactPupilCharacteristics": [],
|
"FactPupilCharacteristics": [],
|
||||||
"FactDeprivation": [],
|
"FactDeprivation": [],
|
||||||
"FactFinance": [],
|
"FactFinance": [],
|
||||||
@@ -100,10 +85,10 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
|||||||
session = _FakeSession(rows)
|
session = _FakeSession(rows)
|
||||||
out = get_supplementary_data_batch(session, [1, 2])
|
out = get_supplementary_data_batch(session, [1, 2])
|
||||||
|
|
||||||
# Exactly one query per table — six total, regardless of two URNs.
|
# Exactly one query per table — five total, regardless of two URNs.
|
||||||
assert sorted(session.queries) == [
|
assert sorted(session.queries) == [
|
||||||
"FactAdmissionDistance", "FactAdmissions", "FactDeprivation",
|
"FactAdmissions", "FactDeprivation", "FactFinance",
|
||||||
"FactFinance", "FactOfstedInspection", "FactPupilCharacteristics",
|
"FactOfstedInspection", "FactPupilCharacteristics",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Latest Ofsted kept per URN
|
# Latest Ofsted kept per URN
|
||||||
@@ -115,14 +100,6 @@ def test_one_query_per_table_and_latest_row_per_urn():
|
|||||||
assert out[1]["admissions"]["year"] == 202627
|
assert out[1]["admissions"]["year"] == 202627
|
||||||
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
assert out[2]["admissions_history"] == [{**out[2]["admissions_history"][0]}]
|
||||||
|
|
||||||
# Cut-off distance: latest year only, per URN. Coverage is ragged, so
|
|
||||||
# serving anything but the newest would show a parent a stale figure.
|
|
||||||
assert out[1]["admission_distance"]["year"] == 2026
|
|
||||||
assert out[1]["admission_distance"]["distance_m"] == 529.47
|
|
||||||
# route_count travels with the figure — the page needs it to say the
|
|
||||||
# distance is the furthest of several bands rather than the only one.
|
|
||||||
assert out[2]["admission_distance"]["route_count"] == 4
|
|
||||||
|
|
||||||
# Empty tables degrade to the null block, not a crash
|
# Empty tables degrade to the null block, not a crash
|
||||||
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
assert out[1]["census"] is None and out[1]["deprivation"] is None
|
||||||
|
|
||||||
@@ -132,4 +109,3 @@ def test_single_wrapper_matches_batch(monkeypatch):
|
|||||||
single = data_loader.get_supplementary_data(session, 5)
|
single = data_loader.get_supplementary_data(session, 5)
|
||||||
assert single["ofsted"]["overall_effectiveness"] == 2
|
assert single["ofsted"]["overall_effectiveness"] == 2
|
||||||
assert single["admissions_history"] == []
|
assert single["admissions_history"] == []
|
||||||
assert single["admission_distance"] is None
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
|
- Screenshot `j1-postcode-results-mobile.png` confirms cards carry only the LA name ("Solihull"), no "X miles away".
|
||||||
|
|
||||||
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
|
**Desktop (1440×900) — Attempt A repeat + keyboard + zoom.**
|
||||||
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("27,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
|
- Desktop hero additionally shows a trust badge ("● UPDATED WITH 2026/2027 ADMISSIONS RESULTS") and a value-prop subheading ("24,000+ primary and secondary schools with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place"). **Both are absent on the mobile hero** (mobile jumps H1 → search box).
|
||||||
- Name search identical, correct, instant (`j1-search-results-desktop.png`).
|
- Name search identical, correct, instant (`j1-search-results-desktop.png`).
|
||||||
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
|
- Keyboard: tab order is logical — Skip link → logo → Search/Compare/Rankings/Admissions nav → search input → Search button → Schools near me → content. Skip link, logo, nav links and Search button all get a clear **2px solid orange (#E07256) focus outline**. The search input uses an orange border + very faint ring (`box-shadow rgba(224,114,86,0.12) 0 0 0 3px`, `outline:none`) — visible but weaker than the other controls. Search → results → school link is fully keyboard-operable (standard links/buttons).
|
||||||
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
|
- 200% reflow (720×450): **no horizontal scroll** (`scrollWidth == clientWidth == 720`); deadline cards reflow from 1×4 to 2×2, no overlap or clipping. Pass.
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
- Severity guess: P2
|
- Severity guess: P2
|
||||||
|
|
||||||
- **F6. Mobile hero omits the value proposition shown on desktop**
|
- **F6. Mobile hero omits the value proposition shown on desktop**
|
||||||
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "27,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
|
- Evidence: Desktop hero has the "UPDATED WITH 2026/2027…" badge + subheading "24,000+ primary and secondary schools with KS2 SATs, GCSE results, Ofsted grades… side by side, in one place" (`j1-home-desktop-fold.png`). The mobile hero (`j1-home-mobile-fold.png`) drops both — below the poetic-but-vague H1 there is only a search box.
|
||||||
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
|
- Criterion violated: Nielsen #1 (Visibility of system status) / recognition-over-recall; mobile content-parity best practice (the primary 63%-of-entries viewport should not lose the core "what is this and why trust it" copy).
|
||||||
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
|
- Argument: A first-time parent landing on mobile sees "Every school in England, compared." + a bare box, with no statement of coverage, data sources, or freshness. Weak/absent value proposition above the fold is a classic driver of immediate exits — directly relevant to the 46% home-exit rate.
|
||||||
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
|
- Severity guess: P2 (candidate P1 given mobile is the primary, highest-traffic viewport)
|
||||||
|
|||||||
@@ -69,173 +69,6 @@ test('home hero offers a "use my location" shortcut beside the search box', asyn
|
|||||||
await expect(nearMe).toBeVisible();
|
await expect(nearMe).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
|
||||||
* The landing page's claims must match the product and the data.
|
|
||||||
*
|
|
||||||
* Every failure this group guards against is silent: a hardcoded fallback that
|
|
||||||
* renders as if it were live, a number that contradicts a constant elsewhere in
|
|
||||||
* the codebase, illustrative copy that reads as fact. None of them throw, none
|
|
||||||
* fail a build, and none show up in a unit test.
|
|
||||||
*/
|
|
||||||
test('the coverage figure is live, not the hardcoded fallback', async ({ page }) => {
|
|
||||||
const info = await (await page.request.get('/api/data-info')).json();
|
|
||||||
test.skip(info.status !== 'loaded', 'environment has no data loaded');
|
|
||||||
const expected: number = info.unique_schools;
|
|
||||||
expect(expected).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
const body = await page.locator('body').innerText();
|
|
||||||
|
|
||||||
// The real count, formatted as the page formats it.
|
|
||||||
expect(body).toContain(expected.toLocaleString('en-GB'));
|
|
||||||
// The fallback that used to render in its place. `total_schools` is not a
|
|
||||||
// field this API returns, so reading it yielded undefined and the "24,000+"
|
|
||||||
// floor was shown on every request while the database held far more.
|
|
||||||
expect(body).not.toContain('24,000+');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the landing page states the real comparison limit', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
const body = await page.locator('body').innerText();
|
|
||||||
// MAX_SCHOOLS in context/ComparisonProvider.tsx is 5. The hero used to
|
|
||||||
// promise three while a card lower on the same page promised five.
|
|
||||||
expect(body).toMatch(/five schools/i);
|
|
||||||
expect(body).not.toMatch(/three schools/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the landing page invents no data about real schools', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
const preview = page.locator('table, [class*="comparePreview"]').first();
|
|
||||||
const body = await page.locator('body').innerText();
|
|
||||||
// The compare preview's columns carried two real school names beside
|
|
||||||
// invented results and invented Ofsted grades.
|
|
||||||
expect(body).not.toMatch(/Our Lady Queen of Heaven|St Mary's Catholic Primary/i);
|
|
||||||
await expect(preview).toContainText(/School A/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the home page has a real title', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
// It was the bare string "Home" — the browser tab, the bookmark and the
|
|
||||||
// search result all read that.
|
|
||||||
expect(await page.title()).not.toBe('Home');
|
|
||||||
expect(await page.title()).toMatch(/schoolcompare/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the site states its independence and never claims to be official', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
// Carried in the footer, so it appears on every page rather than only here.
|
|
||||||
await expect(
|
|
||||||
page.getByText(/an independent site\.\s*not affiliated with the department for education/i)
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Describing the DATA as official is accurate and appears throughout
|
|
||||||
* ("official DfE figures", "the official figure isn't in"). Describing the
|
|
||||||
* SITE as official is not: schoolcompare republishes government data, it is
|
|
||||||
* not a government service. The difference is the grammatical subject, so
|
|
||||||
* this looks for text that opens with "Official" as a standalone label —
|
|
||||||
* the shape a value-prop title or heading takes, and the shape the old
|
|
||||||
* "Official & trusted" took.
|
|
||||||
*/
|
|
||||||
const claims = await page.evaluate(() =>
|
|
||||||
[...document.querySelectorAll('body *')]
|
|
||||||
.flatMap(el => [...el.childNodes]
|
|
||||||
.filter(n => n.nodeType === 3)
|
|
||||||
.map(n => (n.textContent ?? '').trim()))
|
|
||||||
.filter(t => /^Official\b/i.test(t) && !/^Official\s+(DfE|data|figures)/i.test(t))
|
|
||||||
);
|
|
||||||
expect(claims, `text presenting the site itself as official: ${claims.join(' | ')}`).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the landing bands share one vertical rhythm', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
const gaps = await page.evaluate(() => {
|
|
||||||
const landing = document.querySelector('main div[class*="landing"]');
|
|
||||||
if (!landing) return null;
|
|
||||||
const kids = [...landing.children].map(el => el.getBoundingClientRect());
|
|
||||||
const out: number[] = [];
|
|
||||||
for (let i = 1; i < kids.length; i++) out.push(Math.round(kids[i].top - kids[i - 1].bottom));
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
expect(gaps).not.toBeNull();
|
|
||||||
expect(gaps!.length).toBeGreaterThan(1);
|
|
||||||
// Every band gap comes from the page container's single `gap`, so they are
|
|
||||||
// identical. They used to be 24 / 32 / 24 / 16 / 48 / 32px — each band set
|
|
||||||
// its own margin, and the page read as a stack of unrelated strips.
|
|
||||||
expect(new Set(gaps!).size).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The hero artwork. Every assertion here covers a failure that renders as a
|
|
||||||
* perfectly valid page: a path that 404s leaves an empty box, a crop that
|
|
||||||
* stops switching silently decapitates the subject, and a format negotiation
|
|
||||||
* that regresses just serves a bigger file.
|
|
||||||
*/
|
|
||||||
function heroImageState(page: Page) {
|
|
||||||
return page.evaluate(() => {
|
|
||||||
const img = document.querySelector('[class*="heroArt"] img') as HTMLImageElement | null;
|
|
||||||
if (!img) return null;
|
|
||||||
return {
|
|
||||||
// naturalWidth is 0 for an image that failed to load, whatever the src says.
|
|
||||||
loaded: img.complete && img.naturalWidth > 0,
|
|
||||||
file: img.currentSrc.split('/').pop() ?? '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('the hero artwork loads, and switches crop rather than cropping its subject', async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
|
||||||
await page.goto('/');
|
|
||||||
const wide = await heroImageState(page);
|
|
||||||
expect(wide).not.toBeNull();
|
|
||||||
expect(wide!.loaded).toBe(true);
|
|
||||||
expect(wide!.file).toContain('hero-wide');
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Below the one-column breakpoint the slot becomes a band up to 4.9:1. The
|
|
||||||
* desktop frame centre-cropped to that shape loses the schoolhouse, which is
|
|
||||||
* how the previous hero failed on phones — so <picture> must switch to the
|
|
||||||
* pre-cropped band file, not merely to a smaller version of the same frame.
|
|
||||||
*/
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.goto('/');
|
|
||||||
const band = await heroImageState(page);
|
|
||||||
expect(band!.loaded).toBe(true);
|
|
||||||
expect(band!.file).toContain('hero-band');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the hero artwork is served in a modern format', async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
|
||||||
await page.goto('/');
|
|
||||||
const state = await heroImageState(page);
|
|
||||||
// The JPEG exists only as the <img> fallback. Chromium takes AVIF, so seeing
|
|
||||||
// the fallback here means the <source> negotiation broke — which costs about
|
|
||||||
// 24kB extra on the LCP element and nothing visible.
|
|
||||||
expect(state!.file).toMatch(/\.(avif|webp)$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the hero artwork is dimmed for the dark theme', async ({ browser }) => {
|
|
||||||
const filterFor = async (colorScheme: 'light' | 'dark') => {
|
|
||||||
const ctx = await browser.newContext({ colorScheme });
|
|
||||||
const p = await ctx.newPage();
|
|
||||||
await p.goto('/');
|
|
||||||
const value = await p.evaluate(() => {
|
|
||||||
const img = document.querySelector('[class*="heroArt"] img');
|
|
||||||
return img ? getComputedStyle(img).filter : 'missing';
|
|
||||||
});
|
|
||||||
await ctx.close();
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
// The artwork is a fixed raster, so it cannot be re-graded per theme the way
|
|
||||||
// the drawn hero was. Undimmed it is the brightest object on a near-black
|
|
||||||
// page and out-shouts the H1 and the search box — the exact regression the
|
|
||||||
// drawn version shipped with.
|
|
||||||
expect(await filterFor('light')).toBe('none');
|
|
||||||
expect(await filterFor('dark')).toMatch(/brightness/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('searching by name returns school results', async ({ page }) => {
|
test('searching by name returns school results', async ({ page }) => {
|
||||||
await searchByName(page, 'primary');
|
await searchByName(page, 'primary');
|
||||||
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
@@ -800,108 +633,6 @@ test('sticky section nav jumps to server-rendered sections', async ({ page }) =>
|
|||||||
* These are silent failures — nothing on the page looks wrong — so they need
|
* These are silent failures — nothing on the page looks wrong — so they need
|
||||||
* a gate.
|
* a gate.
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* Chart benchmark markers, against whatever they actually overlap.
|
|
||||||
*
|
|
||||||
* The national-average marker shipped as var(--brand) on both templates —
|
|
||||||
* identical to the bar fill it sits on, so it measured 1.00:1 wherever it
|
|
||||||
* crossed a bar and was visible only for schools BELOW the benchmark.
|
|
||||||
*
|
|
||||||
* The WCAG journey below never saw it, and could not have: it composites
|
|
||||||
* backgrounds by walking the ANCESTOR chain, while these markers are
|
|
||||||
* absolutely positioned over a SIBLING. Ancestor-walking is structurally blind
|
|
||||||
* to overlap. This test asks the stacking order instead —
|
|
||||||
* document.elementsFromPoint returns what is genuinely beneath a point — which
|
|
||||||
* is the same question a reader's eye asks.
|
|
||||||
*/
|
|
||||||
const MARKER_PROBE = `(() => {
|
|
||||||
const ps = c => { const m=(c||'').match(/[\\d.]+/g); if(!m) return null;
|
|
||||||
const a=m.map(Number); return {r:a[0],g:a[1],b:a[2],a:m.length>3?a[3]:1}; };
|
|
||||||
const ov = (f,b) => ({r:f.r*f.a+b.r*(1-f.a), g:f.g*f.a+b.g*(1-f.a), b:f.b*f.a+b.b*(1-f.a), a:1});
|
|
||||||
const L = c => { const f=v=>{v/=255; return v<=0.03928?v/12.92:Math.pow((v+.055)/1.055,2.4);};
|
|
||||||
return .2126*f(c.r)+.7152*f(c.g)+.0722*f(c.b); };
|
|
||||||
const RT = (a,b) => { const x=L(a),y=L(b); return (Math.max(x,y)+.05)/(Math.min(x,y)+.05); };
|
|
||||||
|
|
||||||
const markers = [...document.querySelectorAll('[class*="natTick"], [class*="NatLine"]')]
|
|
||||||
.filter(el => el.getBoundingClientRect().width > 0);
|
|
||||||
const out = { count: markers.length, failures: [], unmeasured: 0 };
|
|
||||||
|
|
||||||
for (const el of markers) {
|
|
||||||
/*
|
|
||||||
* Scroll it in first. elementsFromPoint takes VIEWPORT coordinates and
|
|
||||||
* returns an empty stack for anything off-screen — and these markers sit
|
|
||||||
* well down the page. Without this the backdrop silently defaults to
|
|
||||||
* white, which makes a teal-on-teal marker look like teal-on-white and
|
|
||||||
* pass. 'instant' because globals.css sets scroll-behavior: smooth on
|
|
||||||
* html, and a smooth scroll would still be moving when we measure.
|
|
||||||
*/
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
||||||
const own = ps(getComputedStyle(el).backgroundColor);
|
|
||||||
const edge = ps((getComputedStyle(el).boxShadow.match(/rgba?\\([^)]*\\)/) || [])[0] || '');
|
|
||||||
if (!own) continue;
|
|
||||||
|
|
||||||
// What is genuinely underneath, by stacking order rather than by ancestry.
|
|
||||||
const stack = document.elementsFromPoint(cx, cy);
|
|
||||||
if (stack.length === 0) { out.unmeasured++; continue; } // never assume white
|
|
||||||
let backdrop = null;
|
|
||||||
for (const under of stack) {
|
|
||||||
if (under === el || el.contains(under)) continue;
|
|
||||||
const c = ps(getComputedStyle(under).backgroundColor);
|
|
||||||
if (c && c.a > 0.9) { backdrop = c; break; }
|
|
||||||
}
|
|
||||||
if (!backdrop) { out.unmeasured++; continue; }
|
|
||||||
|
|
||||||
// A knockout marker only needs ONE of its two parts to separate.
|
|
||||||
const best = Math.max(RT(ov(own, backdrop), backdrop),
|
|
||||||
edge ? RT(ov(edge, backdrop), backdrop) : 0);
|
|
||||||
if (best < 3) {
|
|
||||||
out.failures.push((el.className || '?').toString().split(' ')[0] +
|
|
||||||
' ' + best.toFixed(2) + ':1 over rgb(' +
|
|
||||||
[backdrop.r, backdrop.g, backdrop.b].map(Math.round).join(',') + ')');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
})()`;
|
|
||||||
|
|
||||||
async function firstSchoolLink(page: Page, query: string): Promise<string> {
|
|
||||||
await page.goto(`/?search=${encodeURIComponent(query)}`);
|
|
||||||
const link = schoolLinks(page).first();
|
|
||||||
await expect(link).toBeVisible({ timeout: 15_000 });
|
|
||||||
return (await link.getAttribute('href'))!;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const scheme of ['light', 'dark'] as const) {
|
|
||||||
test(`chart benchmark markers stay visible in the ${scheme} theme`, async ({ browser }) => {
|
|
||||||
const context = await browser.newContext({ colorScheme: scheme });
|
|
||||||
const page = await context.newPage();
|
|
||||||
const failures: string[] = [];
|
|
||||||
|
|
||||||
// One primary (SATs bars) and one secondary (Attainment 8 bar) — the two
|
|
||||||
// templates carry separate implementations of the same marker.
|
|
||||||
for (const query of ['primary', 'academy']) {
|
|
||||||
const href = await firstSchoolLink(page, query);
|
|
||||||
await page.goto(href);
|
|
||||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
await page.waitForTimeout(900); // bar widths animate in from an effect
|
|
||||||
|
|
||||||
const res = (await page.evaluate(MARKER_PROBE)) as
|
|
||||||
{ count: number; failures: string[]; unmeasured: number };
|
|
||||||
test.skip(res.count === 0 && query === 'primary',
|
|
||||||
'no benchmark markers rendered — environment has no national averages');
|
|
||||||
// A marker we could not resolve a backdrop for is a hole in the check,
|
|
||||||
// not a pass. Fail loudly rather than quietly measuring nothing.
|
|
||||||
expect(res.unmeasured,
|
|
||||||
`${href}: ${res.unmeasured} of ${res.count} markers had no resolvable backdrop`).toBe(0);
|
|
||||||
failures.push(...res.failures.map(f => `${href} → ${f}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.close();
|
|
||||||
expect(failures, `benchmark markers under 3:1 in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('the brand asset set is complete and served', async ({ page }) => {
|
test('the brand asset set is complete and served', async ({ page }) => {
|
||||||
const response = await page.goto('/');
|
const response = await page.goto('/');
|
||||||
expect(response?.ok()).toBe(true);
|
expect(response?.ok()).toBe(true);
|
||||||
@@ -918,21 +649,9 @@ test('the brand asset set is complete and served', async ({ page }) => {
|
|||||||
expect(og.ok()).toBe(true);
|
expect(og.ok()).toBe(true);
|
||||||
expect(og.headers()['content-type']).toContain('image/png');
|
expect(og.headers()['content-type']).toContain('image/png');
|
||||||
|
|
||||||
/*
|
// iOS ignores SVG touch icons, so this must be a real raster.
|
||||||
* iOS ignores SVG touch icons, so this must be a real raster.
|
const apple = await page.request.get('/apple-icon');
|
||||||
*
|
expect(apple.ok()).toBe(true);
|
||||||
* Read the href off the page rather than hardcoding a path. The route moved
|
|
||||||
* when the generated app/apple-icon.tsx became a static app/apple-icon.png:
|
|
||||||
* a generated icon is served at /apple-icon, a static one at /apple-icon.png
|
|
||||||
* with a content hash. This test kept asking for /apple-icon and got a 404 —
|
|
||||||
* reporting a missing icon while the icon was present and correctly linked.
|
|
||||||
*/
|
|
||||||
const appleLink = page.locator('link[rel="apple-touch-icon"]');
|
|
||||||
await expect(appleLink).toHaveCount(1);
|
|
||||||
const appleHref = await appleLink.getAttribute('href');
|
|
||||||
expect(appleHref).toBeTruthy();
|
|
||||||
const apple = await page.request.get(appleHref!);
|
|
||||||
expect(apple.ok(), `apple touch icon at ${appleHref} should be served`).toBe(true);
|
|
||||||
expect(apple.headers()['content-type']).toContain('image/png');
|
expect(apple.headers()['content-type']).toContain('image/png');
|
||||||
|
|
||||||
// Android needs a maskable PNG or the install prompt has no icon.
|
// Android needs a maskable PNG or the install prompt has no icon.
|
||||||
@@ -1053,22 +772,8 @@ test('the header carries the schoolcompare lockup', async ({ page }) => {
|
|||||||
// hashed at build time and change on any unrelated edit.
|
// hashed at build time and change on any unrelated edit.
|
||||||
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
await expect(home).toHaveText(/^\s*schoolcompare\s*$/);
|
||||||
|
|
||||||
/*
|
const mark = home.locator('svg').first();
|
||||||
* The mark is raster artwork, not an inline SVG — it has been since the
|
|
||||||
* supplied logo replaced the reconstruction, and this locator went on
|
|
||||||
* looking for an <svg> that no longer exists.
|
|
||||||
*
|
|
||||||
* Assert on naturalWidth rather than visibility: a <picture> whose sources
|
|
||||||
* all 404 still lays out and still passes toBeVisible(), so the weaker
|
|
||||||
* assertion would go green on a broken lockup.
|
|
||||||
*/
|
|
||||||
const mark = home.locator('img').first();
|
|
||||||
await expect(mark).toBeVisible();
|
await expect(mark).toBeVisible();
|
||||||
const markState = await mark.evaluate((el: HTMLImageElement) => ({
|
|
||||||
loaded: el.complete && el.naturalWidth > 0,
|
|
||||||
src: el.currentSrc,
|
|
||||||
}));
|
|
||||||
expect(markState.loaded, `header mark failed to load: ${markState.src}`).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('no visible text falls back to the browser default black', async ({ page }) => {
|
test('no visible text falls back to the browser default black', async ({ page }) => {
|
||||||
@@ -1210,100 +915,3 @@ for (const scheme of ['light', 'dark'] as const) {
|
|||||||
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
expect(failures, `AA failures in ${scheme}:\n ${failures.join('\n ')}`).toEqual([]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Last distance offered ────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Coverage is partial by nature: only 57 local authorities publish cut-off
|
|
||||||
// distances at all, and staging may not yet hold the extract. These tests
|
|
||||||
// therefore locate a school that HAS a figure and assert the invariants that
|
|
||||||
// make it safe to show, rather than asserting any particular school has one.
|
|
||||||
|
|
||||||
/** URNs known to be in the collected cut-off dataset, spread across LAs so a
|
|
||||||
* single council's data gap cannot skip the whole check. */
|
|
||||||
const CUTOFF_CANDIDATE_URNS = [
|
|
||||||
139837, 100010, 100011, 100028, 100029, 130342, // Camden
|
|
||||||
100458, 135587, // Islington (banded)
|
|
||||||
101099, 100553, 102574, 100769, // mixed
|
|
||||||
];
|
|
||||||
|
|
||||||
async function schoolWithCutoff(page: Page) {
|
|
||||||
for (const urn of CUTOFF_CANDIDATE_URNS) {
|
|
||||||
const res = await page.request.get(`/api/schools/${urn}`);
|
|
||||||
if (!res.ok()) continue;
|
|
||||||
const body = await res.json();
|
|
||||||
if (body?.admission_distance?.distance_m != null) {
|
|
||||||
return { urn, distance: body.admission_distance, phase: body.school_info?.phase };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a published cut-off distance is shown with the year it belongs to', async ({ page }) => {
|
|
||||||
const found = await schoolWithCutoff(page);
|
|
||||||
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
|
|
||||||
|
|
||||||
await page.goto(`/school/${found!.urn}`);
|
|
||||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
const label = page.getByText(/Last distance offered/).first();
|
|
||||||
await expect(label).toBeVisible();
|
|
||||||
|
|
||||||
// The year is the difference between a fact and a floating number: a cut-off
|
|
||||||
// is the outcome of one admissions round and means nothing without it.
|
|
||||||
await expect(label).toContainText(`September ${found!.distance.year}`);
|
|
||||||
|
|
||||||
// And the figure itself, in the unit councils publish in.
|
|
||||||
await expect(page.getByText(/\d+(\.\d+)? (miles|m)\b/).first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a cut-off distance is never shown without saying it is not a catchment', async ({ page }) => {
|
|
||||||
const found = await schoolWithCutoff(page);
|
|
||||||
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
|
|
||||||
|
|
||||||
await page.goto(`/school/${found!.urn}`);
|
|
||||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
await expect(page.getByText(/not a fixed catchment/)).toBeVisible();
|
|
||||||
|
|
||||||
// The old secondary template asserted "distance cut-off data is not
|
|
||||||
// available for this school" on every page, including ones whose council
|
|
||||||
// does publish it. Showing both at once would be a flat contradiction.
|
|
||||||
await expect(page.getByText(/has not published a cut-off distance/)).toHaveCount(0);
|
|
||||||
|
|
||||||
// A banded school's figure is the widest of several routes, and must say so.
|
|
||||||
if ((found!.distance.route_count ?? 1) > 1) {
|
|
||||||
await expect(page.getByText(/admission routes at this school/)).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a cut-off distance opens the Admissions section in the sticky nav', async ({ page }) => {
|
|
||||||
// buildNavItems and the section composers gate on the same condition; when
|
|
||||||
// they drift the nav links to an anchor that was never rendered.
|
|
||||||
const found = await schoolWithCutoff(page);
|
|
||||||
test.skip(found === null, 'no school in the sample has a published cut-off distance yet');
|
|
||||||
|
|
||||||
await page.goto(`/school/${found!.urn}`);
|
|
||||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
|
|
||||||
await expect(page.locator('#admissions')).toHaveCount(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no school page shows an implausible cut-off distance', async ({ page }) => {
|
|
||||||
// The source carries transcription errors up to 533 miles, filtered in
|
|
||||||
// stg_school_distance. This asserts the filter is actually reaching the API,
|
|
||||||
// because a visibly absurd figure on a live page is the failure that would
|
|
||||||
// cost the site its credibility.
|
|
||||||
let checked = 0;
|
|
||||||
for (const urn of CUTOFF_CANDIDATE_URNS) {
|
|
||||||
const res = await page.request.get(`/api/schools/${urn}`);
|
|
||||||
if (!res.ok()) continue;
|
|
||||||
const d = (await res.json())?.admission_distance;
|
|
||||||
if (d?.distance_m == null) continue;
|
|
||||||
checked += 1;
|
|
||||||
expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`)
|
|
||||||
.toBeGreaterThanOrEqual(25);
|
|
||||||
expect(d.distance_m, `URN ${urn} cut-off outside the plausibility band`)
|
|
||||||
.toBeLessThanOrEqual(25000);
|
|
||||||
}
|
|
||||||
test.skip(checked === 0, 'no cut-off distances available to check yet');
|
|
||||||
});
|
|
||||||
@@ -1,591 +0,0 @@
|
|||||||
<title>Last distance offered — detail page mockup</title>
|
|
||||||
<style>
|
|
||||||
:root{
|
|
||||||
--bg-primary:#faf7f2; --bg-secondary:#f3ede4; --bg-card:#fff;
|
|
||||||
--text-primary:#1a1612; --text-secondary:#5c564d; --text-muted:#6d685f;
|
|
||||||
--accent-coral:#e07256; --accent-coral-dark:#b04a2e;
|
|
||||||
--accent-teal:#296f6f; --accent-teal-light:#3a9e9e;
|
|
||||||
--accent-gold:#c9a227; --accent-gold-text:#7a6800;
|
|
||||||
--coral-bg:rgba(224,114,86,.12); --teal-bg:rgba(45,125,125,.12); --gold-bg:rgba(201,162,39,.12);
|
|
||||||
--border:#e5dfd5; --shadow:0 2px 8px rgba(26,22,18,.06); --shadow-md:0 4px 20px rgba(26,22,18,.1);
|
|
||||||
--radius-sm:4px; --radius-md:8px; --radius-lg:16px;
|
|
||||||
--serif:'Playfair Display',Georgia,'Iowan Old Style',serif;
|
|
||||||
--sans:'DM Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
||||||
}
|
|
||||||
/* Mockup is a fixed light artefact — it mirrors the live app, which is light-only. */
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{font-family:var(--sans);background:var(--bg-primary);color:var(--text-primary);line-height:1.6;
|
|
||||||
padding:44px 20px 110px;font-variant-numeric:tabular-nums}
|
|
||||||
.wrap{max-width:820px;margin:0 auto;display:flex;flex-direction:column;gap:0}
|
|
||||||
|
|
||||||
.pagehead h1{font-family:var(--serif);font-weight:600;font-size:clamp(26px,4vw,34px);letter-spacing:-.015em;text-wrap:balance}
|
|
||||||
.pagehead p{color:var(--text-secondary);margin-top:10px;font-size:15px;max-width:64ch}
|
|
||||||
.pagehead p + p{margin-top:8px}
|
|
||||||
|
|
||||||
.step{margin:60px 0 6px;display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
|
|
||||||
.step h2{font-family:var(--serif);font-size:20px;font-weight:600;letter-spacing:-.01em}
|
|
||||||
.step .where{font-size:12px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--accent-teal);
|
|
||||||
background:var(--teal-bg);padding:3px 9px;border-radius:999px}
|
|
||||||
.stepdesc{color:var(--text-muted);font-size:14px;margin-bottom:18px;max-width:66ch}
|
|
||||||
.stepdesc code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;background:var(--bg-secondary);padding:1px 5px;border-radius:var(--radius-sm)}
|
|
||||||
|
|
||||||
/* ---- card shell mirrors SchoolDetailView .card ---- */
|
|
||||||
.card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);
|
|
||||||
box-shadow:var(--shadow);padding:26px 26px 24px}
|
|
||||||
.cardhead{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:8px}
|
|
||||||
.sectionTitle{font-family:var(--serif);font-weight:600;font-size:22px;letter-spacing:-.01em}
|
|
||||||
.sectionSub{color:var(--text-secondary);font-size:14px;margin-bottom:18px}
|
|
||||||
|
|
||||||
.seg{display:inline-flex;background:var(--bg-secondary);border-radius:999px;padding:3px;gap:2px;flex:none}
|
|
||||||
.seg button{appearance:none;border:none;background:none;cursor:pointer;font:inherit;font-size:13px;font-weight:600;
|
|
||||||
color:var(--text-muted);padding:6px 13px;border-radius:999px;white-space:nowrap;transition:background .15s,color .15s}
|
|
||||||
.seg button[aria-pressed="true"]{background:var(--bg-card);color:var(--text-primary);box-shadow:var(--shadow)}
|
|
||||||
.seg button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
|
|
||||||
|
|
||||||
/* ---- tiles ---- */
|
|
||||||
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(146px,1fr));gap:10px;margin-top:4px}
|
|
||||||
.tile{background:var(--bg-secondary);border-radius:var(--radius-md);padding:14px 15px 13px}
|
|
||||||
.tile .num{font-family:var(--serif);font-size:27px;font-weight:600;line-height:1.15;letter-spacing:-.01em;display:block}
|
|
||||||
.tile .num .unit{font-family:var(--sans);font-size:15px;font-weight:600;margin-left:2px}
|
|
||||||
.tile .num .sub{display:block;font-family:var(--sans);font-size:12.5px;font-weight:400;color:var(--text-muted);margin-top:1px}
|
|
||||||
.tile .lbl{display:block;font-size:12.5px;color:var(--text-secondary);margin-top:5px;line-height:1.35}
|
|
||||||
.tile.accent{background:var(--coral-bg)}
|
|
||||||
.tile.accent .num{color:var(--accent-coral-dark)}
|
|
||||||
.tile.newtile{background:var(--coral-bg);box-shadow:inset 0 0 0 1.5px var(--accent-coral)}
|
|
||||||
.tile.newtile .num{color:var(--accent-coral-dark)}
|
|
||||||
.newflag{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
|
|
||||||
color:var(--accent-coral-dark);background:rgba(224,114,86,.2);padding:1px 6px;border-radius:999px;margin-bottom:6px}
|
|
||||||
|
|
||||||
/* ---- verdict banner ---- */
|
|
||||||
.verdict{display:flex;gap:13px;align-items:flex-start;padding:15px 17px;border-radius:var(--radius-md);margin:2px 0 20px}
|
|
||||||
.verdict.hard{background:var(--coral-bg)}
|
|
||||||
.verdict .vic{flex:none;width:22px;height:22px;border-radius:50%;display:grid;place-items:center;margin-top:1px;
|
|
||||||
background:var(--accent-coral-dark);color:#fff;font-size:12px;font-weight:700}
|
|
||||||
.verdict .vhead{font-weight:700;font-size:16.5px;letter-spacing:-.01em;color:var(--accent-coral-dark);text-wrap:balance}
|
|
||||||
.verdict .vsub{color:var(--text-secondary);font-size:13.5px;margin-top:3px}
|
|
||||||
|
|
||||||
/* ---- chart ---- */
|
|
||||||
.chartwrap{overflow-x:auto}
|
|
||||||
.chart{display:block;width:100%;min-width:460px;height:auto}
|
|
||||||
.axtxt{font-family:var(--sans);font-size:11px;fill:var(--text-muted)}
|
|
||||||
.ptlbl{font-family:var(--sans);font-size:12px;font-weight:700;fill:var(--accent-coral-dark)}
|
|
||||||
.keyrow{display:flex;flex-wrap:wrap;gap:16px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
|
|
||||||
.keyrow span{display:inline-flex;align-items:center;gap:7px}
|
|
||||||
.kdot{width:11px;height:11px;border-radius:50%;flex:none}
|
|
||||||
.kdot.line{background:var(--accent-coral)}
|
|
||||||
.kdot.open{background:#fff;box-shadow:inset 0 0 0 2px var(--accent-teal)}
|
|
||||||
.kdot.gap{background:repeating-linear-gradient(90deg,var(--text-muted) 0 2px,transparent 2px 4px);border-radius:0;height:2px}
|
|
||||||
|
|
||||||
/* ---- table ---- */
|
|
||||||
.tblwrap{overflow-x:auto;margin-top:4px}
|
|
||||||
table{width:100%;border-collapse:collapse;font-size:14px;min-width:420px}
|
|
||||||
thead th{text-align:right;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
|
|
||||||
color:var(--text-muted);padding:0 10px 9px;border-bottom:1px solid var(--border)}
|
|
||||||
thead th:first-child{text-align:left}
|
|
||||||
tbody td{text-align:right;padding:11px 10px;border-bottom:1px solid var(--border)}
|
|
||||||
tbody td:first-child{text-align:left;font-weight:600}
|
|
||||||
tbody tr:last-child td{border-bottom:none}
|
|
||||||
tbody tr.now{background:var(--bg-secondary)}
|
|
||||||
td.miss{color:var(--text-muted);font-weight:400}
|
|
||||||
.pill{display:inline-block;font-size:11px;font-weight:600;padding:2px 9px;border-radius:999px;white-space:nowrap}
|
|
||||||
.pill.over{background:var(--coral-bg);color:var(--accent-coral-dark)}
|
|
||||||
.pill.ok{background:var(--teal-bg);color:var(--accent-teal)}
|
|
||||||
.pill.na{background:var(--bg-secondary);color:var(--text-muted)}
|
|
||||||
|
|
||||||
/* ---- map ---- */
|
|
||||||
.mapfig{border-radius:var(--radius-md);overflow:hidden;border:1px solid var(--border);background:#eef2ec}
|
|
||||||
.mapsvg{display:block;width:100%;height:auto}
|
|
||||||
.maplegend{display:flex;flex-wrap:wrap;gap:14px 20px;margin-top:12px;font-size:12.5px;color:var(--text-secondary)}
|
|
||||||
.maplegend span{display:inline-flex;align-items:center;gap:7px}
|
|
||||||
.swatch{width:14px;height:14px;border-radius:50%;flex:none}
|
|
||||||
.swatch.now{background:rgba(224,114,86,.18);box-shadow:inset 0 0 0 2px var(--accent-coral)}
|
|
||||||
.swatch.past{background:transparent;box-shadow:inset 0 0 0 1.5px rgba(176,74,46,.4)}
|
|
||||||
.swatch.you{background:var(--accent-teal);border-radius:2px;transform:rotate(45deg);width:11px;height:11px}
|
|
||||||
|
|
||||||
/* ---- postcode check ---- */
|
|
||||||
.checkbox{margin-top:20px;border-top:1px solid var(--border);padding-top:18px}
|
|
||||||
.checkhead{font-weight:700;font-size:15px;margin-bottom:3px}
|
|
||||||
.checksub{font-size:13.5px;color:var(--text-muted);margin-bottom:12px}
|
|
||||||
.checkform{display:flex;gap:8px;flex-wrap:wrap}
|
|
||||||
.checkform input{font:inherit;font-size:15px;padding:10px 13px;border:1px solid var(--border);border-radius:var(--radius-md);
|
|
||||||
background:var(--bg-card);color:var(--text-primary);min-width:150px;flex:1 1 150px;text-transform:uppercase}
|
|
||||||
.checkform input:focus-visible{outline:2px solid var(--accent-teal);outline-offset:1px;border-color:var(--accent-teal)}
|
|
||||||
.checkform button{font:inherit;font-weight:600;font-size:15px;padding:10px 20px;border:none;border-radius:var(--radius-md);
|
|
||||||
background:var(--accent-coral-dark);color:#fff;cursor:pointer;transition:background .15s}
|
|
||||||
.checkform button:hover{background:#9c3f26}
|
|
||||||
.checkform button:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px}
|
|
||||||
.result{margin-top:14px;background:var(--teal-bg);border-radius:var(--radius-md);padding:15px 17px}
|
|
||||||
.result .rhead{font-weight:700;font-size:16px;color:var(--accent-teal);letter-spacing:-.01em;text-wrap:balance}
|
|
||||||
.result .rsub{font-size:13.5px;color:var(--text-secondary);margin-top:4px}
|
|
||||||
.yearstrip{display:flex;gap:4px;margin-top:12px;flex-wrap:wrap}
|
|
||||||
.yr{font-size:11px;font-weight:600;padding:3px 7px;border-radius:var(--radius-sm);white-space:nowrap}
|
|
||||||
.yr.in{background:rgba(45,125,125,.22);color:var(--accent-teal)}
|
|
||||||
.yr.out{background:var(--coral-bg);color:var(--accent-coral-dark)}
|
|
||||||
.yr.none{background:var(--bg-secondary);color:var(--text-muted)}
|
|
||||||
|
|
||||||
/* ---- notes / disclosure ---- */
|
|
||||||
.note{display:flex;gap:9px;margin-top:16px;font-size:13px;color:var(--text-muted);line-height:1.55}
|
|
||||||
.note .i{flex:none;width:17px;height:17px;border-radius:50%;background:var(--bg-secondary);color:var(--text-muted);
|
|
||||||
font-size:11px;font-weight:700;display:grid;place-items:center;margin-top:2px}
|
|
||||||
.disclosure{margin-top:16px;border-top:1px solid var(--border);padding-top:4px}
|
|
||||||
.disclosure>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px;padding:10px 0;
|
|
||||||
font-weight:600;font-size:14px;color:var(--accent-teal)}
|
|
||||||
.disclosure>summary::-webkit-details-marker{display:none}
|
|
||||||
.disclosure>summary:focus-visible{outline:2px solid var(--accent-teal);outline-offset:2px;border-radius:var(--radius-sm)}
|
|
||||||
.disclosure .chev{transition:transform .2s ease}
|
|
||||||
.disclosure[open]>summary .chev{transform:rotate(90deg)}
|
|
||||||
.disclosure .body{padding:2px 0 10px;font-size:13.5px;color:var(--text-secondary);display:flex;flex-direction:column;gap:10px}
|
|
||||||
.disclosure .body b{color:var(--text-primary)}
|
|
||||||
|
|
||||||
/* ---- viewport stack (keeps card height stable across views) ---- */
|
|
||||||
.viewport{display:grid}
|
|
||||||
.viewport>.view{grid-area:1/1}
|
|
||||||
.viewport>.view[hidden]{display:block;visibility:hidden;pointer-events:none}
|
|
||||||
|
|
||||||
/* ---- edge cases ---- */
|
|
||||||
.cases{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px}
|
|
||||||
.case{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-md);padding:16px 17px}
|
|
||||||
.case h3{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-muted);margin-bottom:10px}
|
|
||||||
.case .body{font-size:14px;color:var(--text-secondary);line-height:1.5}
|
|
||||||
.case .body strong{color:var(--text-primary)}
|
|
||||||
.emptybox{background:var(--bg-secondary);border-radius:var(--radius-md);padding:13px 15px;font-size:13.5px;color:var(--text-secondary)}
|
|
||||||
|
|
||||||
/* ---- phone ---- */
|
|
||||||
.phonerow{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start}
|
|
||||||
.phone{width:330px;max-width:100%;border:9px solid #1a1612;border-radius:34px;overflow:hidden;box-shadow:var(--shadow-md);background:var(--bg-primary)}
|
|
||||||
.phonebody{padding:14px 13px 20px;display:flex;flex-direction:column;gap:12px}
|
|
||||||
.phone .card{padding:17px 16px 16px;border-radius:var(--radius-md)}
|
|
||||||
.phone .sectionTitle{font-size:18px}
|
|
||||||
.phone .tiles{grid-template-columns:1fr 1fr;gap:8px}
|
|
||||||
.phone .tile{padding:11px 12px}
|
|
||||||
.phone .tile .num{font-size:22px}
|
|
||||||
.phone .chart{min-width:0}
|
|
||||||
.phone .chartwrap{overflow:visible}
|
|
||||||
.phonenote{font-size:13px;color:var(--text-muted);flex:1 1 240px;min-width:220px}
|
|
||||||
.phonenote h3{font-family:var(--serif);font-size:17px;color:var(--text-primary);margin-bottom:8px;font-weight:600}
|
|
||||||
.phonenote ul{padding-left:18px;display:flex;flex-direction:column;gap:7px}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="wrap">
|
|
||||||
|
|
||||||
<div class="pagehead">
|
|
||||||
<h1>Last distance offered — school detail page</h1>
|
|
||||||
<p>Adds the final-offer cut-off distance to the existing <b>Admissions</b> card, plus a catchment
|
|
||||||
view on the map. Data covers one to ten years depending on the school and local authority, so every
|
|
||||||
screen here is built around partial coverage rather than assuming a full run.</p>
|
|
||||||
<p>Sample school: <b>Fairlawn Primary School</b>, Lewisham — 8 years of distance data out of 10 years of admissions data.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============ 1. ADMISSIONS CARD ============ -->
|
|
||||||
<div class="step">
|
|
||||||
<h2>1. A distance tile joins the admissions tiles</h2>
|
|
||||||
<span class="where">SchoolDetailView · #admissions</span>
|
|
||||||
</div>
|
|
||||||
<p class="stepdesc">No new section and no new nav entry — the number a parent actually asks for
|
|
||||||
("how close do we need to live?") sits with the rest of the intake story. The segmented control gains a
|
|
||||||
third view, <code>Distance</code>.</p>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="cardhead">
|
|
||||||
<h2 class="sectionTitle">Admissions</h2>
|
|
||||||
<div class="seg" role="group" aria-label="Admissions view">
|
|
||||||
<button type="button" aria-pressed="true" data-view="year">This year</button>
|
|
||||||
<button type="button" aria-pressed="false" data-view="trend">10-year trend</button>
|
|
||||||
<button type="button" aria-pressed="false" data-view="dist">Distance</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="sectionSub">Reception entry, September 2025.</p>
|
|
||||||
|
|
||||||
<div class="viewport">
|
|
||||||
<!-- view: this year -->
|
|
||||||
<div class="view" id="v-year">
|
|
||||||
<dl class="tiles">
|
|
||||||
<div class="tile">
|
|
||||||
<dd class="num">60</dd><dt class="lbl">Places offered</dt>
|
|
||||||
</div>
|
|
||||||
<div class="tile">
|
|
||||||
<dd class="num">142</dd><dt class="lbl">Wanted it first</dt>
|
|
||||||
</div>
|
|
||||||
<div class="tile accent">
|
|
||||||
<dd class="num">54<span class="sub">of 142 · 38%</span></dd>
|
|
||||||
<dt class="lbl">Got their first choice</dt>
|
|
||||||
</div>
|
|
||||||
<div class="tile newtile">
|
|
||||||
<span class="newflag">New</span>
|
|
||||||
<dd class="num">0.31<span class="unit">mi</span><span class="sub">≈ 500 m · 6 min walk</span></dd>
|
|
||||||
<dt class="lbl">Last distance offered</dt>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<div class="note">
|
|
||||||
<span class="i" aria-hidden="true">i</span>
|
|
||||||
<span>The furthest home offered a place once siblings, faith and EHCP priority were applied.
|
|
||||||
It is not a fixed catchment — it moves every year with the number of applications.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- view: distance -->
|
|
||||||
<div class="view" id="v-dist" hidden>
|
|
||||||
<div class="verdict hard">
|
|
||||||
<span class="vic" aria-hidden="true">↓</span>
|
|
||||||
<div>
|
|
||||||
<div class="vhead">The catchment has halved in nine years</div>
|
|
||||||
<div class="vsub">0.62 mi in 2016 → 0.31 mi in 2025. Four of the last five years tightened.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chartwrap">
|
|
||||||
<svg class="chart" viewBox="0 0 700 250" role="img"
|
|
||||||
aria-label="Last distance offered by year: 0.62 miles in 2016, 0.55 in 2017, not published in 2018, 0.48 in 2019, 0.51 in 2020, 0.44 in 2021, no cut-off needed in 2022, 0.39 in 2023, 0.35 in 2024, 0.31 in 2025.">
|
|
||||||
<!-- grid -->
|
|
||||||
<g stroke="#e5dfd5" stroke-width="1">
|
|
||||||
<line x1="46" y1="30" x2="686" y2="30"/>
|
|
||||||
<line x1="46" y1="80" x2="686" y2="80"/>
|
|
||||||
<line x1="46" y1="130" x2="686" y2="130"/>
|
|
||||||
<line x1="46" y1="180" x2="686" y2="180"/>
|
|
||||||
</g>
|
|
||||||
<line x1="46" y1="206" x2="686" y2="206" stroke="#d8d0c3" stroke-width="1.5"/>
|
|
||||||
<g class="axtxt" text-anchor="end">
|
|
||||||
<text x="38" y="34">0.8</text><text x="38" y="84">0.6</text>
|
|
||||||
<text x="38" y="134">0.4</text><text x="38" y="184">0.2</text>
|
|
||||||
</g>
|
|
||||||
<text class="axtxt" x="46" y="16" text-anchor="start">miles</text>
|
|
||||||
|
|
||||||
<!-- gap segments (dashed = no figure published) -->
|
|
||||||
<g fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="4 4" opacity=".55">
|
|
||||||
<path d="M114 92 L182 110"/>
|
|
||||||
<path d="M318 122 L386 132.5"/>
|
|
||||||
</g>
|
|
||||||
<!-- solid series -->
|
|
||||||
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
|
||||||
points="46,75 114,92"/>
|
|
||||||
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
|
||||||
points="182,110 250,102 318,122"/>
|
|
||||||
<polyline fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"
|
|
||||||
points="386,132.5 454,142.5 522,152.5"/>
|
|
||||||
|
|
||||||
<!-- points -->
|
|
||||||
<g fill="#e07256" stroke="#fff" stroke-width="2">
|
|
||||||
<circle cx="46" cy="75" r="5"/><circle cx="114" cy="92" r="5"/>
|
|
||||||
<circle cx="182" cy="110" r="5"/><circle cx="250" cy="102" r="5"/>
|
|
||||||
<circle cx="318" cy="122" r="5"/><circle cx="386" cy="132.5" r="5"/>
|
|
||||||
<circle cx="454" cy="142.5" r="5"/>
|
|
||||||
</g>
|
|
||||||
<!-- latest, emphasised -->
|
|
||||||
<circle cx="522" cy="152.5" r="7.5" fill="#b04a2e" stroke="#fff" stroke-width="2.5"/>
|
|
||||||
|
|
||||||
<!-- "no cut-off needed" marker, 2022 -->
|
|
||||||
<circle cx="352" cy="46" r="6" fill="#fff" stroke="#296f6f" stroke-width="2.5"/>
|
|
||||||
<text class="axtxt" x="352" y="34" text-anchor="middle" fill="#296f6f" font-weight="600">all offered</text>
|
|
||||||
<line x1="352" y1="54" x2="352" y2="196" stroke="#296f6f" stroke-width="1.5" stroke-dasharray="3 4" opacity=".45"/>
|
|
||||||
|
|
||||||
<!-- endpoint labels -->
|
|
||||||
<text class="ptlbl" x="46" y="63" text-anchor="start">0.62</text>
|
|
||||||
<text class="ptlbl" x="530" y="157" text-anchor="start">0.31 mi</text>
|
|
||||||
|
|
||||||
<!-- year axis -->
|
|
||||||
<g class="axtxt" text-anchor="middle">
|
|
||||||
<text x="46" y="226">2016</text><text x="114" y="226">2017</text>
|
|
||||||
<text x="182" y="226">2019</text><text x="250" y="226">2020</text>
|
|
||||||
<text x="318" y="226">2021</text><text x="386" y="226">2023</text>
|
|
||||||
<text x="454" y="226">2024</text><text x="522" y="226">2025</text>
|
|
||||||
</g>
|
|
||||||
<text class="axtxt" x="148" y="243" text-anchor="middle" fill="#6d685f">2018 not published</text>
|
|
||||||
<text class="axtxt" x="352" y="243" text-anchor="middle" fill="#296f6f">2022 undersubscribed</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="keyrow">
|
|
||||||
<span><i class="kdot line" aria-hidden="true"></i>Distance of the last place offered</span>
|
|
||||||
<span><i class="kdot open" aria-hidden="true"></i>No cut-off needed — every applicant offered</span>
|
|
||||||
<span><i class="kdot gap" aria-hidden="true"></i>Not published by the local authority</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tblwrap" style="margin-top:22px">
|
|
||||||
<table>
|
|
||||||
<caption class="sr-only" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Last distance offered by year</caption>
|
|
||||||
<thead>
|
|
||||||
<tr><th scope="col">Year</th><th scope="col">Last distance</th><th scope="col">Places</th><th scope="col">Status</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr class="now"><td>2025</td><td>0.31 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
|
||||||
<tr><td>2024</td><td>0.35 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
|
||||||
<tr><td>2023</td><td>0.39 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
|
||||||
<tr><td>2022</td><td class="miss">No cut-off needed</td><td>60</td><td><span class="pill ok">All offered</span></td></tr>
|
|
||||||
<tr><td>2021</td><td>0.44 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
|
||||||
<tr><td>2020</td><td>0.51 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
|
||||||
<tr><td>2019</td><td>0.48 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
|
||||||
<tr><td>2018</td><td class="miss">—</td><td>60</td><td><span class="pill na">Not published</span></td></tr>
|
|
||||||
<tr><td>2017</td><td>0.55 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
|
||||||
<tr><td>2016</td><td>0.62 mi</td><td>60</td><td><span class="pill over">Oversubscribed</span></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<details class="disclosure">
|
|
||||||
<summary><span class="chev" aria-hidden="true">›</span>What this number does and doesn't tell you</summary>
|
|
||||||
<div class="body">
|
|
||||||
<span><b>It is a result, not a rule.</b> It records how far the last successful applicant lived
|
|
||||||
in a given year. Move one large sibling cohort and the figure shifts.</span>
|
|
||||||
<span><b>Distance is the final tiebreak.</b> Children in care, EHCP places, siblings and — at faith
|
|
||||||
schools — the faith criteria are ranked first. A family inside the distance can still miss out.</span>
|
|
||||||
<span><b>Lewisham measures straight-line distance</b> from home to the school's main gate. Other
|
|
||||||
authorities use walking routes, which are always longer for the same home.</span>
|
|
||||||
<span><b>Gaps are normal.</b> An authority may not publish a figure, or the school may not have
|
|
||||||
needed a distance cut-off that year. Both are shown here rather than hidden.</span>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============ 2. MAP ============ -->
|
|
||||||
<div class="step">
|
|
||||||
<h2>2. The cut-off drawn on the map</h2>
|
|
||||||
<span class="where">SchoolHeroMap · catchment layer</span>
|
|
||||||
</div>
|
|
||||||
<p class="stepdesc">"0.31 miles" is abstract until you see it over your own streets. The latest year is a
|
|
||||||
filled ring; earlier years sit behind it as hairlines, so the tightening reads instantly as a set of
|
|
||||||
shrinking circles. Straight-line rings only — they're an illustration of the number, not a boundary.</p>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="cardhead" style="margin-bottom:14px">
|
|
||||||
<h2 class="sectionTitle">Where the last place went</h2>
|
|
||||||
<div class="seg" role="group" aria-label="Map years">
|
|
||||||
<button type="button" aria-pressed="true">2025 only</button>
|
|
||||||
<button type="button" aria-pressed="false">Last 5 years</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<figure class="mapfig">
|
|
||||||
<svg class="mapsvg" viewBox="0 0 700 400" role="img"
|
|
||||||
aria-label="Map showing concentric catchment rings around Fairlawn Primary School: 0.62 miles in 2016 shrinking to 0.31 miles in 2025, with a marker for a sample home 0.24 miles away, inside the current ring.">
|
|
||||||
<rect width="700" height="400" fill="#eef2ec"/>
|
|
||||||
<!-- park -->
|
|
||||||
<path d="M470 20 h230 v150 h-160 q-70 -20 -70 -80 z" fill="#dfe9dc"/>
|
|
||||||
<!-- water -->
|
|
||||||
<path d="M0 330 q120 -40 250 -10 t260 -20 l190 -30 v130 H0 z" fill="#dbe6ee"/>
|
|
||||||
<!-- street grid -->
|
|
||||||
<g stroke="#fff" stroke-width="7" stroke-linecap="round" opacity=".95">
|
|
||||||
<path d="M-10 90 H710"/><path d="M-10 200 H710"/><path d="M-10 300 H710"/>
|
|
||||||
<path d="M120 -10 V410"/><path d="M300 -10 V410"/><path d="M470 -10 V410"/><path d="M620 -10 V410"/>
|
|
||||||
</g>
|
|
||||||
<g stroke="#fff" stroke-width="3.5" opacity=".8">
|
|
||||||
<path d="M-10 145 H710"/><path d="M-10 250 H710"/><path d="M210 -10 V410"/><path d="M385 -10 V410"/><path d="M550 -10 V410"/>
|
|
||||||
</g>
|
|
||||||
<!-- building blocks -->
|
|
||||||
<g fill="#e4e2dc" opacity=".85">
|
|
||||||
<rect x="132" y="102" width="60" height="30" rx="2"/><rect x="222" y="102" width="64" height="30" rx="2"/>
|
|
||||||
<rect x="132" y="212" width="60" height="26" rx="2"/><rect x="222" y="212" width="64" height="26" rx="2"/>
|
|
||||||
<rect x="400" y="102" width="56" height="30" rx="2"/><rect x="400" y="212" width="56" height="26" rx="2"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- historic rings (hairline) -->
|
|
||||||
<g fill="none" stroke="#b04a2e" opacity=".38" stroke-width="1.5">
|
|
||||||
<circle cx="300" cy="200" r="150"/>
|
|
||||||
<circle cx="300" cy="200" r="126"/>
|
|
||||||
<circle cx="300" cy="200" r="106"/>
|
|
||||||
<circle cx="300" cy="200" r="90"/>
|
|
||||||
</g>
|
|
||||||
<text class="axtxt" x="300" y="44" text-anchor="middle" fill="#b04a2e" font-weight="600">2016 · 0.62 mi</text>
|
|
||||||
<!-- current ring -->
|
|
||||||
<circle cx="300" cy="200" r="75" fill="rgba(224,114,86,.18)" stroke="#e07256" stroke-width="3"/>
|
|
||||||
<text class="axtxt" x="300" y="118" text-anchor="middle" fill="#b04a2e" font-weight="700" font-size="12.5">2025 · 0.31 mi</text>
|
|
||||||
|
|
||||||
<!-- school pin -->
|
|
||||||
<circle cx="300" cy="200" r="11" fill="#b04a2e" stroke="#fff" stroke-width="3"/>
|
|
||||||
<text class="axtxt" x="300" y="232" text-anchor="middle" fill="#1a1612" font-weight="700" font-size="12">Fairlawn Primary</text>
|
|
||||||
|
|
||||||
<!-- your home -->
|
|
||||||
<g transform="translate(246,158)">
|
|
||||||
<rect x="-7" y="-7" width="14" height="14" rx="2" fill="#296f6f" stroke="#fff" stroke-width="2.5" transform="rotate(45)"/>
|
|
||||||
</g>
|
|
||||||
<text class="axtxt" x="246" y="140" text-anchor="middle" fill="#296f6f" font-weight="700" font-size="12">Your home · 0.24 mi</text>
|
|
||||||
</svg>
|
|
||||||
</figure>
|
|
||||||
|
|
||||||
<div class="maplegend">
|
|
||||||
<span><i class="swatch now" aria-hidden="true"></i>2025 cut-off — 0.31 mi</span>
|
|
||||||
<span><i class="swatch past" aria-hidden="true"></i>Earlier years, 2016–2024</span>
|
|
||||||
<span><i class="swatch you" aria-hidden="true"></i>Your home</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="checkbox">
|
|
||||||
<div class="checkhead">Would you have got in?</div>
|
|
||||||
<p class="checksub">We measure straight-line distance from your postcode, the same way Lewisham does.</p>
|
|
||||||
<form class="checkform" onsubmit="return false">
|
|
||||||
<label class="sr-only" for="pc" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Your postcode</label>
|
|
||||||
<input id="pc" type="text" value="SE23 3NA" autocomplete="postal-code" spellcheck="false">
|
|
||||||
<button type="submit">Check</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="result">
|
|
||||||
<div class="rhead">0.24 miles away — inside the cut-off in all 8 years on record</div>
|
|
||||||
<div class="rsub">That's 0.07 miles of headroom on 2025, the tightest year so far. 2018 has no published
|
|
||||||
figure, and in 2022 every applicant was offered a place.</div>
|
|
||||||
<div class="yearstrip">
|
|
||||||
<span class="yr in">2016 ✓</span>
|
|
||||||
<span class="yr in">2017 ✓</span>
|
|
||||||
<span class="yr none">2018 –</span>
|
|
||||||
<span class="yr in">2019 ✓</span>
|
|
||||||
<span class="yr in">2020 ✓</span>
|
|
||||||
<span class="yr in">2021 ✓</span>
|
|
||||||
<span class="yr in">2022 ✓</span>
|
|
||||||
<span class="yr in">2023 ✓</span>
|
|
||||||
<span class="yr in">2024 ✓</span>
|
|
||||||
<span class="yr in">2025 ✓</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="note">
|
|
||||||
<span class="i" aria-hidden="true">!</span>
|
|
||||||
<span>An indication only. Distance is applied after siblings, faith and EHCP priority, and next year's
|
|
||||||
cut-off depends on next year's applicants. Always check the school's own admissions policy.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============ 3. COVERAGE STATES ============ -->
|
|
||||||
<div class="step">
|
|
||||||
<h2>3. Coverage states</h2>
|
|
||||||
<span class="where">Partial data is the normal case</span>
|
|
||||||
</div>
|
|
||||||
<p class="stepdesc">Coverage runs from ten years to none. Each state says something true rather than
|
|
||||||
falling back on a generic "no data" — the reason a figure is absent is itself useful to a parent.</p>
|
|
||||||
|
|
||||||
<div class="cases">
|
|
||||||
<div class="case">
|
|
||||||
<h3>4+ years</h3>
|
|
||||||
<div class="body">Full treatment: verdict banner, chart, table, map rings. <strong>The verdict line only
|
|
||||||
appears with 4+ points</strong> — below that a two-year swing isn't a trend.</div>
|
|
||||||
</div>
|
|
||||||
<div class="case">
|
|
||||||
<h3>2–3 years</h3>
|
|
||||||
<div class="body">Tile and table, no verdict banner, single map ring for the latest year.
|
|
||||||
<strong>"Only 3 years available"</strong> sits under the table.</div>
|
|
||||||
</div>
|
|
||||||
<div class="case">
|
|
||||||
<h3>1 year</h3>
|
|
||||||
<div class="body">Tile plus one map ring. No <code>Distance</code> tab — the tile and its footnote are
|
|
||||||
the whole story.</div>
|
|
||||||
</div>
|
|
||||||
<div class="case">
|
|
||||||
<h3>Never oversubscribed</h3>
|
|
||||||
<div class="body" style="margin-bottom:10px">Not missing data — good news, and it should read that way.</div>
|
|
||||||
<div class="emptybox"><b style="color:var(--accent-teal)">Everyone who applied was offered a place</b>
|
|
||||||
in each of the last 6 years, so no distance cut-off was needed.</div>
|
|
||||||
</div>
|
|
||||||
<div class="case">
|
|
||||||
<h3>No data at all</h3>
|
|
||||||
<div class="body" style="margin-bottom:10px">Replaces the current placeholder text in
|
|
||||||
<code>SecondarySchoolDetailView.tsx:780</code>.</div>
|
|
||||||
<div class="emptybox">Lewisham hasn't published cut-off distances for this school.
|
|
||||||
<a href="#" style="color:var(--accent-teal);font-weight:600">The council's admissions page</a> may list them.</div>
|
|
||||||
</div>
|
|
||||||
<div class="case">
|
|
||||||
<h3>Not distance-ranked</h3>
|
|
||||||
<div class="body" style="margin-bottom:10px">Grammar and some faith schools rank on test score or faith
|
|
||||||
practice, so a distance figure would mislead.</div>
|
|
||||||
<div class="emptybox">Places here are ranked by the entrance test, not by distance.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============ 4. MOBILE ============ -->
|
|
||||||
<div class="step">
|
|
||||||
<h2>4. Mobile</h2>
|
|
||||||
<span class="where">≤ 640 px</span>
|
|
||||||
</div>
|
|
||||||
<p class="stepdesc">Tiles fall to two columns and the distance tile takes the full width beneath them, so the
|
|
||||||
headline number survives the reflow. The chart drops its table on mobile behind a "See all years" disclosure.</p>
|
|
||||||
|
|
||||||
<div class="phonerow">
|
|
||||||
<div class="phone">
|
|
||||||
<div class="phonebody">
|
|
||||||
<div class="card">
|
|
||||||
<div class="cardhead" style="margin-bottom:6px">
|
|
||||||
<h2 class="sectionTitle">Admissions</h2>
|
|
||||||
</div>
|
|
||||||
<p class="sectionSub" style="font-size:13px;margin-bottom:12px">Reception, Sept 2025</p>
|
|
||||||
<div class="seg" style="margin-bottom:14px">
|
|
||||||
<button type="button" aria-pressed="true">Year</button>
|
|
||||||
<button type="button" aria-pressed="false">Trend</button>
|
|
||||||
<button type="button" aria-pressed="false">Distance</button>
|
|
||||||
</div>
|
|
||||||
<dl class="tiles">
|
|
||||||
<div class="tile"><dd class="num">60</dd><dt class="lbl">Places offered</dt></div>
|
|
||||||
<div class="tile"><dd class="num">142</dd><dt class="lbl">Wanted it first</dt></div>
|
|
||||||
</dl>
|
|
||||||
<div class="tile newtile" style="margin-top:8px">
|
|
||||||
<span class="newflag">New</span>
|
|
||||||
<dd class="num" style="font-size:26px">0.31<span class="unit">mi</span>
|
|
||||||
<span class="sub">≈ 500 m · 6 min walk</span></dd>
|
|
||||||
<dt class="lbl">Last distance offered, 2025</dt>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2 class="sectionTitle" style="margin-bottom:12px">Distance</h2>
|
|
||||||
<div class="verdict hard" style="padding:12px 13px;margin:0 0 14px">
|
|
||||||
<span class="vic" aria-hidden="true">↓</span>
|
|
||||||
<div><div class="vhead" style="font-size:15px">Halved in nine years</div>
|
|
||||||
<div class="vsub" style="font-size:12.5px">0.62 mi → 0.31 mi</div></div>
|
|
||||||
</div>
|
|
||||||
<div class="chartwrap">
|
|
||||||
<svg class="chart" viewBox="0 0 300 150" role="img" aria-label="Last distance offered falling from 0.62 miles in 2016 to 0.31 miles in 2025.">
|
|
||||||
<g stroke="#e5dfd5" stroke-width="1">
|
|
||||||
<line x1="26" y1="20" x2="292" y2="20"/><line x1="26" y1="62" x2="292" y2="62"/><line x1="26" y1="104" x2="292" y2="104"/>
|
|
||||||
</g>
|
|
||||||
<g class="axtxt" text-anchor="end" font-size="9">
|
|
||||||
<text x="21" y="23">0.8</text><text x="21" y="65">0.5</text><text x="21" y="107">0.2</text>
|
|
||||||
</g>
|
|
||||||
<path d="M26 55 L64 69" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linecap="round"/>
|
|
||||||
<path d="M64 69 L102 84" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
|
|
||||||
<path d="M102 84 L140 78 L178 95" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
|
||||||
<path d="M178 95 L216 109" fill="none" stroke="#6d685f" stroke-width="1.5" stroke-dasharray="3 3" opacity=".55"/>
|
|
||||||
<path d="M216 109 L254 116 L280 120" fill="none" stroke="#e07256" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
|
||||||
<g fill="#e07256" stroke="#fff" stroke-width="1.8">
|
|
||||||
<circle cx="26" cy="55" r="4"/><circle cx="64" cy="69" r="4"/><circle cx="102" cy="84" r="4"/>
|
|
||||||
<circle cx="140" cy="78" r="4"/><circle cx="178" cy="95" r="4"/><circle cx="216" cy="109" r="4"/><circle cx="254" cy="116" r="4"/>
|
|
||||||
</g>
|
|
||||||
<circle cx="280" cy="120" r="6" fill="#b04a2e" stroke="#fff" stroke-width="2"/>
|
|
||||||
<circle cx="197" cy="30" r="4.5" fill="#fff" stroke="#296f6f" stroke-width="2"/>
|
|
||||||
<g class="axtxt" font-size="9" text-anchor="middle">
|
|
||||||
<text x="26" y="132">'16</text><text x="140" y="132">'20</text><text x="280" y="132">'25</text>
|
|
||||||
</g>
|
|
||||||
<text class="ptlbl" x="280" y="140" text-anchor="middle" font-size="11">0.31 mi</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<details class="disclosure" style="margin-top:10px">
|
|
||||||
<summary style="font-size:13px"><span class="chev" aria-hidden="true">›</span>See all 10 years</summary>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="phonenote">
|
|
||||||
<h3>Mobile decisions</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Distance tile spans both columns — the number a parent came for shouldn't be a half-width cell.</li>
|
|
||||||
<li>Chart keeps every year but labels only first, middle and last; the endpoint stays labelled.</li>
|
|
||||||
<li>Year-by-year table collapses into a disclosure rather than forcing a horizontal scroll.</li>
|
|
||||||
<li>Map rings reuse the existing full-screen hero map sheet, opened from the tile.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Segmented control on the admissions card swaps the stacked views.
|
|
||||||
document.querySelectorAll('.seg button[data-view]').forEach(function (btn) {
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
var group = btn.closest('.seg');
|
|
||||||
group.querySelectorAll('button').forEach(function (b) { b.setAttribute('aria-pressed', String(b === btn)); });
|
|
||||||
var target = btn.dataset.view === 'dist' ? 'v-dist' : 'v-year';
|
|
||||||
['v-year', 'v-dist'].forEach(function (id) {
|
|
||||||
document.getElementById(id).hidden = id !== target;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Last distance offered, rendered on both detail templates.
|
|
||||||
*
|
|
||||||
* The figure is the one number on these pages that a parent may act on — it is
|
|
||||||
* easy to read as "we live inside the catchment, we will get a place". These
|
|
||||||
* tests pin the things that stop it being read that way: the year is always
|
|
||||||
* present, the caveat is always present, and the blanket "not available"
|
|
||||||
* sentence appears only when it is actually true.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { screen } from '@testing-library/react';
|
|
||||||
import { renderSchoolDetail, renderSecondarySchoolDetail } from '../support/renderSchoolDetail';
|
|
||||||
import { primaryFixture, secondaryFixture } from '../support/schoolFixtures';
|
|
||||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
|
||||||
|
|
||||||
const cutoff = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
|
||||||
year: 2025,
|
|
||||||
distance_m: 500,
|
|
||||||
route_count: 1,
|
|
||||||
la_name: 'Camden',
|
|
||||||
distance_unit_raw: 'miles',
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('primary detail page', () => {
|
|
||||||
it('shows the figure with the year it belongs to', () => {
|
|
||||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ distance_m: 772.49, year: 2024 }) });
|
|
||||||
|
|
||||||
expect(screen.getByText('0.48 miles')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2024');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never shows the figure without saying it is not a catchment', () => {
|
|
||||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff() });
|
|
||||||
|
|
||||||
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/moves every year/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flags that a banded school\'s figure is the widest of several routes', () => {
|
|
||||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: cutoff({ route_count: 4 }) });
|
|
||||||
|
|
||||||
expect(screen.getByText(/4 admission routes/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders nothing distance-related when the LA publishes none', () => {
|
|
||||||
renderSchoolDetail({ ...primaryFixture, admissionDistance: null });
|
|
||||||
|
|
||||||
expect(screen.queryByText(/Last distance offered/)).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/not a fixed catchment/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('carries the figure even with no EES admissions row', () => {
|
|
||||||
// The two sources are independent; this school has a cut-off and no
|
|
||||||
// admissions figures. Before this feature the section did not render at all.
|
|
||||||
renderSchoolDetail({
|
|
||||||
...primaryFixture,
|
|
||||||
admissions: null,
|
|
||||||
admissionsHistory: [],
|
|
||||||
admissionDistance: cutoff({ distance_m: 1421.05 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('0.88 miles')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('secondary detail page', () => {
|
|
||||||
it('shows the figure with the year it belongs to', () => {
|
|
||||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff({ distance_m: 3472.96 }) });
|
|
||||||
|
|
||||||
expect(screen.getByText('2.16 miles')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Last distance offered/)).toHaveTextContent('September 2025');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops the blanket "not available" line once a distance exists', () => {
|
|
||||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: cutoff() });
|
|
||||||
|
|
||||||
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/not a fixed catchment/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('names the authority that would hold the data when there is none', () => {
|
|
||||||
// The old copy asserted "Historical distance cut-off data is not available
|
|
||||||
// for this school" on every secondary page, including the ones whose
|
|
||||||
// council does publish it.
|
|
||||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: null });
|
|
||||||
|
|
||||||
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* Last distance offered — formatting and the caveats attached to the figure.
|
|
||||||
*
|
|
||||||
* The assertions about the route note and the year are not cosmetic. A cut-off
|
|
||||||
* shown without its year, or a banded school's widest cut-off shown as if it
|
|
||||||
* were the only one, tells a parent something false about their chances of a
|
|
||||||
* place — so both are pinned here rather than left to the component.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
|
||||||
import { describeCutoff } from '@/components/school/lastDistanceOffered';
|
|
||||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
|
||||||
|
|
||||||
const distance = (over: Partial<SchoolAdmissionDistance> = {}): SchoolAdmissionDistance => ({
|
|
||||||
year: 2025,
|
|
||||||
distance_m: 500,
|
|
||||||
route_count: 1,
|
|
||||||
la_name: 'Camden',
|
|
||||||
distance_unit_raw: 'miles',
|
|
||||||
...over,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatCutoffDistance', () => {
|
|
||||||
it('leads with miles, the unit councils publish in', () => {
|
|
||||||
expect(formatCutoffDistance(500)).toEqual({ primary: '0.31 miles', secondary: '500 m' });
|
|
||||||
expect(formatCutoffDistance(1609.344)).toEqual({ primary: '1.00 miles', secondary: '1.6 km' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('switches to kilometres for the support figure above a kilometre', () => {
|
|
||||||
expect(formatCutoffDistance(3472.96)!.secondary).toBe('3.5 km');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leads with metres below 100 m, where a miles figure carries nothing', () => {
|
|
||||||
expect(formatCutoffDistance(27)).toEqual({ primary: '27 m', secondary: '0.02 miles' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null rather than a zero cut-off', () => {
|
|
||||||
// 0.0 miles appears in the source where a school filled on a higher
|
|
||||||
// criterion. Rendered as "0.00 miles" it would read as the opposite.
|
|
||||||
expect(formatCutoffDistance(0)).toBeNull();
|
|
||||||
expect(formatCutoffDistance(null)).toBeNull();
|
|
||||||
expect(formatCutoffDistance(undefined)).toBeNull();
|
|
||||||
expect(formatCutoffDistance(Number.NaN)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('formatEntryYear', () => {
|
|
||||||
it('names the intake, not the academic year', () => {
|
|
||||||
// formatAcademicYear would render 2025 as "2025/26", which reads as a
|
|
||||||
// school year rather than the September a child started.
|
|
||||||
expect(formatEntryYear(2025)).toBe('September 2025');
|
|
||||||
expect(formatEntryYear(null)).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('describeCutoff', () => {
|
|
||||||
it('always carries the entry year alongside the figure', () => {
|
|
||||||
const d = describeCutoff(distance({ distance_m: 772.49, year: 2024 }));
|
|
||||||
expect(d).not.toBeNull();
|
|
||||||
expect(d!.primary).toBe('0.48 miles');
|
|
||||||
expect(d!.entryYear).toBe('September 2024');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('says nothing about routes for a school with one', () => {
|
|
||||||
expect(describeCutoff(distance({ route_count: 1 }))!.routeNote).toBeNull();
|
|
||||||
expect(describeCutoff(distance({ route_count: null }))!.routeNote).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('warns that a banded school\'s figure is the widest of several', () => {
|
|
||||||
const note = describeCutoff(distance({ route_count: 4 }))!.routeNote;
|
|
||||||
expect(note).toContain('4 admission routes');
|
|
||||||
expect(note).toContain('shorter cut-off');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is null when there is nothing publishable', () => {
|
|
||||||
expect(describeCutoff(null)).toBeNull();
|
|
||||||
expect(describeCutoff(undefined)).toBeNull();
|
|
||||||
expect(describeCutoff(distance({ distance_m: null }))).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -60,7 +60,7 @@ describe('buildNavItems', () => {
|
|||||||
it('omits sections with no data', () => {
|
it('omits sections with no data', () => {
|
||||||
const flags = computeSchoolFlags(specialFixture);
|
const flags = computeSchoolFlags(specialFixture);
|
||||||
const ids = buildNavItems(flags, {
|
const ids = buildNavItems(flags, {
|
||||||
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
expect(ids).not.toContain('ofsted');
|
expect(ids).not.toContain('ofsted');
|
||||||
@@ -74,7 +74,6 @@ describe('buildNavItems', () => {
|
|||||||
return buildNavItems(flags, {
|
return buildNavItems(flags, {
|
||||||
ofsted: fixture.ofsted,
|
ofsted: fixture.ofsted,
|
||||||
admissions: fixture.admissions,
|
admissions: fixture.admissions,
|
||||||
admissionDistance: null,
|
|
||||||
yearlyDataLength: fixture.yearlyData.length,
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
}).find((n) => n.id === 'results')?.label;
|
}).find((n) => n.id === 'results')?.label;
|
||||||
};
|
};
|
||||||
@@ -84,27 +83,11 @@ describe('buildNavItems', () => {
|
|||||||
expect(label(allThroughFixture)).toBe('Results');
|
expect(label(allThroughFixture)).toBe('Results');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens the admissions entry for a cut-off distance with no EES admissions', () => {
|
|
||||||
// 3% of the schools that render have one source and not the other. The nav
|
|
||||||
// condition and the section's render condition have to agree, or the sticky
|
|
||||||
// nav links to an anchor that was never rendered.
|
|
||||||
const flags = computeSchoolFlags(specialFixture);
|
|
||||||
const ids = buildNavItems(flags, {
|
|
||||||
ofsted: null,
|
|
||||||
admissions: null,
|
|
||||||
admissionDistance: { year: 2025, distance_m: 500, route_count: 1, la_name: 'Camden', distance_unit_raw: 'miles' },
|
|
||||||
yearlyDataLength: 1,
|
|
||||||
}).map((n) => n.id);
|
|
||||||
|
|
||||||
expect(ids).toContain('admissions');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the engagement-led ordering', () => {
|
it('keeps the engagement-led ordering', () => {
|
||||||
const flags = computeSchoolFlags(primaryFixture);
|
const flags = computeSchoolFlags(primaryFixture);
|
||||||
const ids = buildNavItems(flags, {
|
const ids = buildNavItems(flags, {
|
||||||
ofsted: primaryFixture.ofsted,
|
ofsted: primaryFixture.ofsted,
|
||||||
admissions: primaryFixture.admissions,
|
admissions: primaryFixture.admissions,
|
||||||
admissionDistance: null,
|
|
||||||
yearlyDataLength: primaryFixture.yearlyData.length,
|
yearlyDataLength: primaryFixture.yearlyData.length,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
@@ -144,29 +127,16 @@ describe('buildSecondaryNavItems', () => {
|
|||||||
const ids = buildSecondaryNavItems(flags, {
|
const ids = buildSecondaryNavItems(flags, {
|
||||||
ofsted: secondaryFixture.ofsted,
|
ofsted: secondaryFixture.ofsted,
|
||||||
admissions: secondaryFixture.admissions,
|
admissions: secondaryFixture.admissions,
|
||||||
admissionDistance: null,
|
|
||||||
yearlyDataLength: secondaryFixture.yearlyData.length,
|
yearlyDataLength: secondaryFixture.yearlyData.length,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
|
expect(ids).toEqual(['ofsted', 'gcse', 'admissions', 'history', 'wellbeing', 'finances']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens the admissions entry for a cut-off distance alone', () => {
|
|
||||||
const flags = computeSecondaryFlags(secondaryFixture);
|
|
||||||
const ids = buildSecondaryNavItems(flags, {
|
|
||||||
ofsted: null,
|
|
||||||
admissions: null,
|
|
||||||
admissionDistance: { year: 2025, distance_m: 2400, route_count: 4, la_name: 'Islington', distance_unit_raw: 'miles' },
|
|
||||||
yearlyDataLength: 1,
|
|
||||||
}).map((n) => n.id);
|
|
||||||
|
|
||||||
expect(ids).toContain('admissions');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gates History on more than one year, unlike the primary page', () => {
|
it('gates History on more than one year, unlike the primary page', () => {
|
||||||
const flags = computeSecondaryFlags(secondaryFixture);
|
const flags = computeSecondaryFlags(secondaryFixture);
|
||||||
const ids = buildSecondaryNavItems(flags, {
|
const ids = buildSecondaryNavItems(flags, {
|
||||||
ofsted: null, admissions: null, admissionDistance: null, yearlyDataLength: 1,
|
ofsted: null, admissions: null, yearlyDataLength: 1,
|
||||||
}).map((n) => n.id);
|
}).map((n) => n.id);
|
||||||
|
|
||||||
expect(ids).not.toContain('history');
|
expect(ids).not.toContain('history');
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export function renderSchoolDetail(fixture: any) {
|
|||||||
const navItems = buildNavItems(flags, {
|
const navItems = buildNavItems(flags, {
|
||||||
ofsted: fixture.ofsted,
|
ofsted: fixture.ofsted,
|
||||||
admissions: fixture.admissions,
|
admissions: fixture.admissions,
|
||||||
admissionDistance: fixture.admissionDistance ?? null,
|
|
||||||
yearlyDataLength: fixture.yearlyData.length,
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,7 +57,6 @@ export function renderSecondarySchoolDetail(fixture: any) {
|
|||||||
const navItems = buildSecondaryNavItems(flags, {
|
const navItems = buildSecondaryNavItems(flags, {
|
||||||
ofsted: fixture.ofsted,
|
ofsted: fixture.ofsted,
|
||||||
admissions: fixture.admissions,
|
admissions: fixture.admissions,
|
||||||
admissionDistance: fixture.admissionDistance ?? null,
|
|
||||||
yearlyDataLength: fixture.yearlyData.length,
|
yearlyDataLength: fixture.yearlyData.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -178,33 +178,6 @@
|
|||||||
--step-4: 2.5rem;
|
--step-4: 2.5rem;
|
||||||
--step-5: 3rem;
|
--step-5: 3rem;
|
||||||
|
|
||||||
/* ── Section rhythm ─────────────────────────────────────────────────
|
|
||||||
The vertical gap between the bands of a page, and the gap between a
|
|
||||||
band's header and its content. Two values, not seven: the landing page
|
|
||||||
previously set its own margin on every band (24 / 32 / 16 / 48px, no
|
|
||||||
scale), which is what made a designed page read as a stack of unrelated
|
|
||||||
strips. Bands must not set their own vertical margins — the page
|
|
||||||
container owns the gap. */
|
|
||||||
--section-gap: 4rem;
|
|
||||||
--section-head-gap: 1.5rem;
|
|
||||||
|
|
||||||
/* ── Hero artwork ───────────────────────────────────────────────────
|
|
||||||
The landing hero is now a supplied raster illustration rather than a
|
|
||||||
drawn SVG, so its colours are in the file, not here. What is left is the
|
|
||||||
ground the artwork sits on and fades into.
|
|
||||||
|
|
||||||
--hero-ground is sampled from the artwork's own copy area, not from
|
|
||||||
Sand — a scrim in Sand (#F5EFE6) is far enough off to leave a visible
|
|
||||||
seam straight down the hero.
|
|
||||||
|
|
||||||
The artwork's copy area is not one flat colour: it runs from a peach
|
|
||||||
#FEE8D2 at the top to a cream #FDF3E7 around 43% height, and below ~48%
|
|
||||||
the left edge is foliage rather than cream. This value is sampled from
|
|
||||||
the middle of that pale run, where the headline and search actually sit;
|
|
||||||
the scrim is what covers the foliage further down. */
|
|
||||||
--hero-ground: #FEF2E1;
|
|
||||||
--hero-ground-rgb: 254, 242, 225;
|
|
||||||
|
|
||||||
/* ── Geometry & motion ──────────────────────────────────────────────
|
/* ── Geometry & motion ──────────────────────────────────────────────
|
||||||
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
"Soft shapes, rounded corners" — the guideline's geometry is markedly
|
||||||
rounder than the old system's 3/6/10/16. */
|
rounder than the old system's 3/6/10/16. */
|
||||||
@@ -329,13 +302,6 @@
|
|||||||
--medal-silver-rgb: 169, 182, 188;
|
--medal-silver-rgb: 169, 182, 188;
|
||||||
--medal-bronze-rgb: 201, 144, 112;
|
--medal-bronze-rgb: 201, 144, 112;
|
||||||
|
|
||||||
/* The artwork is a fixed, bright raster — it cannot be re-graded by
|
|
||||||
token the way the drawn version was. The dark theme instead dims it
|
|
||||||
in CSS (see .heroArt in HomeView.module.css) and fades it into this
|
|
||||||
ground, which is the panel colour rather than the artwork's cream. */
|
|
||||||
--hero-ground: #16222A;
|
|
||||||
--hero-ground-rgb: 22, 34, 42;
|
|
||||||
|
|
||||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
|
--shadow-soft: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-medium: 0 4px 18px rgba(0, 0, 0, 0.45);
|
--shadow-medium: 0 4px 18px rgba(0, 0, 0, 0.45);
|
||||||
--shadow-strong: 0 10px 34px rgba(0, 0, 0, 0.55);
|
--shadow-strong: 0 10px 34px rgba(0, 0, 0, 0.55);
|
||||||
@@ -506,7 +472,7 @@ table,
|
|||||||
border-color: var(--action-stronger);
|
border-color: var(--action-stronger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Secondary: brand outline — supporting actions (Add to compare) */
|
/* Secondary: brand outline — supporting actions (Add to shortlist) */
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
@@ -527,7 +493,7 @@ table,
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Active toggle state — the compare button once a school is on the list */
|
/* Active toggle state — the shortlist button once a school is on the list */
|
||||||
.btn-active {
|
.btn-active {
|
||||||
background: var(--brand-bg);
|
background: var(--brand-bg);
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
@@ -597,12 +563,6 @@ html .leaflet-bar a:hover {
|
|||||||
.main {
|
.main {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
/* Tighten the rhythm rather than abandon it — the ratio between the
|
|
||||||
section gap and the header gap stays the same. */
|
|
||||||
:root {
|
|
||||||
--section-gap: 2.75rem;
|
|
||||||
--section-head-gap: 1.15rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Honour the OS setting. Transitions collapse to near-instant rather than
|
/* Honour the OS setting. Transitions collapse to near-instant rather than
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ async function font(file: string) {
|
|||||||
* The mark, as a data URI. Satori has no access to the public/ URL space, so
|
* The mark, as a data URI. Satori has no access to the public/ URL space, so
|
||||||
* the artwork is read off disk and inlined. This is the same file the header
|
* the artwork is read off disk and inlined. This is the same file the header
|
||||||
* serves — public/brand/mark.png — so the card can never drift from the site.
|
* serves — public/brand/mark.png — so the card can never drift from the site.
|
||||||
* The 47×56 box below is that file's 176:208 ratio; re-crop the artwork and
|
|
||||||
* this has to move with it, or Satori will stretch it.
|
|
||||||
*/
|
*/
|
||||||
async function markDataUri() {
|
async function markDataUri() {
|
||||||
const png = await readFile(join(process.cwd(), 'public', 'brand', 'mark.png'));
|
const png = await readFile(join(process.cwd(), 'public', 'brand', 'mark.png'));
|
||||||
@@ -74,7 +72,7 @@ export default async function OpengraphImage() {
|
|||||||
{/* Lockup */}
|
{/* Lockup */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={mark} width={47} height={56} alt="" />
|
<img src={mark} width={45} height={56} alt="" />
|
||||||
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
|
<div style={{ display: 'flex', fontSize: 34, fontWeight: 700, letterSpacing: '-0.04em' }}>
|
||||||
<span style={{ color: INK }}>school</span>
|
<span style={{ color: INK }}>school</span>
|
||||||
<span style={{ color: TEAL }}>compare</span>
|
<span style={{ color: TEAL }}>compare</span>
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
* Main landing page with school search and browsing
|
* Main landing page with school search and browsing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
|
||||||
import { HomeView } from '@/components/HomeView';
|
import { HomeView } from '@/components/HomeView';
|
||||||
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||||
import { EditorialSection } from '@/components/EditorialSection';
|
import { EditorialSection } from '@/components/EditorialSection';
|
||||||
@@ -26,14 +24,8 @@ interface HomePageProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
export const metadata = {
|
||||||
* `title` was the bare string 'Home', which is what the browser tab, the
|
title: 'Home',
|
||||||
* bookmark and the search result all read. `absolute` opts out of the root
|
|
||||||
* layout's "%s | schoolcompare" template so the homepage doesn't end up
|
|
||||||
* saying the brand twice.
|
|
||||||
*/
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: { absolute: 'schoolcompare | Compare every school in England' },
|
|
||||||
description: 'Search and compare school performance across England',
|
description: 'Search and compare school performance across England',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,24 +79,14 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||||
// `unique_schools`, not `total_schools` — the latter is not a field this
|
const total = dataInfo?.total_schools ?? null;
|
||||||
// endpoint returns, and reading it silently yielded null on every request.
|
|
||||||
const total = dataInfo?.unique_schools ?? null;
|
|
||||||
const years = dataInfo?.years_available ?? [];
|
|
||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={schoolsData}
|
initialSchools={schoolsData}
|
||||||
filters={resolvedFilters}
|
filters={resolvedFilters}
|
||||||
totalSchools={total}
|
totalSchools={total}
|
||||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||||
editorial={hasSearchParams ? null : (
|
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
|
||||||
<EditorialSection
|
|
||||||
totalSchools={total}
|
|
||||||
localAuthorityCount={resolvedFilters.local_authorities.length}
|
|
||||||
earliestYearLabel={years.length ? formatAcademicYear(years[0]) : null}
|
|
||||||
latestYearLabel={years.length ? formatAcademicYear(years[years.length - 1]) : null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -117,14 +99,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
filters={emptyFilters}
|
filters={emptyFilters}
|
||||||
totalSchools={null}
|
totalSchools={null}
|
||||||
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||||
editorial={hasSearchParams ? null : (
|
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
|
||||||
<EditorialSection
|
|
||||||
totalSchools={null}
|
|
||||||
localAuthorityCount={0}
|
|
||||||
earliestYearLabel={null}
|
|
||||||
latestYearLabel={null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance } = data;
|
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, deprivation, finance } = data;
|
||||||
|
|
||||||
// Redirect bare URN to canonical slug URL
|
// Redirect bare URN to canonical slug URL
|
||||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||||
@@ -176,7 +176,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
const navInput = {
|
const navInput = {
|
||||||
ofsted: ofsted ?? null,
|
ofsted: ofsted ?? null,
|
||||||
admissions: admissions ?? null,
|
admissions: admissions ?? null,
|
||||||
admissionDistance: admission_distance ?? null,
|
|
||||||
yearlyDataLength: yearly_data.length,
|
yearlyDataLength: yearly_data.length,
|
||||||
};
|
};
|
||||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||||
@@ -229,7 +228,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
ofsted={ofsted ?? null}
|
ofsted={ofsted ?? null}
|
||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
admissionDistance={admission_distance ?? null}
|
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
nationalAvg={nationalAvg}
|
||||||
@@ -251,7 +249,6 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
census={census ?? null}
|
census={census ?? null}
|
||||||
admissions={admissions ?? null}
|
admissions={admissions ?? null}
|
||||||
admissionsHistory={admissions_history ?? []}
|
admissionsHistory={admissions_history ?? []}
|
||||||
admissionDistance={admission_distance ?? null}
|
|
||||||
deprivation={deprivation ?? null}
|
deprivation={deprivation ?? null}
|
||||||
finance={finance ?? null}
|
finance={finance ?? null}
|
||||||
nationalAvg={nationalAvg}
|
nationalAvg={nationalAvg}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.0 MiB |
@@ -81,7 +81,7 @@ const SECONDARY_STEPS: Step[] = [
|
|||||||
date: 'September',
|
date: 'September',
|
||||||
title: 'Portal opens',
|
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.',
|
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: 'Compare schools side by side', href: '/compare' },
|
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '31 October',
|
date: '31 October',
|
||||||
@@ -126,7 +126,7 @@ const PRIMARY_STEPS: Step[] = [
|
|||||||
date: 'September',
|
date: 'September',
|
||||||
title: 'Portal opens',
|
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.',
|
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: 'Compare schools side by side', href: '/compare' },
|
tool: { label: 'Build and compare your shortlist', href: '/compare' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
date: '15 January',
|
date: '15 January',
|
||||||
|
|||||||
@@ -5,45 +5,15 @@ import styles from './HomeView.module.css';
|
|||||||
interface EditorialSectionProps {
|
interface EditorialSectionProps {
|
||||||
totalSchools: number | null;
|
totalSchools: number | null;
|
||||||
localAuthorityCount: number;
|
localAuthorityCount: number;
|
||||||
latestYearLabel: string | null;
|
|
||||||
earliestYearLabel: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorialSection({
|
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
|
||||||
totalSchools,
|
|
||||||
localAuthorityCount,
|
|
||||||
latestYearLabel,
|
|
||||||
earliestYearLabel,
|
|
||||||
}: EditorialSectionProps) {
|
|
||||||
/*
|
|
||||||
* The coverage line replaces a six-row "Coverage at a glance" table.
|
|
||||||
*
|
|
||||||
* That table put six numbers on the page to say one thing, and the headline
|
|
||||||
* row was wrong: it read "24,000+" — its own hardcoded fallback — because
|
|
||||||
* DataInfoResponse declared a `total_schools` field the API does not send
|
|
||||||
* (it sends `unique_schools`). The fetch had succeeded; only that field was
|
|
||||||
* undefined, so the fallback rendered and nothing failed. Every figure here
|
|
||||||
* is now live, and any figure that isn't available is dropped rather than
|
|
||||||
* replaced with a guess.
|
|
||||||
*/
|
|
||||||
const coverage = [
|
|
||||||
totalSchools ? `${totalSchools.toLocaleString('en-GB')} schools` : null,
|
|
||||||
localAuthorityCount > 0 ? `${localAuthorityCount} local authorities` : null,
|
|
||||||
earliestYearLabel && latestYearLabel ? `${earliestYearLabel}–${latestYearLabel}` : null,
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// No class on the section: the page container owns the vertical rhythm
|
<section className={styles.editorial}>
|
||||||
// now, and this band carries its own ground on the card inside it.
|
<div className={styles.editorialGrid}>
|
||||||
<section>
|
|
||||||
<div className={styles.editorialCard}>
|
|
||||||
<div className={styles.sectionHead}>
|
|
||||||
<p className={styles.sectionKicker}>About school data</p>
|
|
||||||
<h2 className={styles.sectionHeading}>
|
|
||||||
Making England's school performance data actually readable
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className={styles.editorialText}>
|
<div className={styles.editorialText}>
|
||||||
|
<div className={styles.editorialKicker}>About school data</div>
|
||||||
|
<h2 className={styles.editorialHeading}>Making England's school performance data actually readable</h2>
|
||||||
<p>
|
<p>
|
||||||
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
School performance data in England is rich but fragmented. The Department for Education and Ofsted
|
||||||
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
publish Key Stage 2 SATs, GCSE attainment, inspection outcomes, progress scores, admissions figures
|
||||||
@@ -51,14 +21,38 @@ export function EditorialSection({
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
schoolcompare brings it all into one place. Every school page shows performance against the national
|
schoolcompare brings it all into one place. Every school page shows performance against the national
|
||||||
average, explains what the numbers mean, and lets you compare schools side by side. Built for
|
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||||||
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||||
full inspection report.
|
full inspection report.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{coverage.length > 0 && (
|
<div className={styles.factbox}>
|
||||||
<p className={styles.coverageLine}>{coverage.join(' · ')}</p>
|
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
||||||
)}
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Schools covered</span>
|
||||||
|
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Local authorities</span>
|
||||||
|
<span className={styles.factVal}>{localAuthorityCount > 0 ? localAuthorityCount : 152}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Phases</span>
|
||||||
|
<span className={styles.factVal}>Primary & Secondary</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Latest results year</span>
|
||||||
|
<span className={styles.factVal}>2024/25</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Historical data</span>
|
||||||
|
<span className={styles.factVal}>2016–2025</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Metrics per school</span>
|
||||||
|
<span className={styles.factVal}>40+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -460,30 +460,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Narrow phones: the button drops below the input instead of sharing the row.
|
|
||||||
*
|
|
||||||
* "Search schools" is a fixed 134px with `white-space: nowrap`, so on a 390px
|
|
||||||
* screen it took 48% of the row and left the input 102px of text space for a
|
|
||||||
* 194px placeholder — the field showed "School name or" and stopped. At 320px
|
|
||||||
* the input was down to 32px, which is too narrow to read what you are typing.
|
|
||||||
*
|
|
||||||
* Wrapping rather than restructuring: the pill stays one element, so it keeps
|
|
||||||
* its border, shadow and :focus-within ring, and the button gets a full-width
|
|
||||||
* tap target on the way. Giving the button a 100% flex-basis is what forces
|
|
||||||
* the wrap; the input keeps `flex: 1` and so takes the whole first row.
|
|
||||||
*/
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.heroMode .omniBoxContainer {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.heroMode .searchButton {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
/* Below this the pin costs more room than it earns. */
|
/* Below this the pin costs more room than it earns. */
|
||||||
.heroMode .omniIcon {
|
.heroMode .omniIcon {
|
||||||
|
|||||||
@@ -92,33 +92,13 @@
|
|||||||
max-width: 34ch;
|
max-width: 34ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Quieter than .description, but by size only — not by contrast.
|
|
||||||
*
|
|
||||||
* --on-sunken-faint measured 4.71:1 here, which clears AA by 0.21. That is a
|
|
||||||
* fine margin for decorative text and the wrong one for a disclaimer, whose
|
|
||||||
* whole job is to be legible to someone checking whether this is a government
|
|
||||||
* site. --on-sunken-muted is 5.66:1 on the same band.
|
|
||||||
*/
|
|
||||||
.independence {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--step--2);
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--on-sunken-muted);
|
|
||||||
max-width: 38ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
.sectionTitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
/* --sage flips with the theme; this band does not (it is teal in both), so
|
color: var(--sage);
|
||||||
the pairing only held in one of them — the dark sage measured 4.08:1 here
|
|
||||||
against a 4.5 floor, on every page. The --on-sunken-* family exists
|
|
||||||
precisely because this surface is theme-invariant. */
|
|
||||||
color: var(--on-sunken-muted);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,6 @@ export function Footer() {
|
|||||||
<p className={styles.description}>
|
<p className={styles.description}>
|
||||||
Compare primary and secondary schools across England.
|
Compare primary and secondary schools across England.
|
||||||
</p>
|
</p>
|
||||||
{/*
|
|
||||||
Says once, plainly, what the landing page's value props only
|
|
||||||
imply: we publish official data, we are not an official body.
|
|
||||||
Cheap to state and expensive to be wrong about.
|
|
||||||
*/}
|
|
||||||
<p className={styles.independence}>
|
|
||||||
An independent site. Not affiliated with the Department for Education or Ofsted.
|
|
||||||
</p>
|
|
||||||
<a
|
<a
|
||||||
href="mailto:contact@schoolcompare.co.uk"
|
href="mailto:contact@schoolcompare.co.uk"
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
@@ -57,9 +49,7 @@ export function Footer() {
|
|||||||
<ul className={styles.links}>
|
<ul className={styles.links}>
|
||||||
<li><a href="/" className={styles.link}>Search schools</a></li>
|
<li><a href="/" className={styles.link}>Search schools</a></li>
|
||||||
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
|
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
|
||||||
{/* "Compare", not "shortlist" — the nav, this link and the
|
<li><a href="/compare" className={styles.link}>Compare shortlist</a></li>
|
||||||
landing page all name the same feature the same way. */}
|
|
||||||
<li><a href="/compare" className={styles.link}>Compare schools</a></li>
|
|
||||||
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,229 +2,67 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Page rhythm ───────────────────────────────────────────────────────────
|
|
||||||
The landing page owns the vertical gap between its bands; no band sets its
|
|
||||||
own top or bottom margin. Previously each one did, and the gaps came out as
|
|
||||||
24 / 32 / 24 / 16 / 48 / 32 / 16px — no scale, which is most of the reason a
|
|
||||||
designed page read as a stack of unrelated strips. If you add a band here,
|
|
||||||
give it padding and a ground, never a margin. */
|
|
||||||
.landing {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--section-gap);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The hero and the four reasons under it are one thought, so they sit closer
|
|
||||||
to each other than to the next band. This is the only place that overrides
|
|
||||||
the page gap, and it does it by grouping rather than by re-spacing. */
|
|
||||||
.heroGroup {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Section header ────────────────────────────────────────────────────────
|
|
||||||
One pattern for every band: kicker, heading, optional aside. */
|
|
||||||
.sectionHead {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
align-items: baseline;
|
|
||||||
column-gap: 1.5rem;
|
|
||||||
margin-bottom: var(--section-head-gap);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionKicker {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
font-size: var(--step--2);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--brand-strong);
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionHeading {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--step-3);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
|
||||||
max-width: 24ch;
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionAside {
|
|
||||||
font-size: var(--step--1);
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin: 0;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.sectionHead {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.sectionHeading {
|
|
||||||
font-size: var(--step-2);
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
.sectionAside {
|
|
||||||
text-align: left;
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Hero ──────────────────────────────────────────────────────────────────
|
/* ── Hero ──────────────────────────────────────────────────────────────────
|
||||||
The artwork fills the panel and the proposition sits on top of it, in the
|
A Sand panel: proposition and search on the left, the brand landscape
|
||||||
empty cream area the illustration reserves on its left. It is not a
|
bleeding to the panel's right edge. The illustration is decorative, so on
|
||||||
two-column grid: splitting the panel would crop that reserved area off and
|
phones it drops to a short band under the content rather than competing
|
||||||
shrink the scene into a thumbnail, which is the one thing the artwork is
|
with the search for the fold. */
|
||||||
composed not to be.
|
|
||||||
|
|
||||||
Below the one-column breakpoint the artwork leaves the background and
|
|
||||||
becomes a band under the search — see the 860px block. */
|
|
||||||
.heroPanel {
|
.heroPanel {
|
||||||
position: relative;
|
display: grid;
|
||||||
isolation: isolate;
|
grid-template-columns: 1.12fr 0.88fr;
|
||||||
background: var(--hero-ground);
|
align-items: stretch;
|
||||||
|
background: var(--bg-secondary);
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroContent {
|
.heroContent {
|
||||||
position: relative;
|
align-self: center;
|
||||||
z-index: 2;
|
padding: 3rem 1.5rem 3rem 3rem;
|
||||||
padding: 3.5rem 1.5rem 3.5rem 3rem;
|
|
||||||
/* Held inside the artwork's cream area. Beyond roughly this width the text
|
|
||||||
runs onto the hillside, where the scrim below is doing all the work. */
|
|
||||||
max-width: min(56%, 40rem);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroArt {
|
.heroArt {
|
||||||
position: absolute;
|
position: relative;
|
||||||
inset: 0;
|
min-width: 0;
|
||||||
z-index: 0;
|
/* The scene needs height to read; without a floor it collapses to the
|
||||||
|
content column's height on short viewports. */
|
||||||
|
min-height: 24rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroArt picture,
|
.heroArt svg {
|
||||||
.heroArt img {
|
position: absolute;
|
||||||
display: block;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroArt img {
|
.heroTrust {
|
||||||
object-fit: cover;
|
|
||||||
/* Centre keeps the schoolhouse in frame at every desktop panel ratio: the
|
|
||||||
panel runs about 2.1:1 to 2.7:1 against a 1.78:1 image, so cover crops
|
|
||||||
height only, and the school sits well inside the surviving band. */
|
|
||||||
object-position: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Contrast guarantee.
|
|
||||||
*
|
|
||||||
* The artwork's cream area is generous but it is a fixed image on a fluid
|
|
||||||
* panel — as the panel widens the text block and the cream drift apart, and at
|
|
||||||
* some width the headline would sit on hillside green. Rather than tune the
|
|
||||||
* text width per breakpoint and hope, the panel fades its own ground across
|
|
||||||
* the left of the artwork, so the copy always has a known surface under it.
|
|
||||||
*
|
|
||||||
* The gradient starts from --hero-ground, which is sampled from the artwork
|
|
||||||
* itself, so in the light theme this is invisible — it reads as the
|
|
||||||
* illustration, not as an overlay on it.
|
|
||||||
*/
|
|
||||||
.heroPanel::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1;
|
|
||||||
pointer-events: none;
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
var(--hero-ground) 0%,
|
|
||||||
rgba(var(--hero-ground-rgb), 0.97) 26%,
|
|
||||||
rgba(var(--hero-ground-rgb), 0.72) 44%,
|
|
||||||
rgba(var(--hero-ground-rgb), 0) 62%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Dark theme.
|
|
||||||
*
|
|
||||||
* The artwork is a fixed raster, so unlike the drawn hero it cannot be
|
|
||||||
* re-graded token by token — but the failure it would otherwise reproduce is
|
|
||||||
* the same one: a bright illustration is the brightest object on a near-black
|
|
||||||
* page and out-shouts the H1 and the search box. Dimming it in CSS is the
|
|
||||||
* whole treatment, and the scrim then fades it into the dark panel instead of
|
|
||||||
* into cream.
|
|
||||||
*
|
|
||||||
* Raising the brightness raises the background under light dark-theme text, so
|
|
||||||
* this value has a contrast floor, not just a taste range. Measured off
|
|
||||||
* rendered pixels, sampling background up to 120px past each line:
|
|
||||||
*
|
|
||||||
* brightness title body
|
|
||||||
* 0.52 11.01:1 6.44:1
|
|
||||||
* 0.75 9.48:1 5.21:1 ← current
|
|
||||||
*
|
|
||||||
* Body text is the binding one. Going much above 0.75 walks it toward the
|
|
||||||
* 4.5:1 floor, and at that point the scrim needs to carry further right rather
|
|
||||||
* than the artwork being dimmed less.
|
|
||||||
*/
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.heroArt img {
|
|
||||||
filter: brightness(0.75) saturate(0.72) contrast(1.02);
|
|
||||||
}
|
|
||||||
.heroPanel::before {
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
var(--hero-ground) 0%,
|
|
||||||
rgba(var(--hero-ground-rgb), 0.96) 30%,
|
|
||||||
rgba(var(--hero-ground-rgb), 0.66) 50%,
|
|
||||||
rgba(var(--hero-ground-rgb), 0.15) 78%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The one line in the first person, so it is set apart from the marketing copy
|
|
||||||
* around it without shouting: display face, a step down in size, and a short
|
|
||||||
* brand rule instead of a bullet or an emoji.
|
|
||||||
*
|
|
||||||
* No italic — Manrope ships no italic in the loaded weights, so font-style
|
|
||||||
* would be synthesised into a slant. Same reason .heroEmph sets font-style
|
|
||||||
* back to normal.
|
|
||||||
*/
|
|
||||||
.heroByline {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.7rem;
|
||||||
margin: 1.25rem 0 0;
|
margin-top: 1.4rem;
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
font-weight: 500;
|
|
||||||
font-style: normal;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroByline::before {
|
.heroTrustDots {
|
||||||
content: '';
|
display: inline-flex;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 1.25rem;
|
|
||||||
height: 2px;
|
|
||||||
border-radius: 1px;
|
|
||||||
background: var(--brand);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.heroTrustDot {
|
||||||
.heroByline {
|
width: 1.6rem;
|
||||||
margin-top: 1rem;
|
height: 1.6rem;
|
||||||
font-size: var(--step--2);
|
border-radius: 50%;
|
||||||
}
|
border: 2px solid var(--bg-secondary);
|
||||||
|
margin-right: -0.55rem;
|
||||||
}
|
}
|
||||||
|
.heroTrustDot:nth-child(1) { background: var(--sage); }
|
||||||
|
.heroTrustDot:nth-child(2) { background: var(--mustard); }
|
||||||
|
.heroTrustDot:nth-child(3) { background: var(--sky); }
|
||||||
|
|
||||||
.heroEyebrow {
|
.heroEyebrow {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -234,10 +72,7 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
/* --brand on its own 10% tint over Sand measures 4.19:1 — the tint darkens
|
color: var(--brand);
|
||||||
the ground under the label, and the token was specced against the ground,
|
|
||||||
not against the tint. --brand-strong on the same composite is 5.85:1. */
|
|
||||||
color: var(--brand-strong);
|
|
||||||
background: rgba(var(--brand-rgb), 0.1);
|
background: rgba(var(--brand-rgb), 0.1);
|
||||||
padding: 0.3rem 0.7rem;
|
padding: 0.3rem 0.7rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -285,64 +120,24 @@
|
|||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.heroContent {
|
.heroContent {
|
||||||
padding: 2.75rem 1.5rem 2.75rem 2rem;
|
padding: 2.25rem 1.5rem 2.25rem 2rem;
|
||||||
max-width: min(64%, 34rem);
|
}
|
||||||
|
.heroArt {
|
||||||
|
min-height: 20rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/* One column: content first, then a short band of landscape. */
|
||||||
* One column: the artwork stops being a background and becomes a band under
|
|
||||||
* the search.
|
|
||||||
*
|
|
||||||
* Overlaying at this width does not work — the panel is too narrow for the
|
|
||||||
* copy to stay inside the artwork's cream area, so the scrim would have to
|
|
||||||
* cover nearly the whole image, which is just a tinted rectangle with a
|
|
||||||
* picture faintly behind it. Below the band, <picture> has already switched
|
|
||||||
* to the pre-cropped file (see Illustration.tsx); this is the layout half of
|
|
||||||
* the same decision.
|
|
||||||
*/
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
/*
|
|
||||||
* Artwork above the copy, not below it.
|
|
||||||
*
|
|
||||||
* The DOM keeps .heroContent first so the desktop overlay does not depend on
|
|
||||||
* source order, which left the band stranded under the search on phones —
|
|
||||||
* reading as a strip stuck on the end rather than as a hero image. `order`
|
|
||||||
* moves it visually only; it is decorative and aria-hidden, so there is no
|
|
||||||
* reading order to disturb.
|
|
||||||
*
|
|
||||||
* It costs about 136px above the search. Measured on a 667px viewport (the
|
|
||||||
* shortest phone still in use) the search still lands near 400px, well
|
|
||||||
* inside the fold.
|
|
||||||
*/
|
|
||||||
.heroPanel {
|
.heroPanel {
|
||||||
display: flex;
|
grid-template-columns: 1fr;
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
.heroContent {
|
.heroContent {
|
||||||
padding: 1.75rem 1.5rem 1.75rem;
|
padding: 2rem 1.5rem 1.75rem;
|
||||||
max-width: none;
|
|
||||||
}
|
}
|
||||||
/* 13rem, not 11: at 860px an 11rem band is 4.5:1, and squeezing the 3.2:1
|
|
||||||
crop into that throws away enough height to cut the flag off the roof and
|
|
||||||
the base off the building. 13rem holds it at about 3.8:1, where the
|
|
||||||
building stays whole. */
|
|
||||||
.heroArt {
|
.heroArt {
|
||||||
position: static;
|
min-height: 11rem;
|
||||||
order: -1;
|
order: 2;
|
||||||
height: 13rem;
|
|
||||||
}
|
|
||||||
/* The band crop puts the schoolhouse at 73% across — reported by
|
|
||||||
scripts/build-hero-images.js, which derives it from the crop box rather
|
|
||||||
than leaving the two to drift. Squeezing narrower then crops the emptier
|
|
||||||
left side rather than the subject. */
|
|
||||||
.heroArt img {
|
|
||||||
object-position: 73% center;
|
|
||||||
}
|
|
||||||
/* The scrim exists to protect text sitting on the artwork. Nothing sits on
|
|
||||||
it here, and leaving it would wash the band out. */
|
|
||||||
.heroPanel::before {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
.heroTitle {
|
.heroTitle {
|
||||||
font-size: var(--step-3);
|
font-size: var(--step-3);
|
||||||
@@ -355,11 +150,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
|
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
|
||||||
long coverage sentence, leaving the proposition and the search — the data
|
long coverage sentence, leaving the proposition and the search — a
|
||||||
provenance is carried by the "Official & trusted" prop immediately below
|
first-time visitor still gets the trust line beneath the box, which carries
|
||||||
the hero (audit P1.7, feeds the 46% home exit rate). */
|
the data provenance the coverage sentence used to (audit P1.7, feeds the
|
||||||
|
46% home exit rate). */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.heroPanel {
|
.heroPanel {
|
||||||
|
margin-bottom: 1rem;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
.heroContent {
|
.heroContent {
|
||||||
@@ -378,12 +175,12 @@
|
|||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
/* 10rem, not 8.5: the band is widest-per-height right at this breakpoint —
|
.heroTrust {
|
||||||
at 640px an 8.5rem band is 4.2:1, worse than anything above it, because
|
margin-top: 1rem;
|
||||||
the height steps down here while the width does not. 10rem keeps it near
|
font-size: var(--step--2);
|
||||||
3.6:1, in line with the rest of the range. */
|
}
|
||||||
.heroArt {
|
.heroArt {
|
||||||
height: 10rem;
|
min-height: 8.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +193,7 @@
|
|||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0 0 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.valueProp {
|
.valueProp {
|
||||||
@@ -459,6 +256,7 @@
|
|||||||
.valueProps {
|
.valueProps {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,15 +689,86 @@
|
|||||||
border-color: var(--brand-strong);
|
border-color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── How it works section ─────────────────────────────────────────────────
|
.exploringRow {
|
||||||
A Sand band, borrowing the hero panel's ground and radius so the page has
|
display: flex;
|
||||||
two anchored bands rather than one designed element and a long tail of
|
flex-direction: column;
|
||||||
unstyled strips. */
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploringLabel {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploringChips {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploringChip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.95rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploringChip:hover {
|
||||||
|
border-color: var(--brand);
|
||||||
|
color: var(--brand-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.55;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── How it works section ─────────────────────────────── */
|
||||||
|
|
||||||
.howItWorks {
|
.howItWorks {
|
||||||
background: var(--bg-secondary);
|
padding: 3rem 0 1rem;
|
||||||
border-radius: var(--radius-xl);
|
}
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
|
.hiwHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwHeading {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwSub {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hiwGrid {
|
.hiwGrid {
|
||||||
@@ -919,19 +788,8 @@
|
|||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* The preview panel sits on the card surface, not on Sand.
|
|
||||||
*
|
|
||||||
* Everything inside it is a translucent status or brand tint, and those tokens
|
|
||||||
* are specced to clear AA over --bg-card. Over Sand the same tints composite
|
|
||||||
* darker, which put the report-card chips at 4.22:1 and 4.29:1 against a 4.5
|
|
||||||
* floor — the ratio depended on a ground two levels up. On the card surface
|
|
||||||
* they are 5.69:1 and 5.81:1. The border keeps it reading as an inset frame
|
|
||||||
* now that panel and card share a colour.
|
|
||||||
*/
|
|
||||||
.hiwVisual {
|
.hiwVisual {
|
||||||
background: var(--bg-card);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0.9rem;
|
padding: 0.9rem;
|
||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
@@ -1277,10 +1135,7 @@
|
|||||||
.compareHeadLabel {
|
.compareHeadLabel {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.48rem;
|
font-size: 0.48rem;
|
||||||
/* --text-muted on the header row's tint is 4.45:1 in the dark theme —
|
color: var(--text-muted);
|
||||||
under the floor by a hair, because the tint darkens the ground beneath
|
|
||||||
the label. --text-secondary clears it on both grounds. */
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -1328,9 +1183,12 @@
|
|||||||
.hiwGrid {
|
.hiwGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.howItWorks {
|
.hiwHeader {
|
||||||
padding: 1.75rem 1.25rem;
|
flex-direction: column;
|
||||||
border-radius: var(--radius-lg);
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.hiwHeading {
|
||||||
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1354,29 +1212,18 @@
|
|||||||
|
|
||||||
/* ── Editorial section ───────────────────────────────── */
|
/* ── Editorial section ───────────────────────────────── */
|
||||||
|
|
||||||
/* Heading beside the prose rather than above it. The fact box used to occupy
|
.editorial {
|
||||||
the right-hand column; with it gone, a single column left the card half
|
padding: 2rem 0 3rem;
|
||||||
empty, because the prose is capped at a 62ch reading measure and the card
|
}
|
||||||
is not. */
|
|
||||||
.editorialCard {
|
.editorialGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
grid-template-columns: 1.4fr 1fr;
|
||||||
column-gap: 3rem;
|
gap: 2rem;
|
||||||
padding: 2rem 2.25rem;
|
padding: 1.75rem;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: 14px;
|
||||||
}
|
|
||||||
|
|
||||||
/* The shared header pattern spaces itself for a header sitting above its
|
|
||||||
content; here it sits beside it. */
|
|
||||||
.editorialCard .sectionHead {
|
|
||||||
margin-bottom: 0;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editorialCard .coverageLine {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editorialText {
|
.editorialText {
|
||||||
@@ -1385,6 +1232,23 @@
|
|||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editorialKicker {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--brand-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorialHeading {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
/* Running prose is the one place the serif appears. Slightly larger than the
|
/* Running prose is the one place the serif appears. Slightly larger than the
|
||||||
UI around it because Literata's x-height sits lower than the grotesque's. */
|
UI around it because Literata's x-height sits lower than the grotesque's. */
|
||||||
.editorialText p {
|
.editorialText p {
|
||||||
@@ -1396,25 +1260,53 @@
|
|||||||
max-width: 62ch;
|
max-width: 62ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One line of live coverage figures, replacing a six-row table whose headline
|
.factbox {
|
||||||
number was its own hardcoded fallback. Quiet by design: it is provenance,
|
background: var(--bg-secondary);
|
||||||
not a claim. */
|
border-radius: 10px;
|
||||||
.coverageLine {
|
padding: 1.25rem;
|
||||||
margin: 1.25rem 0 0;
|
display: flex;
|
||||||
padding-top: 1rem;
|
flex-direction: column;
|
||||||
border-top: 1px solid var(--border);
|
gap: 0;
|
||||||
font-size: var(--step--1);
|
}
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
|
.factboxHeading {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factRow:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factKey {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.factVal {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.editorialCard {
|
.editorialGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
gap: 1.25rem;
|
||||||
.editorialCard .sectionHead {
|
|
||||||
margin-bottom: var(--section-head-gap);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1493,137 +1385,189 @@
|
|||||||
.loadMoreButton {
|
.loadMoreButton {
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
/* =========================================================
|
|
||||||
Next admissions deadline
|
|
||||||
|
|
||||||
One bar, replacing four equally-weighted countdown cards. Those gave the
|
/* =========================================================
|
||||||
page's largest numeral to things up to 245 days away, and two of the four
|
Admissions Countdown Strip
|
||||||
counted down to offer days — dates you receive something on, which cannot
|
|
||||||
be missed. The headline is the next real deadline; the rest stay on the
|
|
||||||
page as one supporting line, so no fact was lost, only its weight.
|
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
||||||
.admissions {
|
.admissionsStrip {
|
||||||
display: flex;
|
padding: 1.5rem 0 2rem;
|
||||||
flex-direction: column;
|
border-top: 1px solid var(--border);
|
||||||
gap: 0.85rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nextDeadline {
|
.stripHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1.5rem;
|
margin-bottom: 1rem;
|
||||||
position: relative;
|
flex-wrap: wrap;
|
||||||
overflow: hidden;
|
gap: 0.5rem;
|
||||||
padding: 1.15rem 1.5rem 1.15rem 1.75rem;
|
|
||||||
background: var(--bg-card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-soft);
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: border-color var(--transition), box-shadow var(--transition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A brand rule down the leading edge — the same device the countdown cards
|
.stripLabel {
|
||||||
used along their top edge, kept so the band still reads as admissions. */
|
font-size: 0.72rem;
|
||||||
.nextDeadline::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0 auto 0 0;
|
|
||||||
width: 5px;
|
|
||||||
background: var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nextDeadline:hover {
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
box-shadow: var(--shadow-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nextDeadlineBody {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.15rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nextDeadlineKicker {
|
|
||||||
font-size: var(--step--2);
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--brand-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nextDeadlineTitle {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--step-1);
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nextDeadlineDate {
|
|
||||||
font-size: var(--step--1);
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nextDeadlineCount {
|
.stripCta {
|
||||||
display: flex;
|
font-size: 0.82rem;
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.35rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nextDeadlineDays {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: var(--step-3);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--brand-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nextDeadlineUnit {
|
|
||||||
font-size: var(--step--1);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inside a fortnight the count changes hue rather than size, so urgency is
|
|
||||||
legible without the bar growing. Terracotta is the status hue, not the
|
|
||||||
action hue — this is a state, not a button. */
|
|
||||||
.nextDeadlineUrgent .nextDeadlineDays {
|
|
||||||
color: var(--status-below);
|
|
||||||
}
|
|
||||||
|
|
||||||
.laterDates {
|
|
||||||
font-size: var(--step--1);
|
|
||||||
color: var(--text-muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.laterDatesLink {
|
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.laterDatesLink:hover {
|
.stripCta:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.countdownRail {
|
||||||
.nextDeadline {
|
display: grid;
|
||||||
align-items: flex-start;
|
grid-template-columns: repeat(4, 1fr);
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem 1.1rem 1rem 1.35rem;
|
|
||||||
}
|
}
|
||||||
.nextDeadlineCount {
|
|
||||||
align-items: baseline;
|
.countdownChip {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.1rem 0.9rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(var(--shadow-rgb), 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChip::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChipDeadline::before {
|
||||||
|
background: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChipOffer::before {
|
||||||
|
background: var(--status-above);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChipUrgent {
|
||||||
|
border-color: rgba(var(--status-below-rgb), 0.4);
|
||||||
|
background: rgba(var(--status-below-rgb), 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrackDeadline {
|
||||||
|
color: var(--brand-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrackOffer {
|
||||||
|
color: var(--status-above);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrackDot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDays {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChipDeadline .chipDays,
|
||||||
|
.countdownChipUrgent .chipDays {
|
||||||
|
color: var(--status-below);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChipOffer .chipDays {
|
||||||
|
color: var(--status-above);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDaysUnit {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
vertical-align: bottom;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipMilestone {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDate {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.countdownRail {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On phones the 2×2 grid cramped each chip so badly the "Secondary ·
|
||||||
|
Deadline" track label dropped to 9.6px. Switch to a horizontal
|
||||||
|
snap-scroller — each card is full-width-ish and stays readable,
|
||||||
|
and the rightmost card peeks past the edge to signal there's more. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.countdownRail {
|
||||||
|
display: flex;
|
||||||
|
grid-template-columns: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-right: 1.25rem;
|
||||||
|
margin-inline: -1rem;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||||
|
}
|
||||||
|
.countdownRail::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.countdownChip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 78%;
|
||||||
|
min-width: 220px;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
.chipTrack {
|
||||||
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,22 +40,13 @@ function daysUntil(month: number, day: number): number {
|
|||||||
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextOccurrence(month: number, day: number): Date {
|
function formatCountdownDate(month: number, day: number): string {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
const y = today.getFullYear();
|
const y = today.getFullYear();
|
||||||
const target = new Date(y, month - 1, day);
|
let target = new Date(y, month - 1, day);
|
||||||
return target < today ? new Date(y + 1, month - 1, day) : target;
|
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||||
}
|
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
function formatCountdownDate(month: number, day: number): string {
|
|
||||||
return nextOccurrence(month, day)
|
|
||||||
.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatShortDate(month: number, day: number): string {
|
|
||||||
return nextOccurrence(month, day)
|
|
||||||
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountdownChipData {
|
interface CountdownChipData {
|
||||||
@@ -66,16 +57,6 @@ interface CountdownChipData {
|
|||||||
day: number;
|
day: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdmissionsMilestone {
|
|
||||||
chip: CountdownChipData;
|
|
||||||
/** Whole days from today. Always present — computed during render. */
|
|
||||||
days: number;
|
|
||||||
/** "Sat, 31 October 2026" — for the headline bar. */
|
|
||||||
dateLabel: string;
|
|
||||||
/** "31 Oct 2026" — for the supporting line, where four dates share a row. */
|
|
||||||
shortDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||||
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
{ 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: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||||||
@@ -144,46 +125,26 @@ interface ValueProp {
|
|||||||
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
* The brand guideline's fourth value prop is "Save & revisit" (shortlisting /
|
||||||
* favourites). That feature does not exist in this product, so it is
|
* favourites). That feature does not exist in this product, so it is
|
||||||
* deliberately replaced by the admissions-deadline prop below — which is real,
|
* deliberately replaced by the admissions-deadline prop below — which is real,
|
||||||
* and is backed by the next-deadline bar further down this page.
|
* and is backed by the countdown strip further down this page.
|
||||||
*
|
|
||||||
* Every claim here must name something the product actually does. Two of the
|
|
||||||
* four previously did not: "up to three schools" contradicted MAX_SCHOOLS = 5
|
|
||||||
* in context/ComparisonProvider.tsx (and the card further down the page, which
|
|
||||||
* correctly said five), and "class sizes" described data the codebase has never
|
|
||||||
* held — grep for it and this line was the only hit. Both are corrected below
|
|
||||||
* against the real fields, which live in components/school/InclusionSection.tsx.
|
|
||||||
*/
|
*/
|
||||||
const VALUE_PROPS: ValueProp[] = [
|
const VALUE_PROPS: ValueProp[] = [
|
||||||
{
|
{
|
||||||
icon: <ShieldCheckIcon />,
|
icon: <ShieldCheckIcon />,
|
||||||
tintClass: styles.propIconTrust,
|
tintClass: styles.propIconTrust,
|
||||||
/*
|
title: 'Official & trusted',
|
||||||
* "Built on official data", never "Official".
|
|
||||||
*
|
|
||||||
* The previous title was "Official & trusted", whose grammatical subject
|
|
||||||
* is this site — it reads as a claim that schoolcompare is itself an
|
|
||||||
* official service. It is not: it is an independent site that republishes
|
|
||||||
* official figures. The distinction is the difference between describing
|
|
||||||
* the data and describing ourselves, and only the first is true.
|
|
||||||
*
|
|
||||||
* Everywhere else the word appears ("official DfE figures", "the official
|
|
||||||
* figure isn't in") it already qualifies the data, which is correct and
|
|
||||||
* should stay.
|
|
||||||
*/
|
|
||||||
title: 'Built on official data',
|
|
||||||
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
body: 'Every figure comes from DfE performance tables and Ofsted.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BarsIcon />,
|
icon: <BarsIcon />,
|
||||||
tintClass: styles.propIconCompare,
|
tintClass: styles.propIconCompare,
|
||||||
title: 'Easy to compare',
|
title: 'Easy to compare',
|
||||||
body: 'Up to five schools side by side, on the measures that matter.',
|
body: 'Up to three schools side by side, on the measures that matter.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <HeartIcon />,
|
icon: <HeartIcon />,
|
||||||
tintClass: styles.propIconContext,
|
tintClass: styles.propIconContext,
|
||||||
title: 'Beyond the numbers',
|
title: 'Beyond the numbers',
|
||||||
body: 'SEN support, pupil premium and attendance — not just results.',
|
body: 'Class sizes, SEN support and local context, not just results.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <CalendarIcon />,
|
icon: <CalendarIcon />,
|
||||||
@@ -213,30 +174,9 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
const mapParamsRef = useRef<string>('');
|
const mapParamsRef = useRef<string>('');
|
||||||
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||||||
const [geoError, setGeoError] = useState<string | null>(null);
|
const [geoError, setGeoError] = useState<string | null>(null);
|
||||||
/*
|
const [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
|
||||||
* Computed during render, on the server as well as the client, so the
|
ADMISSIONS_CHIPS.map(c => ({ chip: c, days: null }))
|
||||||
* deadline bar is in the first HTML rather than appearing on hydrate.
|
);
|
||||||
*
|
|
||||||
* The previous version deferred this to an effect to dodge a hydration
|
|
||||||
* mismatch, which meant the section had to reserve its own height — and the
|
|
||||||
* reservation was a single guessed number for a block whose supporting line
|
|
||||||
* wraps to a different height at every width. Measured, it was short at
|
|
||||||
* every breakpoint, shifting the page by up to 108px on a phone.
|
|
||||||
*
|
|
||||||
* The mismatch it was dodging is real but tiny: a server rendering at
|
|
||||||
* 23:59:59 and a client hydrating at 00:00:01 disagree by one day. That is
|
|
||||||
* confined to two text nodes, which carry suppressHydrationWarning below.
|
|
||||||
*/
|
|
||||||
const [milestones] = useState<AdmissionsMilestone[]>(() => {
|
|
||||||
const measured = ADMISSIONS_CHIPS.map(chip => ({
|
|
||||||
chip,
|
|
||||||
days: daysUntil(chip.month, chip.day),
|
|
||||||
dateLabel: formatCountdownDate(chip.month, chip.day),
|
|
||||||
shortDate: formatShortDate(chip.month, chip.day),
|
|
||||||
}));
|
|
||||||
measured.sort((a, b) => a.days - b.days);
|
|
||||||
return measured;
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||||
const isLocationSearch = !!searchParams.get('postcode');
|
const isLocationSearch = !!searchParams.get('postcode');
|
||||||
@@ -299,6 +239,13 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
|
||||||
|
useEffect(() => {
|
||||||
|
const withDays = ADMISSIONS_CHIPS.map(c => ({ chip: c, days: daysUntil(c.month, c.day) }));
|
||||||
|
withDays.sort((a, b) => (a.days ?? Infinity) - (b.days ?? Infinity));
|
||||||
|
setSortedChips(withDays);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLoadMore = async () => {
|
const handleLoadMore = async () => {
|
||||||
if (isLoadingMore || !hasMore) return;
|
if (isLoadingMore || !hasMore) return;
|
||||||
track('results_load_more', { next_page: currentPage + 1 });
|
track('results_load_more', { next_page: currentPage + 1 });
|
||||||
@@ -387,28 +334,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
});
|
});
|
||||||
}, [initialSchools.total, isSearchActive, searchParams]);
|
}, [initialSchools.total, isSearchActive, searchParams]);
|
||||||
|
|
||||||
/*
|
|
||||||
* The coverage figure, or nothing at all.
|
|
||||||
*
|
|
||||||
* This used to be the string "24,000+", hardcoded in three places while the
|
|
||||||
* database held 27,230 — and the fact box that was meant to show the live
|
|
||||||
* number silently rendered its fallback, because DataInfoResponse declared a
|
|
||||||
* `total_schools` field the API has never sent. Now there is one source: if
|
|
||||||
* the real count is unavailable the sentence simply omits it rather than
|
|
||||||
* inventing a floor.
|
|
||||||
*/
|
|
||||||
const coverageLabel = totalSchools ? totalSchools.toLocaleString('en-GB') : null;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The headline milestone is the next thing a parent can actually miss — an
|
|
||||||
* application deadline. Offer days are dates you receive something on, so
|
|
||||||
* they belong in the supporting line, not in the page's largest numeral.
|
|
||||||
* Four equally-sized cards counting down to things 245 days away was the
|
|
||||||
* loudest element on the page and asked nothing of anyone.
|
|
||||||
*/
|
|
||||||
const nextDeadline = milestones.find(m => m.chip.type === 'deadline') ?? null;
|
|
||||||
const laterMilestones = milestones.filter(m => m !== nextDeadline);
|
|
||||||
|
|
||||||
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
||||||
// split which surface drives compare adds.
|
// split which surface drives compare adds.
|
||||||
const addSchoolFromSearch = useCallback((school: School) => {
|
const addSchoolFromSearch = useCallback((school: School) => {
|
||||||
@@ -421,18 +346,12 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
}, [addSchool, selectedSchools.length]);
|
}, [addSchool, selectedSchools.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* The landing arrangement owns its own vertical rhythm (one gap, set in
|
<div className={styles.homeView}>
|
||||||
CSS); the search arrangement is a filter bar directly above its results
|
|
||||||
and wants none of it. */
|
|
||||||
<div className={isSearchActive ? styles.homeView : `${styles.homeView} ${styles.landing}`}>
|
|
||||||
{/* Hero: a Sand panel with the proposition and the search on the left and
|
{/* Hero: a Sand panel with the proposition and the search on the left and
|
||||||
the brand landscape bleeding to the panel edge on the right. The
|
the brand landscape bleeding to the panel edge on the right. The
|
||||||
search lives inside the panel here and above the results elsewhere,
|
search lives inside the panel here and above the results elsewhere,
|
||||||
which is why FilterBar is rendered in two places rather than moved.
|
which is why FilterBar is rendered in two places rather than moved. */}
|
||||||
The hero and the four value props are one group, so they sit closer
|
|
||||||
together than the page's section gap. */}
|
|
||||||
{!isSearchActive ? (
|
{!isSearchActive ? (
|
||||||
<div className={styles.heroGroup}>
|
|
||||||
<section className={styles.heroPanel}>
|
<section className={styles.heroPanel}>
|
||||||
<div className={styles.heroContent}>
|
<div className={styles.heroContent}>
|
||||||
<span className={styles.heroEyebrow}>
|
<span className={styles.heroEyebrow}>
|
||||||
@@ -450,7 +369,7 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
every line above the fold costs. */}
|
every line above the fold costs. */}
|
||||||
<span className={styles.heroDescriptionFull}>
|
<span className={styles.heroDescriptionFull}>
|
||||||
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
{' '}Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores
|
||||||
and admissions data{coverageLabel && <> for <strong>{coverageLabel} schools</strong></>} — side by
|
and admissions data for <strong>24,000+ schools</strong> — side by
|
||||||
side, in one place.
|
side, in one place.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -463,35 +382,21 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
geoState={geoState}
|
geoState={geoState}
|
||||||
geoError={geoError}
|
geoError={geoError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<p className={styles.heroTrust}>
|
||||||
|
<span className={styles.heroTrustDots} aria-hidden="true">
|
||||||
|
<span className={styles.heroTrustDot} />
|
||||||
|
<span className={styles.heroTrustDot} />
|
||||||
|
<span className={styles.heroTrustDot} />
|
||||||
|
</span>
|
||||||
|
Built on official DfE and Ofsted data — 24,000+ schools
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
|
||||||
Rendered after the content on purpose. On desktop it is absolutely
|
|
||||||
positioned behind the panel, so order does not matter; below the
|
|
||||||
one-column breakpoint it returns to normal flow, where DOM order is
|
|
||||||
what puts the band under the search rather than above the headline.
|
|
||||||
*/}
|
|
||||||
<div className={styles.heroArt}>
|
<div className={styles.heroArt}>
|
||||||
<HeroIllustration />
|
<HeroIllustration />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Why this site, in four lines. These titles are labels on a list,
|
|
||||||
not section headings — as <h2> they outranked the page's real
|
|
||||||
headings in the document outline and gave a screen-reader user
|
|
||||||
four false landmarks before any content. */}
|
|
||||||
<ul className={styles.valueProps}>
|
|
||||||
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
|
||||||
<li key={title} className={styles.valueProp}>
|
|
||||||
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
|
||||||
<div>
|
|
||||||
<p className={styles.propTitle}>{title}</p>
|
|
||||||
<p className={styles.propBody}>{body}</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -503,55 +408,94 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Next admissions deadline — one bar, not four cards. */}
|
{/* Why this site, in four lines. Only on the landing page. */}
|
||||||
{!isSearchActive && (
|
{!isSearchActive && (
|
||||||
<section className={styles.admissions} aria-label="Admissions deadlines">
|
<ul className={styles.valueProps}>
|
||||||
{nextDeadline && (
|
{VALUE_PROPS.map(({ icon, tintClass, title, body }) => (
|
||||||
<>
|
<li key={title} className={styles.valueProp}>
|
||||||
<a
|
<span className={`${styles.propIcon} ${tintClass}`}>{icon}</span>
|
||||||
href="/admissions"
|
<div>
|
||||||
className={
|
<h2 className={styles.propTitle}>{title}</h2>
|
||||||
nextDeadline.days <= 14
|
<p className={styles.propBody}>{body}</p>
|
||||||
? `${styles.nextDeadline} ${styles.nextDeadlineUrgent}`
|
</div>
|
||||||
: styles.nextDeadline
|
</li>
|
||||||
}
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Admissions countdown strip — only on landing page */}
|
||||||
|
{!isSearchActive && (
|
||||||
|
<section className={styles.admissionsStrip}>
|
||||||
|
<div className={styles.stripHeader}>
|
||||||
|
<span className={styles.stripLabel}>Key admissions deadlines</span>
|
||||||
|
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={styles.countdownRail}
|
||||||
|
style={{
|
||||||
|
opacity: sortedChips[0]?.days !== null ? 1 : 0,
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className={styles.nextDeadlineBody}>
|
{sortedChips.map(({ chip, days }) => {
|
||||||
<span className={styles.nextDeadlineKicker}>
|
const isUrgent = days !== null && days <= 14;
|
||||||
Next admissions deadline · {nextDeadline.chip.track.split(' · ')[0]}
|
const chipClass = [
|
||||||
|
styles.countdownChip,
|
||||||
|
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
|
||||||
|
isUrgent ? styles.countdownChipUrgent : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
const trackClass = [
|
||||||
|
styles.chipTrack,
|
||||||
|
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
|
||||||
|
].join(' ');
|
||||||
|
return (
|
||||||
|
<div key={chip.milestone} className={chipClass}>
|
||||||
|
<span className={trackClass}>
|
||||||
|
<span className={styles.chipTrackDot} aria-hidden="true" />
|
||||||
|
{chip.track}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.nextDeadlineTitle}>{nextDeadline.chip.milestone}</span>
|
<div>
|
||||||
<span className={styles.nextDeadlineDate} suppressHydrationWarning>
|
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||||||
{nextDeadline.dateLabel}
|
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||||||
</span>
|
</div>
|
||||||
</span>
|
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||||||
<span className={styles.nextDeadlineCount}>
|
<div className={styles.chipDate}>
|
||||||
<span className={styles.nextDeadlineDays} suppressHydrationWarning>
|
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
|
||||||
{nextDeadline.days === 0 ? 'Today' : nextDeadline.days}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
{nextDeadline.days !== 0 && (
|
);
|
||||||
<span className={styles.nextDeadlineUnit}>days left</span>
|
})}
|
||||||
)}
|
</div>
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<p className={styles.laterDates} suppressHydrationWarning>
|
|
||||||
{laterMilestones.map(m => `${m.chip.milestone} ${m.shortDate}`).join(' · ')}
|
|
||||||
{' '}
|
|
||||||
<a href="/admissions" className={styles.laterDatesLink}>Full admissions guide →</a>
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Secondary discovery — moved below deadlines so the admissions
|
||||||
|
countdown (time-sensitive) shows ahead of generic "explore" links. */}
|
||||||
|
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||||
|
<div className={styles.exploringRow}>
|
||||||
|
<span className={styles.exploringLabel}>Start exploring</span>
|
||||||
|
<div className={styles.exploringChips}>
|
||||||
|
<a href="/rankings" className={styles.exploringChip}>
|
||||||
|
<span className={styles.chipDot} aria-hidden="true" />
|
||||||
|
Top-rated primary schools
|
||||||
|
</a>
|
||||||
|
<a href="/rankings" className={styles.exploringChip}>
|
||||||
|
<span className={styles.chipDot} aria-hidden="true" />
|
||||||
|
Top-rated secondary schools
|
||||||
|
</a>
|
||||||
|
<a href="/compare" className={styles.exploringChip}>
|
||||||
|
<span className={styles.chipDot} aria-hidden="true" />
|
||||||
|
Start a comparison
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
||||||
{!isSearchActive && howItWorks}
|
{!isSearchActive && howItWorks}
|
||||||
{!isSearchActive && editorial}
|
{!isSearchActive && editorial}
|
||||||
|
|
||||||
{/* Results Section. Skipped entirely on the landing page when there is
|
{/* Results Section */}
|
||||||
nothing to list — an empty <section> is still a flex child, so it was
|
|
||||||
contributing a full section gap of blank space above the footer. */}
|
|
||||||
{(isSearchActive || initialSchools.schools.length > 0) && (
|
|
||||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||||
<div className={styles.sectionHeader}>
|
<div className={styles.sectionHeader}>
|
||||||
@@ -737,7 +681,6 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,16 +13,6 @@ export function HowItWorksSection() {
|
|||||||
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
{ subj: 'Writing', exp: 81, exc: 26, nat: 72, excLabel: 'Greater depth' },
|
||||||
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
{ subj: 'Maths', exp: 85, exc: 41, nat: 74, excLabel: 'Higher std' },
|
||||||
];
|
];
|
||||||
/*
|
|
||||||
* The columns are deliberately unnamed.
|
|
||||||
*
|
|
||||||
* They previously carried two real school names — "Our Lady Queen of Heaven"
|
|
||||||
* and "St Mary's Catholic Primary" — beside invented results and invented
|
|
||||||
* Ofsted grades. On a site whose entire proposition is official DfE and
|
|
||||||
* Ofsted data, publishing made-up figures against identifiable schools is
|
|
||||||
* the one thing it cannot do, and no illustrative intent survives being
|
|
||||||
* screenshotted. Generic labels make the layout point just as well.
|
|
||||||
*/
|
|
||||||
const compareRows = [
|
const compareRows = [
|
||||||
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||||
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
{ label: 'Higher standard (RWM)', a: '13%', b: '6%', aHi: true },
|
||||||
@@ -39,15 +29,9 @@ export function HowItWorksSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.howItWorks}>
|
<section className={styles.howItWorks}>
|
||||||
{/* One header pattern across every band on this page: kicker, heading,
|
<div className={styles.hiwHeader}>
|
||||||
optional aside. The page previously ran four different treatments
|
<h2 className={styles.hiwHeading}>What you'll see on every school</h2>
|
||||||
(two uppercase micro-labels — one left, one centred — a large heading
|
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
|
||||||
with right-aligned grey text, and a kicker-plus-heading), which is
|
|
||||||
most of why a designed page read as a stack of unrelated strips. */}
|
|
||||||
<div className={styles.sectionHead}>
|
|
||||||
<p className={styles.sectionKicker}>Every school page</p>
|
|
||||||
<h2 className={styles.sectionHeading}>What you'll see on every school</h2>
|
|
||||||
<p className={styles.sectionAside}>Primary or secondary — the page adapts to the phase</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hiwGrid}>
|
<div className={styles.hiwGrid}>
|
||||||
{/* Card 1 — Performance */}
|
{/* Card 1 — Performance */}
|
||||||
@@ -133,8 +117,8 @@ export function HowItWorksSection() {
|
|||||||
<div className={styles.comparePreview}>
|
<div className={styles.comparePreview}>
|
||||||
<div className={styles.compareHead}>
|
<div className={styles.compareHead}>
|
||||||
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
||||||
<div className={styles.compareHeadCell}>School A</div>
|
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
|
||||||
<div className={styles.compareHeadCell}>School B</div>
|
<div className={styles.compareHeadCell}>St Mary's<br />Catholic Primary</div>
|
||||||
</div>
|
</div>
|
||||||
{compareRows.map(({ label, a, b, aHi }) => (
|
{compareRows.map(({ label, a, b, aHi }) => (
|
||||||
<div key={label} className={styles.compareRow}>
|
<div key={label} className={styles.compareRow}>
|
||||||
@@ -143,16 +127,13 @@ export function HowItWorksSection() {
|
|||||||
<span className={styles.compareRowVal}>{b}</span>
|
<span className={styles.compareRowVal}>{b}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className={styles.compareFoot}>+ compare up to 5 schools</div>
|
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.hiwCardBody}>
|
<div className={styles.hiwCardBody}>
|
||||||
<div className={styles.hiwStep}>Compare</div>
|
<div className={styles.hiwStep}>Compare</div>
|
||||||
<div className={styles.hiwTitle}>Side by side</div>
|
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
|
||||||
{/* One verb for one feature. The site previously called this
|
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||||
"compare" in the nav, "shortlist" in the footer and "pin" here,
|
|
||||||
which reads as three separate things it does not have. */}
|
|
||||||
<p className={styles.hiwDesc}>Compare up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,103 +1,146 @@
|
|||||||
/**
|
/**
|
||||||
* The landing hero artwork.
|
* Brand illustration — the landing hero's landscape.
|
||||||
*
|
*
|
||||||
* This is the supplied illustration, not a drawing of one — see
|
* Guideline rules for illustration: no people or faces, soft shapes, rounded
|
||||||
* assets/hero-source.png and scripts/build-hero-images.js, which produces
|
* corners, minimal detail, maximum clarity. The scene reads as "the journey to
|
||||||
* everything under public/brand/hero-*. Regenerate rather than hand-editing.
|
* a school" — a winding path up through layered hills to a small schoolhouse,
|
||||||
|
* with a pin marking where you are.
|
||||||
*
|
*
|
||||||
* Server-safe by construction: no 'use client', no hooks, no handlers, so it
|
* Deliberately server-safe: no 'use client', no hooks, no event handlers, so
|
||||||
* renders in the RSC pass and never reaches the client bundle.
|
* it renders in the RSC pass and never reaches the client bundle.
|
||||||
*
|
|
||||||
* TWO CROPS, ONE <picture>
|
|
||||||
* ------------------------
|
|
||||||
* The slot is two different shapes. On desktop the artwork sits behind the
|
|
||||||
* whole panel at roughly 2.1:1 → 2.7:1, with the headline over the empty cream
|
|
||||||
* area the illustration reserves on its left. Below the one-column breakpoint
|
|
||||||
* it becomes a band between 2.6:1 and 4.9:1, under the search rather than
|
|
||||||
* behind it.
|
|
||||||
*
|
|
||||||
* A single file under `object-fit: cover` centre-crops, and at the band's
|
|
||||||
* extreme that slices a strip through the middle of the scene and loses the
|
|
||||||
* schoolhouse — exactly how the previous SVG hero failed on phones. So the
|
|
||||||
* <source media> switches crop, not just resolution: `band` is pre-cropped
|
|
||||||
* around the school and is already near 2.6:1, so the narrow band barely
|
|
||||||
* crops it further.
|
|
||||||
*
|
|
||||||
* `object-position` is set in CSS per breakpoint and is load-bearing — see
|
|
||||||
* .heroArt in HomeView.module.css.
|
|
||||||
*
|
|
||||||
* The artwork is decorative: the proposition beside it carries the meaning, so
|
|
||||||
* alt is empty and it is hidden from assistive tech rather than described.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The panel is not the viewport. `.main` is capped at 1400px with 1.5rem of
|
* These hex values mirror the brand palette in app/globals.css — --sky,
|
||||||
* padding (1rem under 768px), so the artwork's box is that minus the padding —
|
* --sage, --brand, --coral, --sand and the tints/shades the scene needs
|
||||||
* describing it as 100vw over-requests by ~48px worth of candidate at every
|
* around them. They are written literally on purpose: an illustration is
|
||||||
* width, which on a slow connection is a larger file than needed for the LCP
|
* artwork, not themed UI. Recolouring it per theme would break the picture,
|
||||||
* element.
|
* so it keeps one fixed, light palette in both themes (it sits on a Sand
|
||||||
|
* panel in light mode and reads as a framed image in dark mode).
|
||||||
*/
|
*/
|
||||||
const SIZES = '(min-width: 1400px) 1352px, (min-width: 769px) calc(100vw - 3rem), calc(100vw - 2rem)';
|
const SKY_HIGH = '#DAEDF8';
|
||||||
|
const SKY_LOW = '#EFF8FB';
|
||||||
|
const CLOUD = '#FFFFFF';
|
||||||
|
|
||||||
/** Intrinsic size of the wide crop — the aspect hint that prevents reflow. */
|
const HILL_FAR = '#D6EDE2'; // --sage, lightened
|
||||||
const WIDE_W = 2000;
|
const HILL_MID = '#A7D7C5'; // --sage, exact
|
||||||
const WIDE_H = 1125;
|
const HILL_NEAR = '#7FC3AC';
|
||||||
|
const HILL_FRONT = '#5BA88F'; // --sage darkened toward --brand
|
||||||
|
|
||||||
|
const PATH_FILL = '#FAF6EE'; // --sand, lightened
|
||||||
|
const PATH_EDGE = '#E6DAC2'; // --sand, darkened
|
||||||
|
|
||||||
|
const SCHOOL_WALL = '#FCE8C3'; // cream
|
||||||
|
const SCHOOL_ROOF = '#F0A868'; // warm orange
|
||||||
|
const SCHOOL_DOOR = '#0F766E'; // --brand, exact
|
||||||
|
const SCHOOL_WINDOW = '#C7EBF5'; // --sky, exact
|
||||||
|
const SCHOOL_CLOCK = '#FAFAF8'; // --bg-primary (Warm White)
|
||||||
|
const FLAGPOLE = '#8FA3AE';
|
||||||
|
const PENNANT = '#F97360'; // --coral, exact
|
||||||
|
|
||||||
|
const TREE_DARK = '#2E7D6B';
|
||||||
|
const TREE_MID = '#3E8C74';
|
||||||
|
const TREE_LIGHT = '#4A9E85';
|
||||||
|
const TREE_PALE = '#7FC3AC';
|
||||||
|
|
||||||
|
const PIN = '#F97360'; // --coral, exact
|
||||||
|
const PIN_EYE = '#FFFFFF';
|
||||||
|
|
||||||
|
/** Scoped so the gradient id can't collide with another inline SVG. */
|
||||||
|
const SKY_GRADIENT_ID = 'sc-hero-sky';
|
||||||
|
|
||||||
export function HeroIllustration() {
|
export function HeroIllustration() {
|
||||||
return (
|
return (
|
||||||
<picture>
|
<svg viewBox="0 0 540 520" preserveAspectRatio="xMidYMax slice" aria-hidden="true" focusable="false">
|
||||||
{/* Band crop first: <source> is first-match-wins, so the narrow-viewport
|
<defs>
|
||||||
rules have to precede the unconstrained desktop ones. */}
|
<linearGradient id={SKY_GRADIENT_ID} x1="0" y1="0" x2="0" y2="1">
|
||||||
<source
|
<stop offset="0" stopColor={SKY_HIGH} />
|
||||||
media="(max-width: 860px)"
|
<stop offset="1" stopColor={SKY_LOW} />
|
||||||
type="image/avif"
|
</linearGradient>
|
||||||
srcSet="/brand/hero-band-600.avif 600w, /brand/hero-band-900.avif 900w, /brand/hero-band-1344.avif 1344w"
|
</defs>
|
||||||
sizes={SIZES}
|
|
||||||
/>
|
|
||||||
<source
|
|
||||||
media="(max-width: 860px)"
|
|
||||||
type="image/webp"
|
|
||||||
srcSet="/brand/hero-band-600.webp 600w, /brand/hero-band-900.webp 900w, /brand/hero-band-1344.webp 1344w"
|
|
||||||
sizes={SIZES}
|
|
||||||
/>
|
|
||||||
{/*
|
|
||||||
The band's JPEG, and the reason it exists.
|
|
||||||
|
|
||||||
<img src> cannot vary by viewport, so it is always the wide crop. A
|
{/* Sky */}
|
||||||
browser that takes neither AVIF nor WebP would therefore fall through
|
<rect width="540" height="520" fill={`url(#${SKY_GRADIENT_ID})`} />
|
||||||
to the desktop frame on a phone and lose the schoolhouse — the exact
|
|
||||||
failure this whole component is arranged to prevent, surviving in the
|
{/* Two soft clouds, each a pair of overlapping ellipses */}
|
||||||
one path nobody looks at. No `type` here, so it matches anywhere the
|
<g fill={CLOUD} opacity="0.85">
|
||||||
media query does, and it sits after the modern formats so they still
|
<ellipse cx="96" cy="86" rx="32" ry="15" />
|
||||||
win where supported.
|
<ellipse cx="122" cy="79" rx="23" ry="18" />
|
||||||
*/}
|
<ellipse cx="418" cy="58" rx="27" ry="13" />
|
||||||
<source media="(max-width: 860px)" srcSet="/brand/hero-band-900.jpg" />
|
<ellipse cx="439" cy="52" rx="19" ry="14" />
|
||||||
<source
|
</g>
|
||||||
type="image/avif"
|
|
||||||
srcSet="/brand/hero-wide-1000.avif 1000w, /brand/hero-wide-1400.avif 1400w, /brand/hero-wide-2000.avif 2000w"
|
{/* Four layered rolling hills, palest and furthest first */}
|
||||||
sizes={SIZES}
|
<path d="M0 300 C 90 264 168 294 246 276 C 330 256 410 282 540 254 L540 520 L0 520 Z" fill={HILL_FAR} />
|
||||||
|
<path d="M0 344 C 104 308 186 340 268 324 C 356 306 452 332 540 306 L540 520 L0 520 Z" fill={HILL_MID} />
|
||||||
|
<path d="M0 408 C 118 378 214 410 306 392 C 400 374 470 398 540 382 L540 520 L0 520 Z" fill={HILL_NEAR} />
|
||||||
|
<path d="M0 468 C 130 446 236 474 340 458 C 432 444 486 462 540 452 L540 520 L0 520 Z" fill={HILL_FRONT} />
|
||||||
|
|
||||||
|
{/* Winding cream path, with a dotted edge for a little texture */}
|
||||||
|
<path
|
||||||
|
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
|
||||||
|
stroke={PATH_FILL}
|
||||||
|
strokeWidth="18"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
<source
|
<path
|
||||||
type="image/webp"
|
d="M214 520 C 202 466 254 448 276 424 C 298 400 274 378 254 366 C 230 352 244 328 276 316"
|
||||||
srcSet="/brand/hero-wide-1000.webp 1000w, /brand/hero-wide-1400.webp 1400w, /brand/hero-wide-2000.webp 2000w"
|
stroke={PATH_EDGE}
|
||||||
sizes={SIZES}
|
strokeWidth="18"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeDasharray="0.5 26"
|
||||||
|
opacity="0.5"
|
||||||
/>
|
/>
|
||||||
{/*
|
|
||||||
The hero is the largest thing above the fold, so it is almost certainly
|
{/* The school at the top of the path */}
|
||||||
the LCP element: fetchPriority high, and never lazy. eslint's
|
<g transform="translate(276 232)">
|
||||||
no-img-element wants next/image, which cannot art-direct between two
|
<rect x="12" y="34" width="98" height="58" rx="4" fill={SCHOOL_WALL} />
|
||||||
different crops — that is the whole point of the <picture> above.
|
<path d="M2 36 L61 6 L120 36 Z" fill={SCHOOL_ROOF} />
|
||||||
*/}
|
<rect x="54" y="62" width="20" height="30" rx="2" fill={SCHOOL_DOOR} />
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
<g fill={SCHOOL_WINDOW}>
|
||||||
<img
|
<rect x="24" y="46" width="16" height="14" rx="2" />
|
||||||
src="/brand/hero-wide-1400.jpg"
|
<rect x="86" y="46" width="16" height="14" rx="2" />
|
||||||
alt=""
|
<rect x="24" y="70" width="16" height="12" rx="2" />
|
||||||
aria-hidden="true"
|
<rect x="86" y="70" width="16" height="12" rx="2" />
|
||||||
width={WIDE_W}
|
</g>
|
||||||
height={WIDE_H}
|
{/* Flagpole and coral pennant */}
|
||||||
decoding="async"
|
<rect x="59.5" y="-14" width="2.5" height="20" fill={FLAGPOLE} />
|
||||||
fetchPriority="high"
|
<path d="M62 -13 L78 -8.5 L62 -4 Z" fill={PENNANT} />
|
||||||
/>
|
{/* Clock in the gable */}
|
||||||
</picture>
|
<circle cx="61" cy="24" r="6.5" fill={SCHOOL_CLOCK} />
|
||||||
|
<path d="M61 20.5 v7 M57.5 24 h7" stroke={SCHOOL_DOOR} strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Scattered rounded trees */}
|
||||||
|
<g fill={TREE_DARK}>
|
||||||
|
<circle cx="104" cy="372" r="27" />
|
||||||
|
<circle cx="136" cy="386" r="19" />
|
||||||
|
<circle cx="470" cy="336" r="23" />
|
||||||
|
</g>
|
||||||
|
<g fill={TREE_LIGHT}>
|
||||||
|
<circle cx="78" cy="394" r="21" />
|
||||||
|
<circle cx="492" cy="358" r="17" />
|
||||||
|
<circle cx="176" cy="420" r="18" />
|
||||||
|
</g>
|
||||||
|
<g fill={TREE_MID}>
|
||||||
|
<ellipse cx="44" cy="336" rx="15" ry="22" />
|
||||||
|
<ellipse cx="508" cy="296" rx="13" ry="18" />
|
||||||
|
<ellipse cx="150" cy="342" rx="12" ry="17" />
|
||||||
|
</g>
|
||||||
|
<g fill={TREE_PALE} opacity="0.9">
|
||||||
|
<circle cx="410" cy="420" r="16" />
|
||||||
|
<circle cx="438" cy="430" r="12" />
|
||||||
|
<circle cx="120" cy="470" r="14" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Location pin — you are here, the path leads up to the school */}
|
||||||
|
<g transform="translate(196 372)">
|
||||||
|
<path d="M21 48 C21 48 42 26 42 15.5 A21 21 0 1 0 0 15.5 C0 26 21 48 21 48 Z" fill={PIN} />
|
||||||
|
<circle cx="21" cy="15.5" r="8" fill={PIN_EYE} />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,10 +12,6 @@
|
|||||||
* ground is teal, because there the teal pin's silhouette
|
* ground is teal, because there the teal pin's silhouette
|
||||||
* disappears and only the white window survives.
|
* disappears and only the white window survives.
|
||||||
*
|
*
|
||||||
* Both are written onto one 176×208 canvas with the artwork at the same
|
|
||||||
* height, so they are drop-in swappable — see ASPECT below for why that
|
|
||||||
* matters rather than merely being neat.
|
|
||||||
*
|
|
||||||
* `variant="auto"` serves the on-dark artwork to dark-theme viewers via a
|
* `variant="auto"` serves the on-dark artwork to dark-theme viewers via a
|
||||||
* <picture> source, so this needs no JavaScript and no client boundary.
|
* <picture> source, so this needs no JavaScript and no client boundary.
|
||||||
*
|
*
|
||||||
@@ -33,22 +29,8 @@
|
|||||||
const MARK_LIGHT = '/brand/mark.png';
|
const MARK_LIGHT = '/brand/mark.png';
|
||||||
const MARK_ON_DARK = '/brand/mark-on-dark.png';
|
const MARK_ON_DARK = '/brand/mark-on-dark.png';
|
||||||
|
|
||||||
/**
|
/** Intrinsic aspect of the artwork, used to derive width from height. */
|
||||||
* Intrinsic size of the artwork files, used to derive width from height.
|
const ASPECT = 153 / 189;
|
||||||
*
|
|
||||||
* Both colourways are deliberately written onto the SAME canvas at the same
|
|
||||||
* artwork height, so this one ratio is correct for either of them. That is not
|
|
||||||
* tidiness — it is required. `variant="auto"` renders a single <img> whose
|
|
||||||
* srcset swaps the file underneath it, so the width/height attributes are
|
|
||||||
* shared by both colourways and cannot be varied per file. When the two were
|
|
||||||
* tightly cropped they had different ratios (0.810 and 0.875) and different
|
|
||||||
* padding, which meant a wrong aspect hint before load — a reflow on load
|
|
||||||
* under `width: auto` — and a visible jump in logo size whenever the OS theme
|
|
||||||
* flipped. Re-crop or replace one file and you must re-normalise both.
|
|
||||||
*/
|
|
||||||
const ART_WIDTH = 176;
|
|
||||||
const ART_HEIGHT = 208;
|
|
||||||
const ASPECT = ART_WIDTH / ART_HEIGHT;
|
|
||||||
|
|
||||||
interface LogoMarkProps {
|
interface LogoMarkProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -48,32 +48,13 @@
|
|||||||
Each bar compares against its own benchmark (expected vs higher standard /
|
Each bar compares against its own benchmark (expected vs higher standard /
|
||||||
greater depth), so the marker sits on the individual bar's track rather than
|
greater depth), so the marker sits on the individual bar's track rather than
|
||||||
as one line spanning both bars. */
|
as one line spanning both bars. */
|
||||||
/*
|
|
||||||
* Knockout, not a colour.
|
|
||||||
*
|
|
||||||
* This marker was var(--brand) — the same value as .barExpected and
|
|
||||||
* .barExceeding, so wherever it crossed a bar it measured 1.00:1 and was not
|
|
||||||
* rendered distinguishably at all. It scored 5.47:1 only against the empty
|
|
||||||
* track, which means it was visible precisely when a school was BELOW the
|
|
||||||
* national average and vanished for every school at or above it.
|
|
||||||
*
|
|
||||||
* No single colour fixes this, because the marker's position is data-driven:
|
|
||||||
* it can land on the bar, on the empty track, or straddle the boundary. So it
|
|
||||||
* is drawn as a knockout — a light core carrying a dark edge. On the teal bar
|
|
||||||
* the core reads; on the pale track the edge reads. Both tokens flip with the
|
|
||||||
* theme, so the pairing holds in dark mode too.
|
|
||||||
*
|
|
||||||
* The edge is box-shadow rather than border so it costs no layout width and
|
|
||||||
* cannot shift the 50% translate.
|
|
||||||
*/
|
|
||||||
.natTick {
|
.natTick {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -3px;
|
top: -3px;
|
||||||
bottom: -3px;
|
bottom: -3px;
|
||||||
width: 3px;
|
width: 2px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--bg-card);
|
background: var(--brand);
|
||||||
box-shadow: 0 0 0 1px var(--text-primary);
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -82,14 +63,13 @@
|
|||||||
.natTick::before {
|
.natTick::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -3px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 6px;
|
width: 5px;
|
||||||
height: 6px;
|
height: 5px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--bg-card);
|
background: var(--brand);
|
||||||
box-shadow: 0 0 0 1px var(--text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.barHeaderRight {
|
.barHeaderRight {
|
||||||
@@ -142,24 +122,12 @@
|
|||||||
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Expected and Exceeding were both var(--brand) — one colour for two series,
|
|
||||||
* distinguished only by which row you were looking at, while the legend
|
|
||||||
* claimed two.
|
|
||||||
*
|
|
||||||
* They are a sequential pair, not two categories: "exceeding" is a subset of
|
|
||||||
* the same cohort at a harder bar. So they take two steps of the same hue
|
|
||||||
* rather than two different hues, with the harder measure the more intense
|
|
||||||
* step. --brand-stronger is darker than --brand in the light theme and lighter
|
|
||||||
* in the dark one, which is the right direction in both: further from the
|
|
||||||
* ground.
|
|
||||||
*/
|
|
||||||
.barExpected {
|
.barExpected {
|
||||||
background: var(--brand);
|
background: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barExceeding {
|
.barExceeding {
|
||||||
background: var(--brand-stronger);
|
background: var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.barLabel {
|
.barLabel {
|
||||||
|
|||||||
@@ -143,40 +143,17 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
|||||||
<SubjectColumn key={subject.name} subject={subject} />
|
<SubjectColumn key={subject.name} subject={subject} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/*
|
|
||||||
The legend describes what is drawn, which it previously did not.
|
|
||||||
|
|
||||||
Both data swatches were var(--status-above) — green — while the bars
|
|
||||||
they labelled were var(--brand) teal, and they were identical to each
|
|
||||||
other, so two different series shared one swatch. Worse, the only
|
|
||||||
swatch that matched the bar colour was the one labelled "National
|
|
||||||
average": anyone reading the chart by matching colours would conclude
|
|
||||||
the teal bars WERE the national average.
|
|
||||||
|
|
||||||
Each swatch now carries the exact value its bar carries, and the
|
|
||||||
national-average swatch mirrors the knockout marker rather than being
|
|
||||||
a flat colour, so it is recognisable as the thing on the chart.
|
|
||||||
*/}
|
|
||||||
<div className={styles.legend}>
|
<div className={styles.legend}>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand)' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
||||||
Expected standard
|
Expected standard
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div className={styles.legendSwatch} style={{ background: 'var(--brand-stronger)' }} />
|
<div className={styles.legendSwatch} style={{ background: 'var(--status-above)' }} />
|
||||||
Exceeding / high score
|
Exceeding / high score
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.legendItem}>
|
<div className={styles.legendItem}>
|
||||||
<div
|
<div className={styles.legendSwatch} style={{ background: 'var(--brand)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||||
className={styles.legendSwatch}
|
|
||||||
style={{
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
boxShadow: '0 0 0 1px var(--text-primary)',
|
|
||||||
width: '3px',
|
|
||||||
height: '12px',
|
|
||||||
borderRadius: '2px',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
National average
|
National average
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,112 +12,59 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
import type { SchoolAdmissions } from '@/lib/types';
|
||||||
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
import { formatAcademicYear, formatPercentage } from '@/lib/utils';
|
||||||
import { summariseAdmissions } from '@/lib/compareLogic';
|
import { summariseAdmissions } from '@/lib/compareLogic';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
|
import { AdmissionsViewToggle } from './AdmissionsViewToggle';
|
||||||
import { AdmissionsTrendChart } from './charts';
|
import { AdmissionsTrendChart } from './charts';
|
||||||
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
|
||||||
|
|
||||||
|
|
||||||
export function AdmissionsSection({
|
export function AdmissionsSection({
|
||||||
admissions,
|
admissions,
|
||||||
admissionsHistory,
|
admissionsHistory,
|
||||||
admissionDistance,
|
|
||||||
isAllThrough,
|
isAllThrough,
|
||||||
}: {
|
}: {
|
||||||
/* Nullable: the two data sources are independent. A minority of schools have
|
admissions: SchoolAdmissions;
|
||||||
a published cut-off distance but no EES admissions row (3% of the schools
|
|
||||||
that render, sampled against staging), and for those this section exists
|
|
||||||
to carry the distance alone. */
|
|
||||||
admissions: SchoolAdmissions | null;
|
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
|
||||||
isAllThrough: boolean;
|
isAllThrough: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cutoff = describeCutoff(admissionDistance);
|
|
||||||
|
|
||||||
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
// Trend toggle only appears with ≥2 years carrying an offer rate.
|
||||||
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
const admissionsOfferYears = admissionsHistory.filter((h) => h.first_preference_offer_pct != null).length;
|
||||||
const showAdmissionsTrend = admissions != null && admissionsOfferYears >= 2;
|
const showAdmissionsTrend = admissionsOfferYears >= 2;
|
||||||
// Banded interpretation of the first-choice offer rate ("More than half of
|
// Banded interpretation of the first-choice offer rate ("More than half of
|
||||||
// first choices missed out" etc.) — the same banding the compare screen
|
// first choices missed out" etc.) — the same banding the compare screen
|
||||||
// uses, so a low offer rate reads as how severe it actually is.
|
// uses, so a low offer rate reads as how severe it actually is.
|
||||||
const admissionsSummary = admissions ? summariseAdmissions(admissions) : null;
|
const admissionsSummary = summariseAdmissions(admissions);
|
||||||
|
|
||||||
const title = (
|
const title = <>Admissions{!showAdmissionsTrend && ` (${formatAcademicYear(admissions.year)})`}</>;
|
||||||
<>Admissions{!showAdmissionsTrend && admissions && ` (${formatAcademicYear(admissions.year)})`}</>
|
|
||||||
);
|
|
||||||
|
|
||||||
{/* All-through admissions data covers a single entry point (usually the
|
{/* All-through admissions data covers a single entry point (usually the
|
||||||
Year 7 secondary intake), not reception — say so, or a parent could
|
Year 7 secondary intake), not reception — say so, or a parent could
|
||||||
read these as the whole-school figures. */}
|
read these as the whole-school figures. */}
|
||||||
const subtitle: ReactNode = isAllThrough && admissions?.school_phase ? (
|
const subtitle: ReactNode = isAllThrough && admissions.school_phase ? (
|
||||||
<p className={styles.sectionSubtitle}>
|
<p className={styles.sectionSubtitle}>
|
||||||
These figures are for {admissions.school_phase.toLowerCase()} entry
|
These figures are for {admissions.school_phase.toLowerCase()} entry
|
||||||
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
{/secondary/i.test(admissions.school_phase) ? ' (Year 7)' : /primary/i.test(admissions.school_phase) ? ' (Reception)' : ''}.
|
||||||
</p>
|
</p>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
{/* Spans both columns rather than taking a half-width cell. This is the
|
|
||||||
figure parents come to the page for, and at tile width the two-line
|
|
||||||
"0.31 miles / September 2025" pairing wraps badly. */}
|
|
||||||
const distanceTile = cutoff && (
|
|
||||||
<div className={`${styles.admissionsTile} ${styles.admissionsTileDistance}`}>
|
|
||||||
<dd className={styles.admissionsTileNum}>
|
|
||||||
{cutoff.primary}
|
|
||||||
<span className={styles.admissionsTileSub}>{cutoff.secondary}</span>
|
|
||||||
</dd>
|
|
||||||
<dt className={styles.admissionsTileLabel}>
|
|
||||||
Last distance offered · {cutoff.entryYear}
|
|
||||||
</dt>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const distanceNote = cutoff && (
|
|
||||||
<p className={styles.admissionsDistanceNote}>
|
|
||||||
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
|
|
||||||
{cutoff.routeNote && <> {cutoff.routeNote}</>}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Which row template the tile grid needs.
|
|
||||||
*
|
|
||||||
* The grid reserves two equal rows so the year view matches the height of the
|
|
||||||
* trend chart beside it. With a cut-off and no EES admissions there are no
|
|
||||||
* metric tiles to fill them, and the reserved rows render as a bare block of
|
|
||||||
* the grid's own gap colour under the distance tile.
|
|
||||||
*/
|
|
||||||
const hasMetricTiles = admissions != null && (
|
|
||||||
admissions.places_offered != null
|
|
||||||
|| admissions.first_preference_applications != null
|
|
||||||
|| admissions.first_preference_offer_pct != null
|
|
||||||
|| admissions.total_applications != null
|
|
||||||
);
|
|
||||||
const tilesClass = [
|
|
||||||
styles.admissionsTiles,
|
|
||||||
cutoff && hasMetricTiles ? styles.admissionsTilesWithDistance : '',
|
|
||||||
cutoff && !hasMetricTiles ? styles.admissionsTilesDistanceOnly : '',
|
|
||||||
].filter(Boolean).join(' ');
|
|
||||||
|
|
||||||
const yearView = (
|
const yearView = (
|
||||||
<>
|
<>
|
||||||
<dl className={tilesClass}>
|
<dl className={styles.admissionsTiles}>
|
||||||
{admissions?.places_offered != null && (
|
{admissions.places_offered != null && (
|
||||||
<div className={styles.admissionsTile}>
|
<div className={styles.admissionsTile}>
|
||||||
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
<dd className={styles.admissionsTileNum}>{admissions.places_offered}</dd>
|
||||||
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
<dt className={styles.admissionsTileLabel}>Places offered</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.first_preference_applications != null && (
|
{admissions.first_preference_applications != null && (
|
||||||
<div className={styles.admissionsTile}>
|
<div className={styles.admissionsTile}>
|
||||||
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
<dd className={styles.admissionsTileNum}>{admissions.first_preference_applications}</dd>
|
||||||
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
<dt className={styles.admissionsTileLabel}>Wanted it first</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.first_preference_offer_pct != null && (
|
{admissions.first_preference_offer_pct != null && (
|
||||||
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
|
<div className={`${styles.admissionsTile} ${styles.admissionsTileAccent}`}>
|
||||||
<dd className={styles.admissionsTileNum}>
|
<dd className={styles.admissionsTileNum}>
|
||||||
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
||||||
@@ -134,25 +81,20 @@ export function AdmissionsSection({
|
|||||||
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
|
<dt className={styles.admissionsTileLabel}>Got their first choice</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.total_applications != null && (
|
{admissions.total_applications != null && (
|
||||||
<div className={styles.admissionsTile}>
|
<div className={styles.admissionsTile}>
|
||||||
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
<dd className={styles.admissionsTileNum}>{admissions.total_applications.toLocaleString()}</dd>
|
||||||
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
<dt className={styles.admissionsTileLabel}>Applied in total</dt>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{distanceTile}
|
|
||||||
</dl>
|
</dl>
|
||||||
{admissionsSummary?.chip && (
|
{admissionsSummary.chip && (
|
||||||
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
<p className={styles.admissionsTrendSummary}>{admissionsSummary.chip.text}</p>
|
||||||
)}
|
)}
|
||||||
{distanceNote}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
{/* Guarded rather than asserted: showAdmissionsTrend already requires
|
const trendView = (
|
||||||
admissions, and tying the two together in one expression keeps that
|
|
||||||
invariant checked by the compiler instead of assumed. */}
|
|
||||||
const trendView = admissions && (
|
|
||||||
<>
|
<>
|
||||||
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
<div className={styles.admissionsChartCap}>First-choice offer rate</div>
|
||||||
<AdmissionsTrendChart history={admissionsHistory} />
|
<AdmissionsTrendChart history={admissionsHistory} />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SchoolFlags } from '@/lib/schoolSections';
|
import type { SchoolFlags } from '@/lib/schoolSections';
|
||||||
@@ -34,7 +34,6 @@ export interface PrimarySchoolSectionsProps {
|
|||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
admissionsHistory: SchoolAdmissions[];
|
admissionsHistory: SchoolAdmissions[];
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
@@ -43,7 +42,7 @@ export interface PrimarySchoolSectionsProps {
|
|||||||
|
|
||||||
export function PrimarySchoolSections({
|
export function PrimarySchoolSections({
|
||||||
schoolInfo, yearlyData, absenceData, ofsted, census,
|
schoolInfo, yearlyData, absenceData, ofsted, census,
|
||||||
admissions, admissionsHistory, admissionDistance, deprivation, finance, nationalAvg, flags,
|
admissions, admissionsHistory, deprivation, finance, nationalAvg, flags,
|
||||||
}: PrimarySchoolSectionsProps) {
|
}: PrimarySchoolSectionsProps) {
|
||||||
const primaryAvg = nationalAvg?.primary ?? {};
|
const primaryAvg = nationalAvg?.primary ?? {};
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
@@ -92,15 +91,10 @@ export function PrimarySchoolSections({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Either source is enough to justify the section. The cut-off distance
|
{admissions && (
|
||||||
and the EES admissions figures come from different places and a
|
|
||||||
minority of schools have one without the other — gating on admissions
|
|
||||||
alone would hide a published distance on those pages. */}
|
|
||||||
{(admissions || admissionDistance) && (
|
|
||||||
<AdmissionsSection
|
<AdmissionsSection
|
||||||
admissions={admissions}
|
admissions={admissions}
|
||||||
admissionsHistory={admissionsHistory}
|
admissionsHistory={admissionsHistory}
|
||||||
admissionDistance={admissionDistance}
|
|
||||||
isAllThrough={flags.isAllThrough}
|
isAllThrough={flags.isAllThrough}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,22 +6,17 @@
|
|||||||
* JavaScript at all. Server component.
|
* JavaScript at all. Server component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { School, SchoolAdmissions, SchoolAdmissionDistance } from '@/lib/types';
|
import type { School, SchoolAdmissions } from '@/lib/types';
|
||||||
import { formatPercentage } from '@/lib/utils';
|
import { formatPercentage } from '@/lib/utils';
|
||||||
import { Section, sectionStyles as styles } from './sectionShared';
|
import { Section, sectionStyles as styles } from './sectionShared';
|
||||||
import { describeCutoff, CUTOFF_NOTE, CUTOFF_MEASUREMENT_NOTE } from './lastDistanceOffered';
|
|
||||||
|
|
||||||
export function SecondaryAdmissionsSection({
|
export function SecondaryAdmissionsSection({
|
||||||
admissions, admissionDistance, schoolInfo, hasSixthForm,
|
admissions, schoolInfo, hasSixthForm,
|
||||||
}: {
|
}: {
|
||||||
/* Nullable for the same reason as the primary section: a school can have a
|
admissions: SchoolAdmissions;
|
||||||
published cut-off and no EES admissions row. */
|
|
||||||
admissions: SchoolAdmissions | null;
|
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
|
||||||
schoolInfo: School;
|
schoolInfo: School;
|
||||||
hasSixthForm: boolean;
|
hasSixthForm: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cutoff = describeCutoff(admissionDistance);
|
|
||||||
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
||||||
const admissionsTag = (() => {
|
const admissionsTag = (() => {
|
||||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||||
@@ -45,43 +40,32 @@ export function SecondaryAdmissionsSection({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.metricsGrid}>
|
||||||
{admissions?.places_offered != null && (
|
{admissions.places_offered != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Year 7 places offered</div>
|
<div className={styles.metricLabel}>Year 7 places offered</div>
|
||||||
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.total_applications != null && (
|
{admissions.total_applications != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Total applications</div>
|
<div className={styles.metricLabel}>Total applications</div>
|
||||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.first_preference_applications != null && (
|
{admissions.first_preference_applications != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>1st preference applications</div>
|
<div className={styles.metricLabel}>1st preference applications</div>
|
||||||
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
<div className={styles.metricValue}>{admissions.first_preference_applications.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions?.first_preference_offer_pct != null && (
|
{admissions.first_preference_offer_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Families who got their first choice</div>
|
<div className={styles.metricLabel}>Families who got their first choice</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{cutoff && (
|
|
||||||
<div className={`${styles.metricCard} ${styles.metricCardDistance}`}>
|
|
||||||
<div className={styles.metricLabel}>
|
|
||||||
Last distance offered · {cutoff.entryYear}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>
|
{admissions.oversubscribed != null && (
|
||||||
{cutoff.primary}
|
|
||||||
<span className={styles.metricValueSub}>{cutoff.secondary}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{admissions?.oversubscribed != null && (
|
|
||||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
||||||
{admissions.oversubscribed
|
{admissions.oversubscribed
|
||||||
? '⚠ Applications exceeded places last year'
|
? '⚠ Applications exceeded places last year'
|
||||||
@@ -89,23 +73,9 @@ export function SecondaryAdmissionsSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Replaces a blanket "distance cut-off data is not available for this
|
|
||||||
school", which was hardcoded onto every secondary page and was untrue
|
|
||||||
wherever the local authority does publish. The absence is now stated
|
|
||||||
only when it is real, and names the authority that would hold it. */}
|
|
||||||
{cutoff ? (
|
|
||||||
<p className={styles.admissionsDistanceNote}>
|
|
||||||
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
|
|
||||||
{cutoff.routeNote && <> {cutoff.routeNote}</>}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||||
{schoolInfo.local_authority
|
Historical distance cut-off data is not available for this school. Contact the admissions authority for oversubscription criteria details.
|
||||||
? `${schoolInfo.local_authority} has not published a cut-off distance for this school.`
|
|
||||||
: 'No cut-off distance has been published for this school.'}{' '}
|
|
||||||
Contact the admissions authority for its oversubscription criteria.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
|
|
||||||
{hasSixthForm && (
|
{hasSixthForm && (
|
||||||
<div className={styles.sixthFormNote}>
|
<div className={styles.sixthFormNote}>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
School, SchoolResult, AbsenceData, OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolAdmissions, SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { ofstedLegacyAreas } from '@/lib/utils';
|
import { ofstedLegacyAreas } from '@/lib/utils';
|
||||||
import type { SecondaryFlags } from '@/lib/schoolSections';
|
import type { SecondaryFlags } from '@/lib/schoolSections';
|
||||||
@@ -32,7 +32,6 @@ export interface SecondarySchoolSectionsProps {
|
|||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
census: SchoolCensus | null;
|
census: SchoolCensus | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
nationalAvg: NationalAverages | null;
|
nationalAvg: NationalAverages | null;
|
||||||
@@ -41,7 +40,7 @@ export interface SecondarySchoolSectionsProps {
|
|||||||
|
|
||||||
export function SecondarySchoolSections({
|
export function SecondarySchoolSections({
|
||||||
schoolInfo, yearlyData, ofsted, census,
|
schoolInfo, yearlyData, ofsted, census,
|
||||||
admissions, admissionDistance, deprivation, finance, nationalAvg, flags,
|
admissions, deprivation, finance, nationalAvg, flags,
|
||||||
}: SecondarySchoolSectionsProps) {
|
}: SecondarySchoolSectionsProps) {
|
||||||
const secondaryAvg = nationalAvg?.secondary ?? {};
|
const secondaryAvg = nationalAvg?.secondary ?? {};
|
||||||
|
|
||||||
@@ -80,12 +79,9 @@ export function SecondarySchoolSections({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* See PrimarySchoolSections: distance and EES admissions are independent
|
{admissions && (
|
||||||
sources, so either one warrants the section. */}
|
|
||||||
{(admissions || admissionDistance) && (
|
|
||||||
<SecondaryAdmissionsSection
|
<SecondaryAdmissionsSection
|
||||||
admissions={admissions}
|
admissions={admissions}
|
||||||
admissionDistance={admissionDistance}
|
|
||||||
schoolInfo={schoolInfo}
|
schoolInfo={schoolInfo}
|
||||||
hasSixthForm={flags.hasSixthForm}
|
hasSixthForm={flags.hasSixthForm}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared logic and copy for the "last distance offered" figure.
|
|
||||||
*
|
|
||||||
* The primary and secondary admissions sections lay their metrics out
|
|
||||||
* differently (a seamless tile grid vs. metric cards), so the markup is not
|
|
||||||
* shared — but the words are. Every caveat below is doing a specific job, and
|
|
||||||
* a figure that appeared on one template without them would be misleading in a
|
|
||||||
* way the other template was not:
|
|
||||||
*
|
|
||||||
* * The year, because a cut-off is the outcome of one particular admissions
|
|
||||||
* round and means nothing detached from it.
|
|
||||||
* * "not a fixed catchment", because that is the inference a parent will
|
|
||||||
* otherwise draw, and it is wrong — the distance moves every year.
|
|
||||||
* * The route count, because on a banded school the headline is the widest
|
|
||||||
* of several cut-offs and does not apply to every child.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SchoolAdmissionDistance } from '@/lib/types';
|
|
||||||
import { formatCutoffDistance, formatEntryYear } from '@/lib/utils';
|
|
||||||
|
|
||||||
export interface CutoffDisplay {
|
|
||||||
/** Headline figure, e.g. "0.31 miles". */
|
|
||||||
primary: string;
|
|
||||||
/** Supporting conversion, e.g. "500 m". */
|
|
||||||
secondary: string;
|
|
||||||
/** Entry point the figure belongs to, e.g. "September 2025". */
|
|
||||||
entryYear: string;
|
|
||||||
/** Present only where several admission routes were collapsed into one figure. */
|
|
||||||
routeNote: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the cut-off measures, and what it does not. Identical on both templates. */
|
|
||||||
export const CUTOFF_NOTE =
|
|
||||||
'The furthest home offered a place, after higher priorities such as siblings, ' +
|
|
||||||
'faith and EHCP were applied. It is not a fixed catchment — it moves every year ' +
|
|
||||||
'with the number of applications.';
|
|
||||||
|
|
||||||
/** Straight-line, because that is how councils measure it. */
|
|
||||||
export const CUTOFF_MEASUREMENT_NOTE = 'Measured in a straight line from the school.';
|
|
||||||
|
|
||||||
export function describeCutoff(
|
|
||||||
distance: SchoolAdmissionDistance | null | undefined
|
|
||||||
): CutoffDisplay | null {
|
|
||||||
if (!distance) return null;
|
|
||||||
|
|
||||||
const figure = formatCutoffDistance(distance.distance_m);
|
|
||||||
if (!figure) return null;
|
|
||||||
|
|
||||||
const routes = distance.route_count ?? 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
primary: figure.primary,
|
|
||||||
secondary: figure.secondary,
|
|
||||||
entryYear: formatEntryYear(distance.year),
|
|
||||||
routeNote:
|
|
||||||
routes > 1
|
|
||||||
? `Furthest of ${routes} admission routes at this school — the one that ` +
|
|
||||||
'applies to your child may have had a shorter cut-off.'
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -778,38 +778,6 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Last distance offered, secondary template. These cards are opaque
|
|
||||||
(--bg-secondary), so the tint can be set directly here — unlike the primary
|
|
||||||
tile grid, nothing shows through between them. */
|
|
||||||
.metricCardDistance {
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
|
|
||||||
border-color: var(--brand);
|
|
||||||
/* Two columns' worth of width. Its label carries the entry year as well as
|
|
||||||
the measure, which at the 140px track width of the other cards wraps to
|
|
||||||
three lines and pushes the figure out of alignment with them. */
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricCardDistance .metricValue {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.1rem;
|
|
||||||
color: var(--brand-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricValueSub {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Same measured reason as .admissionsTileDistance above: --text-muted falls to
|
|
||||||
4.09:1 on the tinted card in dark theme. */
|
|
||||||
.metricCardDistance .metricLabel,
|
|
||||||
.metricCardDistance .metricValueSub {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metricHint {
|
.metricHint {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1485,62 +1453,6 @@
|
|||||||
color: var(--brand-strong);
|
color: var(--brand-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Admissions: last distance offered ──
|
|
||||||
*
|
|
||||||
* Spans the full grid width instead of taking a fifth half-cell. Two reasons:
|
|
||||||
* the tiles are a 2-column grid, so a fifth tile would sit alone next to a gap;
|
|
||||||
* and this is the figure the section is most often opened for, which a
|
|
||||||
* half-width cell would rank below "Applied in total".
|
|
||||||
*/
|
|
||||||
.admissionsTileDistance {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
/*
|
|
||||||
* --brand-bg is translucent, and the tile grid paints --border between
|
|
||||||
* cells, so a plain `background` would let the hairline colour show through
|
|
||||||
* and read as muddy rather than tinted. The opaque card colour is laid down
|
|
||||||
* first and the tint composited over it as an image.
|
|
||||||
*/
|
|
||||||
background-color: var(--bg-card);
|
|
||||||
background-image: linear-gradient(var(--brand-bg), var(--brand-bg));
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsTileDistance .admissionsTileNum {
|
|
||||||
color: var(--brand-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The tint costs the muted tokens their AA margin, so this tile does not use
|
|
||||||
* them.
|
|
||||||
*
|
|
||||||
* Measured on the composited backdrop, not the computed one: the tint is a
|
|
||||||
* background-image over --bg-card, so getComputedStyle reports the untinted
|
|
||||||
* card and flatters the result. Sampling the rendered pixel in dark theme gives
|
|
||||||
* a backdrop of rgb(34,59,64), on which --text-muted is 4.09:1 — under the 4.5
|
|
||||||
* floor — while --text-secondary is 6.50:1.
|
|
||||||
*
|
|
||||||
* The label is where the year lives, so it is the last thing on the tile that
|
|
||||||
* can afford to be hard to read: a distance without its year is not a fact
|
|
||||||
* about anything.
|
|
||||||
*/
|
|
||||||
.admissionsTileDistance .admissionsTileLabel,
|
|
||||||
.admissionsTileDistance .admissionsTileSub {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The caveats, not a footnote in the visual-noise sense: without them the
|
|
||||||
* figure reads as a catchment boundary. Kept at body contrast rather than
|
|
||||||
* --text-muted for that reason — it qualifies the number above it, so it has
|
|
||||||
* to be as readable as the number.
|
|
||||||
*/
|
|
||||||
.admissionsDistanceNote {
|
|
||||||
margin: 0.85rem 0 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
max-width: 68ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsVerdict {
|
.admissionsVerdict {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
@@ -1656,20 +1568,6 @@
|
|||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: 1fr 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The distance tile occupies a third row. Left implicit it would inherit the
|
|
||||||
1fr sizing of the row template and stretch to match the metric tiles above,
|
|
||||||
which are twice its content height. */
|
|
||||||
.admissionsViewYear .admissionsTilesWithDistance {
|
|
||||||
grid-template-rows: 1fr 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cut-off distance but no EES admissions figures: there is nothing to put in
|
|
||||||
the two metric rows, and leaving them in the template paints an empty block
|
|
||||||
of the grid's gap colour beneath the tile. */
|
|
||||||
.admissionsViewYear .admissionsTilesDistanceOnly {
|
|
||||||
grid-template-rows: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admissionsChartCap {
|
.admissionsChartCap {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1909,23 +1807,12 @@
|
|||||||
border-radius: 4px 0 0 4px;
|
border-radius: 4px 0 0 4px;
|
||||||
transition: width 0.6s ease;
|
transition: width 0.6s ease;
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
* Knockout, for the same reason as .natTick in SatsChart.module.css: this was
|
|
||||||
* var(--brand), identical to .att8VizFill above it, so where the marker
|
|
||||||
* crossed the school's own bar it measured 1.00:1 — invisible for exactly the
|
|
||||||
* schools at or above the national average.
|
|
||||||
*
|
|
||||||
* A light core with a dark edge reads on the teal fill and on the pale track
|
|
||||||
* alike, and both tokens flip with the theme. Keep this in step with
|
|
||||||
* SatsChart's .natTick; they are the same marker on two templates.
|
|
||||||
*/
|
|
||||||
.att8VizNatLine {
|
.att8VizNatLine {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -4px;
|
top: -4px;
|
||||||
bottom: -4px;
|
bottom: -4px;
|
||||||
width: 3px;
|
width: 2px;
|
||||||
background: var(--bg-card);
|
background: var(--brand);
|
||||||
box-shadow: 0 0 0 1px var(--text-primary);
|
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData, SchoolCensus,
|
School, SchoolResult, AbsenceData, SchoolCensus,
|
||||||
OfstedInspection, SchoolAdmissions, SchoolAdmissionDistance, SchoolDeprivation, SchoolFinance,
|
OfstedInspection, SchoolAdmissions, SchoolDeprivation, SchoolFinance,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { isSpecialSchool } from './utils';
|
import { isSpecialSchool } from './utils';
|
||||||
|
|
||||||
@@ -119,9 +119,6 @@ export function computeSchoolFlags({
|
|||||||
export interface NavItemsInput {
|
export interface NavItemsInput {
|
||||||
ofsted: OfstedInspection | null;
|
ofsted: OfstedInspection | null;
|
||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
/** A published cut-off distance renders the Admissions section on its own,
|
|
||||||
* so it has to open the nav entry too. */
|
|
||||||
admissionDistance: SchoolAdmissionDistance | null;
|
|
||||||
yearlyDataLength: number;
|
yearlyDataLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +133,7 @@ export interface NavItemsInput {
|
|||||||
*/
|
*/
|
||||||
export function buildNavItems(
|
export function buildNavItems(
|
||||||
flags: SchoolFlags,
|
flags: SchoolFlags,
|
||||||
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
|
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
|
||||||
): NavItem[] {
|
): NavItem[] {
|
||||||
const navItems: NavItem[] = [];
|
const navItems: NavItem[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
@@ -146,7 +143,7 @@ export function buildNavItems(
|
|||||||
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
|
label: flags.isAllThrough ? 'Results' : flags.isSecondary ? 'GCSEs' : 'SATs',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
if (flags.hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
|
||||||
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyDataLength > 0) navItems.push({ id: 'history', label: 'History' });
|
||||||
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
if (flags.hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
|
||||||
@@ -216,12 +213,12 @@ export function computeSecondaryFlags({
|
|||||||
*/
|
*/
|
||||||
export function buildSecondaryNavItems(
|
export function buildSecondaryNavItems(
|
||||||
flags: SecondaryFlags,
|
flags: SecondaryFlags,
|
||||||
{ ofsted, admissions, admissionDistance, yearlyDataLength }: NavItemsInput,
|
{ ofsted, admissions, yearlyDataLength }: NavItemsInput,
|
||||||
): NavItem[] {
|
): NavItem[] {
|
||||||
const navItems: NavItem[] = [];
|
const navItems: NavItem[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
if (flags.hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
|
||||||
if (admissions || admissionDistance) navItems.push({ id: 'admissions', label: 'Admissions' });
|
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
|
||||||
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
|
if (yearlyDataLength > 1) navItems.push({ id: 'history', label: 'History' });
|
||||||
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
if (flags.hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
|
||||||
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
if (flags.hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
|
||||||
|
|||||||
@@ -163,30 +163,6 @@ export interface SchoolAdmissions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last distance offered — how far from the school the last child to be given a
|
|
||||||
* place that year lived.
|
|
||||||
*
|
|
||||||
* Not a catchment: it is the outcome of one year's applications and moves every
|
|
||||||
* year. Only published by the local authorities that choose to, so most schools
|
|
||||||
* have none of this at all.
|
|
||||||
*/
|
|
||||||
export interface SchoolAdmissionDistance {
|
|
||||||
/** Entry year the cut-off belongs to — always shown alongside the figure. */
|
|
||||||
year: number;
|
|
||||||
/** Straight-line distance in metres. */
|
|
||||||
distance_m: number | null;
|
|
||||||
/**
|
|
||||||
* Admission routes collapsed into this figure (ability bands, or separate
|
|
||||||
* reception and junior intakes). Above 1 the distance is the furthest of
|
|
||||||
* several, which the page has to say out loud.
|
|
||||||
*/
|
|
||||||
route_count: number | null;
|
|
||||||
la_name: string | null;
|
|
||||||
/** The unit the council published in ("miles", "meters", "km"). */
|
|
||||||
distance_unit_raw: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SchoolDeprivation {
|
export interface SchoolDeprivation {
|
||||||
lsoa_code: string | null;
|
lsoa_code: string | null;
|
||||||
idaci_score: number | null;
|
idaci_score: number | null;
|
||||||
@@ -354,8 +330,6 @@ export interface SchoolDetailsResponse {
|
|||||||
admissions: SchoolAdmissions | null;
|
admissions: SchoolAdmissions | null;
|
||||||
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
/** All available admissions years, oldest first. Drives the multi-year trend view. */
|
||||||
admissions_history: SchoolAdmissions[];
|
admissions_history: SchoolAdmissions[];
|
||||||
/** Latest published cut-off distance, or null where the LA publishes none. */
|
|
||||||
admission_distance: SchoolAdmissionDistance | null;
|
|
||||||
deprivation: SchoolDeprivation | null;
|
deprivation: SchoolDeprivation | null;
|
||||||
finance: SchoolFinance | null;
|
finance: SchoolFinance | null;
|
||||||
}
|
}
|
||||||
@@ -470,27 +444,11 @@ export interface MetricsResponse {
|
|||||||
metrics: MetricDefinition[];
|
metrics: MetricDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/data-info.
|
|
||||||
*
|
|
||||||
* The school count field is `unique_schools`. This interface declared
|
|
||||||
* `total_schools` instead — a field the API has never sent — so every read of
|
|
||||||
* it was `undefined` at runtime while type-checking cleanly, and the landing
|
|
||||||
* page silently fell back to a hardcoded "24,000+" against a real 27,230.
|
|
||||||
* Nothing threw and no test failed, because the interface, not the code, was
|
|
||||||
* the thing that was wrong. Keep these names matched to backend/app.py.
|
|
||||||
*/
|
|
||||||
export interface DataInfoResponse {
|
export interface DataInfoResponse {
|
||||||
status: 'loaded' | 'no_data';
|
total_schools: number;
|
||||||
data_source: string;
|
years_available: number[];
|
||||||
/** Absent on the `no_data` branch — see backend/app.py:1015. Optional here
|
latest_year: number;
|
||||||
* so callers are forced to handle the case instead of reading undefined. */
|
total_records: number;
|
||||||
unique_schools?: number;
|
|
||||||
years_available?: number[];
|
|
||||||
total_records?: number;
|
|
||||||
schools_per_year?: Record<string, number>;
|
|
||||||
local_authorities?: Record<string, number>;
|
|
||||||
message?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -502,54 +502,6 @@ export function getPhaseStyle(phase?: string | null): { key: string; label: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const METRES_PER_MILE = 1609.344;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format an admission cut-off distance.
|
|
||||||
*
|
|
||||||
* Miles lead because that is the unit councils publish in — 90% of the source
|
|
||||||
* rows are miles — and because it is the unit a parent has already been quoted
|
|
||||||
* if they have read their council's booklet. The metric figure follows as
|
|
||||||
* support rather than replacing it.
|
|
||||||
*
|
|
||||||
* Below 100 m the miles figure stops carrying information ("0.03 miles"), so
|
|
||||||
* the two swap round and metres lead instead.
|
|
||||||
*/
|
|
||||||
export function formatCutoffDistance(
|
|
||||||
metres: number | null | undefined
|
|
||||||
): { primary: string; secondary: string } | null {
|
|
||||||
if (metres == null || !Number.isFinite(metres) || metres <= 0) return null;
|
|
||||||
|
|
||||||
const miles = metres / METRES_PER_MILE;
|
|
||||||
|
|
||||||
if (metres < 100) {
|
|
||||||
return {
|
|
||||||
primary: `${Math.round(metres)} m`,
|
|
||||||
secondary: `${miles.toFixed(2)} miles`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
primary: `${miles.toFixed(2)} miles`,
|
|
||||||
secondary: metres < 1000
|
|
||||||
? `${Math.round(metres / 10) * 10} m`
|
|
||||||
: `${(metres / 1000).toFixed(1)} km`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The entry point a cut-off belongs to, phrased the way councils phrase it.
|
|
||||||
*
|
|
||||||
* The distance year is a plain entry year (2025 = the September 2025 intake),
|
|
||||||
* not the six-digit academic year EES uses, so formatAcademicYear would render
|
|
||||||
* it as "2025/26" and invite the reader to think of it as a school year rather
|
|
||||||
* than an application round.
|
|
||||||
*/
|
|
||||||
export function formatEntryYear(year: number | null | undefined): string {
|
|
||||||
if (year == null) return '';
|
|
||||||
return `September ${year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAcademicYear(year: number | null | undefined): string {
|
export function formatAcademicYear(year: number | null | undefined): string {
|
||||||
if (year == null) return '';
|
if (year == null) return '';
|
||||||
const s = year.toString();
|
const s = year.toString();
|
||||||
|
|||||||
@@ -31,8 +31,7 @@
|
|||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0"
|
||||||
"sharp": "^0.34.5"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -938,8 +937,8 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||||
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -4161,8 +4160,8 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -8585,9 +8584,9 @@
|
|||||||
"version": "0.34.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.0.0",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -8630,8 +8629,8 @@
|
|||||||
"version": "7.7.3",
|
"version": "7.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"optional": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.2.0"
|
||||||
"sharp": "^0.34.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Brand asset provenance
|
|
||||||
|
|
||||||
Every image shipped in this folder, where it came from, and what we are allowed
|
|
||||||
to do with it. Add a row before adding a file — in two years nobody will
|
|
||||||
remember, and "we think it was fine" is not a licence.
|
|
||||||
|
|
||||||
## `mark.png`, `mark-on-dark.png`
|
|
||||||
|
|
||||||
The schoolcompare mark, extracted from the logo sheet supplied by the project
|
|
||||||
owner. Two colourways on one 176×208 canvas — see `components/Logo.tsx` for why
|
|
||||||
they must share a canvas.
|
|
||||||
|
|
||||||
- **Source:** supplied by the project owner
|
|
||||||
- **Licence:** owned by the project
|
|
||||||
|
|
||||||
> Only a 153×189 instance exists on the supplied sheet, so the 512px PWA icon is
|
|
||||||
> upscaled and slightly soft. A vector (SVG/AI/EPS) dropped in here and
|
|
||||||
> regenerated fixes it; every consumer goes through `components/Logo.tsx` or
|
|
||||||
> this folder, so it is one swap.
|
|
||||||
|
|
||||||
## `hero-wide-*`, `hero-band-*`
|
|
||||||
|
|
||||||
The landing hero illustration: a path climbing through hills to a schoolhouse,
|
|
||||||
with an empty cream area on the left reserved for the headline.
|
|
||||||
|
|
||||||
- **Source:** supplied by the project owner
|
|
||||||
- **Master:** `nextjs-app/assets/hero-source.png` (2000×1125 PNG), committed so
|
|
||||||
the derivatives can be regenerated
|
|
||||||
- **Derivatives:** `node scripts/build-hero-images.js` — never hand-edit these
|
|
||||||
- **Licence:** **to be confirmed by the project owner**
|
|
||||||
|
|
||||||
> ⚠️ The licence line above is the one thing here that is not verified. If this
|
|
||||||
> artwork was bought, generated, or commissioned, record which — and keep the
|
|
||||||
> receipt or licence key alongside it. It is the most prominent image on the
|
|
||||||
> site.
|
|
||||||
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 46 KiB |
@@ -1,114 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Derivatives for the landing hero artwork.
|
|
||||||
*
|
|
||||||
* node scripts/build-hero-images.js
|
|
||||||
*
|
|
||||||
* Source of truth is assets/hero-source.png (1672×941). It is committed so
|
|
||||||
* these can be regenerated: never hand-edit anything in public/brand/hero-*,
|
|
||||||
* change the source or the numbers here and re-run.
|
|
||||||
*
|
|
||||||
* TWO CROPS, ON PURPOSE
|
|
||||||
* ---------------------
|
|
||||||
* The hero slot is not one shape. On desktop the artwork sits behind the whole
|
|
||||||
* panel at roughly 2.1:1 → 2.7:1; below the one-column breakpoint it collapses
|
|
||||||
* to a band between 2.6:1 and 4.9:1. A single file under `object-fit: cover`
|
|
||||||
* centre-crops, which at the extremes slices a strip through the middle of the
|
|
||||||
* scene and loses the schoolhouse — the same failure the previous SVG hero had.
|
|
||||||
*
|
|
||||||
* So:
|
|
||||||
* wide the full frame, including the empty cream area the artwork reserves
|
|
||||||
* on the left for the headline. Used behind the desktop panel.
|
|
||||||
* band a crop around the schoolhouse and the path, already near 2.6:1, so
|
|
||||||
* the narrow band barely crops it at all. The school sits at ~65%
|
|
||||||
* across, which is why the CSS pairs this with object-position: 65%:
|
|
||||||
* squeezing further toward 4.9:1 crops the empty sides, not the subject.
|
|
||||||
*
|
|
||||||
* AVIF and WebP are both emitted. This is flat-shaded vector-style artwork, so
|
|
||||||
* AVIF lands roughly an order of magnitude under the source PNG. Both crops
|
|
||||||
* also get a JPEG: the wide one backs the <img src>, and the band one backs a
|
|
||||||
* <source media> — without it a browser taking neither modern format would
|
|
||||||
* fall through to the desktop frame on a phone, since <img src> cannot vary by
|
|
||||||
* viewport.
|
|
||||||
*
|
|
||||||
* sharp is declared in devDependencies rather than leaned on as a transitive
|
|
||||||
* dependency of next. It does currently arrive that way, but relying on that
|
|
||||||
* means a Next upgrade can silently break this script — the same reasoning as
|
|
||||||
* the outputFileTracingIncludes note in next.config.js.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const sharp = require('sharp');
|
|
||||||
|
|
||||||
const SRC = path.join(__dirname, '..', 'assets', 'hero-source.png');
|
|
||||||
const OUT = path.join(__dirname, '..', 'public', 'brand');
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Band crop, in source pixels, measured against the artwork rather than
|
|
||||||
* guessed: the schoolhouse spans roughly x 1420–1860, y 420–660, with its flag
|
|
||||||
* up at y 330.
|
|
||||||
*
|
|
||||||
* The crop is flush to the right edge because it has to be. The school sits at
|
|
||||||
* about 82% across the frame, so there is only ~140px of artwork to its right
|
|
||||||
* — not enough to centre it. Anchoring right and taking 1050px back puts the
|
|
||||||
* school at ~66% across the crop, which is what BAND_FOCUS_X below reports to
|
|
||||||
* the CSS so the narrowest band crops the empty left side rather than the
|
|
||||||
* subject.
|
|
||||||
*/
|
|
||||||
const BAND = { left: 656, top: 300, width: 1344, height: 420 };
|
|
||||||
|
|
||||||
/** Where the schoolhouse lands in the band crop, for CSS object-position. */
|
|
||||||
const BAND_FOCUS_X = Math.round(((1420 + 1860) / 2 - BAND.left) / BAND.width * 100);
|
|
||||||
|
|
||||||
const WIDE_WIDTHS = [2000, 1400, 1000];
|
|
||||||
const BAND_WIDTHS = [1344, 900, 600];
|
|
||||||
|
|
||||||
async function emit(pipeline, name, width) {
|
|
||||||
const base = pipeline.clone().resize({ width, withoutEnlargement: true });
|
|
||||||
await base.clone().avif({ quality: 62, effort: 6 }).toFile(path.join(OUT, `${name}-${width}.avif`));
|
|
||||||
await base.clone().webp({ quality: 80, effort: 6 }).toFile(path.join(OUT, `${name}-${width}.webp`));
|
|
||||||
return width;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
if (!fs.existsSync(SRC)) throw new Error(`missing source: ${SRC}`);
|
|
||||||
fs.mkdirSync(OUT, { recursive: true });
|
|
||||||
|
|
||||||
const meta = await sharp(SRC).metadata();
|
|
||||||
console.log(`source ${meta.width}×${meta.height}`);
|
|
||||||
console.log(`band focus x: ${BAND_FOCUS_X}% (keep .heroArt object-position in step)`);
|
|
||||||
|
|
||||||
const wide = sharp(SRC);
|
|
||||||
for (const w of WIDE_WIDTHS) await emit(wide, 'hero-wide', w);
|
|
||||||
// Fallback for the <img> itself. Flattened onto the artwork's own cream so a
|
|
||||||
// JPEG (no alpha) can never composite against black.
|
|
||||||
await sharp(SRC)
|
|
||||||
.resize({ width: 1400 })
|
|
||||||
.flatten({ background: '#FEF2E1' })
|
|
||||||
.jpeg({ quality: 82, mozjpeg: true })
|
|
||||||
.toFile(path.join(OUT, 'hero-wide-1400.jpg'));
|
|
||||||
|
|
||||||
const band = sharp(SRC).extract(BAND);
|
|
||||||
for (const w of BAND_WIDTHS) await emit(band, 'hero-band', w);
|
|
||||||
await sharp(SRC)
|
|
||||||
.extract(BAND)
|
|
||||||
.resize({ width: 900 })
|
|
||||||
.flatten({ background: '#FEF2E1' })
|
|
||||||
.jpeg({ quality: 82, mozjpeg: true })
|
|
||||||
.toFile(path.join(OUT, 'hero-band-900.jpg'));
|
|
||||||
|
|
||||||
const files = fs.readdirSync(OUT).filter(f => f.startsWith('hero-')).sort();
|
|
||||||
let total = 0;
|
|
||||||
for (const f of files) {
|
|
||||||
const { size } = fs.statSync(path.join(OUT, f));
|
|
||||||
total += size;
|
|
||||||
console.log(` ${f.padEnd(24)} ${(size / 1024).toFixed(1)} kB`);
|
|
||||||
}
|
|
||||||
console.log(`total ${(total / 1024).toFixed(1)} kB across ${files.length} files`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -224,68 +224,3 @@ with DAG(
|
|||||||
)
|
)
|
||||||
|
|
||||||
extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci
|
extract_idaci >> dbt_build_idaci >> invalidate_cache_idaci
|
||||||
|
|
||||||
|
|
||||||
# ── Annual DAG (Last distance offered) ────────────────────────────────
|
|
||||||
|
|
||||||
with DAG(
|
|
||||||
dag_id="school_data_annual_distance",
|
|
||||||
default_args=default_args,
|
|
||||||
description="Last distance offered (LA admission cut-offs) extraction and transform",
|
|
||||||
# Councils publish on allocation day (March for secondary, April for
|
|
||||||
# primary) and each one on its own timetable, so there is no date worth
|
|
||||||
# scheduling against. Triggered manually after a collection run refreshes
|
|
||||||
# the CSV.
|
|
||||||
schedule=None,
|
|
||||||
start_date=datetime(2025, 1, 1),
|
|
||||||
catchup=False,
|
|
||||||
tags=["school-compare", "annual"],
|
|
||||||
) as annual_distance_dag:
|
|
||||||
|
|
||||||
extract_distance = BashOperator(
|
|
||||||
task_id="extract_distance",
|
|
||||||
bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-school-distance target-postgres",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coverage is the thing that silently rots here: the CSV is assembled by
|
|
||||||
# hand from council publications, so a collection run that half-failed
|
|
||||||
# produces a valid file with a fraction of the schools in it. A row count
|
|
||||||
# alone would not catch that — losing an entire local authority leaves the
|
|
||||||
# total looking healthy — so the floor is checked on distinct LAs too.
|
|
||||||
validate_distance = BashOperator(
|
|
||||||
task_id="validate_distance",
|
|
||||||
bash_command=f"""
|
|
||||||
cd {PIPELINE_DIR} && python -c "
|
|
||||||
import psycopg2, os, sys
|
|
||||||
conn = psycopg2.connect(
|
|
||||||
host=os.environ.get('PG_HOST', 'localhost'),
|
|
||||||
port=os.environ.get('PG_PORT', '5432'),
|
|
||||||
user=os.environ.get('PG_USER', 'postgres'),
|
|
||||||
password=os.environ.get('PG_PASSWORD', 'postgres'),
|
|
||||||
dbname=os.environ.get('PG_DATABASE', 'school_compare'),
|
|
||||||
)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute('SELECT count(*), count(distinct urn), count(distinct la_code) FROM raw.school_distance_offered')
|
|
||||||
rows, urns, las = cur.fetchone()
|
|
||||||
conn.close()
|
|
||||||
print(f'Loaded {{rows}} rows, {{urns}} schools, {{las}} local authorities')
|
|
||||||
if rows < 7000 or urns < 3000 or las < 45:
|
|
||||||
print('ERROR: distance extract is short of expected coverage '
|
|
||||||
'(baseline 2026-08: 9128 rows / 3726 schools / 57 LAs)', file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
print('Validation passed')
|
|
||||||
"
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
dbt_build_distance = BashOperator(
|
|
||||||
task_id="dbt_build",
|
|
||||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_school_distance+ fact_admission_distance+",
|
|
||||||
)
|
|
||||||
|
|
||||||
invalidate_cache_distance = BashOperator(
|
|
||||||
task_id="invalidate_cache",
|
|
||||||
bash_command=INVALIDATE_CACHE_CMD,
|
|
||||||
)
|
|
||||||
|
|
||||||
extract_distance >> validate_distance >> dbt_build_distance >> invalidate_cache_distance
|
|
||||||
@@ -67,21 +67,6 @@ plugins:
|
|||||||
pip_url: ./plugins/extractors/tap-uk-idaci
|
pip_url: ./plugins/extractors/tap-uk-idaci
|
||||||
executable: tap-uk-idaci
|
executable: tap-uk-idaci
|
||||||
|
|
||||||
# Last distance offered — admission cut-offs collected from LA
|
|
||||||
# publications. There is no national source for this, so the URL points at
|
|
||||||
# the collected CSV rather than at a government endpoint (same pattern as
|
|
||||||
# the legacy KS2/KS4 URLs above).
|
|
||||||
- name: tap-uk-school-distance
|
|
||||||
namespace: uk_school_distance
|
|
||||||
pip_url: ./plugins/extractors/tap-uk-school-distance
|
|
||||||
executable: tap-uk-school-distance
|
|
||||||
settings:
|
|
||||||
- name: download_url
|
|
||||||
kind: string
|
|
||||||
description: URL of the collected last-distance-offered CSV
|
|
||||||
config:
|
|
||||||
download_url: http://10.0.1.224:8081/filebrowser/api/public/dl/UU_oEqVr?inline=true
|
|
||||||
|
|
||||||
loaders:
|
loaders:
|
||||||
- name: target-postgres
|
- name: target-postgres
|
||||||
variant: meltanolabs
|
variant: meltanolabs
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=68", "wheel"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "tap-uk-school-distance"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "Singer tap for last-distance-offered admission cut-offs collected from LA admissions publications"
|
|
||||||
requires-python = ">=3.10"
|
|
||||||
dependencies = [
|
|
||||||
"singer-sdk~=0.53",
|
|
||||||
"requests>=2.31",
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
tap-uk-school-distance = "tap_uk_school_distance.tap:TapUKSchoolDistance.cli"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""tap-uk-school-distance: Singer tap for LA "last distance offered" admission cut-offs."""
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"""Singer tap for "last distance offered" school admission cut-offs.
|
|
||||||
|
|
||||||
Unlike every other tap in this project, the upstream here is not a single
|
|
||||||
government endpoint. There is no national dataset of admission cut-off
|
|
||||||
distances: each local authority publishes its own, in its own format
|
|
||||||
(PDF booklets, XLSX allocation tables, HTML pages), and only some publish
|
|
||||||
at all. The CSV this tap reads is the output of that collection work —
|
|
||||||
one row per school per year per admission route, already normalised to
|
|
||||||
metres in `distance_m`.
|
|
||||||
|
|
||||||
The tap deliberately does no cleaning. Implausible values (0.0 miles,
|
|
||||||
1-metre cut-offs, a 533-mile outlier) are loaded verbatim into `raw` and
|
|
||||||
filtered in `stg_school_distance`, so the raw table stays a faithful
|
|
||||||
record of what the councils published and the plausibility rules live in
|
|
||||||
one reviewable place.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from singer_sdk import Stream, Tap
|
|
||||||
from singer_sdk import typing as th
|
|
||||||
|
|
||||||
REQUEST_TIMEOUT = 300
|
|
||||||
|
|
||||||
|
|
||||||
class SchoolDistanceStream(Stream):
|
|
||||||
"""Stream: last distance offered, one row per school × year × admission route."""
|
|
||||||
|
|
||||||
name = "school_distance_offered"
|
|
||||||
# school_name is part of the key, not decoration: where a school admits
|
|
||||||
# through several routes the route is encoded in the name ("… Band 1",
|
|
||||||
# "… Band 2"), and (urn, year) alone is not unique. Verified against the
|
|
||||||
# source: (urn, year) collides on 118 keys, (urn, year, school_name) on
|
|
||||||
# none. Without school_name in the key a reload would silently drop the
|
|
||||||
# other bands.
|
|
||||||
primary_keys = ["urn", "year", "school_name"]
|
|
||||||
replication_key = None
|
|
||||||
|
|
||||||
schema = th.PropertiesList(
|
|
||||||
th.Property("urn", th.IntegerType, required=True),
|
|
||||||
th.Property("year", th.IntegerType, required=True),
|
|
||||||
th.Property("school_name", th.StringType, required=True),
|
|
||||||
th.Property("la_code", th.IntegerType),
|
|
||||||
th.Property("la_name", th.StringType),
|
|
||||||
# The figure as the council published it, before unit conversion —
|
|
||||||
# kept so a surprising metre value can be traced back to "0.31 miles"
|
|
||||||
# without reopening the source PDF.
|
|
||||||
th.Property("distance_value_raw", th.NumberType),
|
|
||||||
th.Property("distance_unit_raw", th.StringType),
|
|
||||||
th.Property("distance_m", th.NumberType),
|
|
||||||
# Path of the council publication the row was read from. This is the
|
|
||||||
# provenance trail for a figure we cannot re-derive from an API.
|
|
||||||
th.Property("source_file", th.StringType),
|
|
||||||
).to_dict()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _num(value: str) -> float | None:
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _int(value: str) -> int | None:
|
|
||||||
try:
|
|
||||||
return int(float(value))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_records(self, context):
|
|
||||||
url = self.config["download_url"]
|
|
||||||
self.logger.info("Downloading school distance data from %s", url)
|
|
||||||
|
|
||||||
resp = requests.get(url, timeout=REQUEST_TIMEOUT)
|
|
||||||
resp.raise_for_status()
|
|
||||||
|
|
||||||
reader = csv.DictReader(io.StringIO(resp.text))
|
|
||||||
|
|
||||||
yielded = 0
|
|
||||||
skipped = 0
|
|
||||||
for row in reader:
|
|
||||||
urn = self._int(row.get("urn"))
|
|
||||||
year = self._int(row.get("year"))
|
|
||||||
name = (row.get("school_name") or "").strip()
|
|
||||||
|
|
||||||
# Every part of the primary key must be present, or the load is
|
|
||||||
# not idempotent. One row in the source has a blank year.
|
|
||||||
if urn is None or year is None or not name:
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
yield {
|
|
||||||
"urn": urn,
|
|
||||||
"year": year,
|
|
||||||
"school_name": name,
|
|
||||||
"la_code": self._int(row.get("la_code")),
|
|
||||||
"la_name": (row.get("la_name") or "").strip() or None,
|
|
||||||
"distance_value_raw": self._num(row.get("distance_value_raw")),
|
|
||||||
"distance_unit_raw": (row.get("distance_unit_raw") or "").strip() or None,
|
|
||||||
"distance_m": self._num(row.get("distance_m")),
|
|
||||||
"source_file": (row.get("source_file") or "").strip() or None,
|
|
||||||
}
|
|
||||||
yielded += 1
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
"Yielded %d distance records (%d skipped: missing urn, year or school name)",
|
|
||||||
yielded,
|
|
||||||
skipped,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TapUKSchoolDistance(Tap):
|
|
||||||
"""Singer tap for LA-published admission cut-off distances."""
|
|
||||||
|
|
||||||
name = "tap-uk-school-distance"
|
|
||||||
|
|
||||||
config_jsonschema = th.PropertiesList(
|
|
||||||
th.Property(
|
|
||||||
"download_url",
|
|
||||||
th.StringType,
|
|
||||||
required=True,
|
|
||||||
description="URL of the collected last-distance-offered CSV",
|
|
||||||
),
|
|
||||||
).to_dict()
|
|
||||||
|
|
||||||
def discover_streams(self):
|
|
||||||
return [SchoolDistanceStream(self)]
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
TapUKSchoolDistance.cli()
|
|
||||||
@@ -156,22 +156,6 @@ models:
|
|||||||
- name: cross_la_offers
|
- name: cross_la_offers
|
||||||
- name: total_offers
|
- name: total_offers
|
||||||
|
|
||||||
- name: fact_admission_distance
|
|
||||||
description: >
|
|
||||||
Last distance offered — one row per URN per year. Where a school admits
|
|
||||||
through several routes (ability bands, separate reception/junior
|
|
||||||
intakes) the figure is the furthest of them and route_count says how
|
|
||||||
many were collapsed.
|
|
||||||
columns:
|
|
||||||
- name: urn
|
|
||||||
tests: [not_null]
|
|
||||||
- name: year
|
|
||||||
tests: [not_null]
|
|
||||||
- name: distance_m
|
|
||||||
tests: [not_null]
|
|
||||||
- name: route_count
|
|
||||||
tests: [not_null]
|
|
||||||
|
|
||||||
- name: fact_finance
|
- name: fact_finance
|
||||||
description: School financial data — one row per URN per year
|
description: School financial data — one row per URN per year
|
||||||
columns:
|
columns:
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
-- Mart: last distance offered — one row per URN per year.
|
|
||||||
--
|
|
||||||
-- Collapses the per-route grain of stg_school_distance to the grain the site
|
|
||||||
-- serves. Where a school admits through several routes the figure shown is the
|
|
||||||
-- FURTHEST of them, which is what "last distance offered" literally means: the
|
|
||||||
-- distance of the last place the school gave out that year.
|
|
||||||
--
|
|
||||||
-- route_count travels with it so the page can say so. A parent looking at a
|
|
||||||
-- banded secondary needs to know that 2.4 miles is the widest of four bands
|
|
||||||
-- and not the number that applies to their own child — the count is what makes
|
|
||||||
-- that sentence possible, and dropping it would make the figure quietly
|
|
||||||
-- optimistic.
|
|
||||||
--
|
|
||||||
-- Kept separate from fact_admissions rather than joined into it. That mart is
|
|
||||||
-- EES-derived and near-complete for England; this one comes from 57 local
|
|
||||||
-- authorities that publish cut-offs at all, so folding it in would add mostly
|
|
||||||
-- null columns to every school in the country and tie two very different
|
|
||||||
-- refresh cadences together.
|
|
||||||
|
|
||||||
with routes as (
|
|
||||||
select * from {{ ref('stg_school_distance') }}
|
|
||||||
),
|
|
||||||
|
|
||||||
per_year as (
|
|
||||||
select
|
|
||||||
urn,
|
|
||||||
year,
|
|
||||||
max(distance_m) as distance_m,
|
|
||||||
count(*) as route_count,
|
|
||||||
min(la_code) as la_code,
|
|
||||||
min(la_name) as la_name,
|
|
||||||
-- The unit the council actually published in. Where routes disagree
|
|
||||||
-- (they do not in the current data) the alphabetically first is taken
|
|
||||||
-- rather than an arbitrary row, so the mart is deterministic.
|
|
||||||
min(distance_unit_raw) as distance_unit_raw,
|
|
||||||
min(source_file) as source_file
|
|
||||||
from routes
|
|
||||||
group by urn, year
|
|
||||||
)
|
|
||||||
|
|
||||||
select
|
|
||||||
urn,
|
|
||||||
year,
|
|
||||||
distance_m,
|
|
||||||
route_count,
|
|
||||||
la_code,
|
|
||||||
la_name,
|
|
||||||
distance_unit_raw,
|
|
||||||
source_file
|
|
||||||
from per_year
|
|
||||||
@@ -61,11 +61,3 @@ sources:
|
|||||||
|
|
||||||
- name: idaci
|
- name: idaci
|
||||||
description: Income Deprivation Affecting Children Index lookups
|
description: Income Deprivation Affecting Children Index lookups
|
||||||
|
|
||||||
- name: school_distance_offered
|
|
||||||
description: >
|
|
||||||
Last distance offered (admission cut-offs) transcribed from local
|
|
||||||
authority admissions publications. One row per school × year ×
|
|
||||||
admission route; no national source exists, so coverage is limited to
|
|
||||||
the LAs that publish. Loaded verbatim — implausible values are
|
|
||||||
filtered in stg_school_distance, not here.
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
-- Staging model: "last distance offered" admission cut-offs.
|
|
||||||
--
|
|
||||||
-- One row per school × year × admission route. A school admits through
|
|
||||||
-- several routes when it bands by ability (Islington publishes four bands per
|
|
||||||
-- secondary) or takes more than one intake (Gloucestershire publishes
|
|
||||||
-- reception, junior and secondary allocations separately). The route is
|
|
||||||
-- carried in school_name, which is why it forms part of the grain here and is
|
|
||||||
-- collapsed only in fact_admission_distance.
|
|
||||||
--
|
|
||||||
-- PLAUSIBILITY BAND (25 m – 25 km)
|
|
||||||
--
|
|
||||||
-- The source is transcribed from council PDFs, spreadsheets and web pages, and
|
|
||||||
-- a minority of rows are transcription or unit errors rather than real
|
|
||||||
-- cut-offs. Three failure modes, all present:
|
|
||||||
--
|
|
||||||
-- * 0.0 miles (34 rows, mostly Staffordshire). Published where a school
|
|
||||||
-- filled on a higher criterion so distance was never reached — a real
|
|
||||||
-- fact, but "0 metres" read as a cut-off would tell a parent the opposite
|
|
||||||
-- of the truth.
|
|
||||||
-- * 1–10 metre values (72 rows, e.g. Hillingdon). A cut-off inside the
|
|
||||||
-- school gate is not physically possible; these are unit slips.
|
|
||||||
-- * Values above 25 km (~240 rows), topping out at 533 miles. Genuine rural
|
|
||||||
-- secondary cut-offs reach roughly 12–15 miles, so the ceiling keeps those
|
|
||||||
-- while cutting figures that are visibly wrong.
|
|
||||||
--
|
|
||||||
-- Together these are ~4% of rows. They are loaded verbatim into raw and
|
|
||||||
-- excluded here, so the rule is reviewable in one place and the raw table
|
|
||||||
-- stays a faithful record of what each council published.
|
|
||||||
|
|
||||||
{% set min_plausible_m = 25 %}
|
|
||||||
{% set max_plausible_m = 25000 %}
|
|
||||||
|
|
||||||
with source as (
|
|
||||||
select * from {{ source('raw', 'school_distance_offered') }}
|
|
||||||
),
|
|
||||||
|
|
||||||
renamed as (
|
|
||||||
select
|
|
||||||
cast(urn as integer) as urn,
|
|
||||||
cast(year as integer) as year,
|
|
||||||
trim(school_name) as admission_route,
|
|
||||||
cast(la_code as integer) as la_code,
|
|
||||||
la_name,
|
|
||||||
cast(distance_value_raw as numeric) as distance_value_raw,
|
|
||||||
distance_unit_raw,
|
|
||||||
cast(distance_m as numeric) as distance_m,
|
|
||||||
source_file
|
|
||||||
from source
|
|
||||||
where urn is not null
|
|
||||||
and year is not null
|
|
||||||
and distance_m is not null
|
|
||||||
and cast(distance_m as numeric) between {{ min_plausible_m }} and {{ max_plausible_m }}
|
|
||||||
)
|
|
||||||
|
|
||||||
select * from renamed
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
-- Custom test: every published cut-off distance is inside the plausibility
|
|
||||||
-- band, and there is exactly one row per school per year.
|
|
||||||
--
|
|
||||||
-- The band is enforced in stg_school_distance; this asserts it survived the
|
|
||||||
-- max() collapse in the mart. A failure means either the staging filter was
|
|
||||||
-- edited without updating this test, or the grain broke — both of which put a
|
|
||||||
-- visibly wrong number ("533 miles") on a live school page, which is the one
|
|
||||||
-- outcome this feature cannot afford.
|
|
||||||
|
|
||||||
with out_of_band as (
|
|
||||||
select urn, year, distance_m, 'out of plausibility band' as problem
|
|
||||||
from {{ ref('fact_admission_distance') }}
|
|
||||||
where distance_m is null
|
|
||||||
or distance_m < 25
|
|
||||||
or distance_m > 25000
|
|
||||||
),
|
|
||||||
|
|
||||||
duplicated as (
|
|
||||||
select urn, year, max(distance_m) as distance_m, 'duplicate urn/year' as problem
|
|
||||||
from {{ ref('fact_admission_distance') }}
|
|
||||||
group by urn, year
|
|
||||||
having count(*) > 1
|
|
||||||
)
|
|
||||||
|
|
||||||
select * from out_of_band
|
|
||||||
union all
|
|
||||||
select * from duplicated
|
|
||||||