Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6c2cd5116 | ||
|
|
dc79d653e5 | ||
|
|
b0d5334e06 | ||
|
|
d65eb58883 | ||
|
|
7f5f0fb676 | ||
|
|
47f3591ed8 | ||
|
|
6fc7fce948 | ||
|
|
eb6d918650 | ||
|
|
d47ac71c47 | ||
|
|
17e5371e9c | ||
|
|
e2c63a9905 | ||
|
|
3f3c5953f6 | ||
|
|
124c6702a9 | ||
|
|
e25722d9ab | ||
|
|
07d586d0ad | ||
|
|
b793640507 | ||
|
|
21a5d18f59 | ||
|
|
f614414070 | ||
|
|
310b63b0cb | ||
|
|
c2c76c5817 | ||
|
|
c5a4d106da | ||
|
|
2437ffce42 | ||
|
|
eb648f3f76 | ||
|
|
74e5fffc10 | ||
|
|
748ef32180 | ||
|
|
b0c4ea8282 | ||
|
|
e236669fde | ||
|
|
fb5a0928bd | ||
|
|
264edd2e3a | ||
|
|
cd2cbe7be6 | ||
|
|
73182d0c0c | ||
|
|
cbe3a9a772 | ||
|
|
2e9b5c83c5 | ||
|
|
102397fe69 | ||
|
|
68a192e430 | ||
|
|
7c08138fe4 | ||
|
|
a7829d591a | ||
|
|
1ed4470fc2 | ||
|
|
7a16b1b52f | ||
|
|
cf9d41b476 | ||
|
|
e820e7fecd |
No files matched your search
+69
-1
@@ -38,7 +38,7 @@ from .data_loader import (
|
||||
)
|
||||
from .data_loader import get_data_info as get_db_info
|
||||
from . import flags
|
||||
from .places import build_place_registry
|
||||
from .places import build_place_index, build_place_registry, places_for_urn
|
||||
from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS
|
||||
from .utils import clean_for_json, convert_to_native
|
||||
|
||||
@@ -65,6 +65,10 @@ _sitemaps: dict[str, str] | None = None
|
||||
# Built from the same DataFrame the sitemap uses, so places and sitemap can
|
||||
# never describe different corpora. Reset by the same admin endpoint.
|
||||
_place_registry: dict | None = None
|
||||
# Cached beside the registry, and invalidated by identity against it — see
|
||||
# get_place_index. Never cleared independently.
|
||||
_place_index: dict | None = None
|
||||
_place_index_source: dict | None = None
|
||||
|
||||
VALID_PLACE_KINDS = ("town", "locality", "authority", "outcode")
|
||||
|
||||
@@ -188,6 +192,24 @@ def get_place_registry() -> dict:
|
||||
return _place_registry
|
||||
|
||||
|
||||
def get_place_index() -> dict:
|
||||
"""URN → its published places, cached against the registry it came from.
|
||||
|
||||
Invalidation is an identity check rather than a second flag to remember to
|
||||
clear. Anything that drops `_place_registry` — the tests all do — gets a
|
||||
fresh registry object here, which no longer matches the one the index was
|
||||
built from, so the index rebuilds with it. A separate `_place_index = None`
|
||||
would be one more thing to forget, and a stale reverse index is exactly the
|
||||
bug that would put links to another dataset's places on a school page.
|
||||
"""
|
||||
global _place_index, _place_index_source
|
||||
registry = get_place_registry()
|
||||
if _place_index is None or _place_index_source is not registry:
|
||||
_place_index = build_place_index(registry)
|
||||
_place_index_source = registry
|
||||
return _place_index
|
||||
|
||||
|
||||
def _urlset(rows: list[str]) -> str:
|
||||
return "\n".join([
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
@@ -211,6 +233,45 @@ def _place_url(place) -> str:
|
||||
return f"/schools/{place.slug}"
|
||||
|
||||
|
||||
def _places_payload(urn: int) -> list[dict]:
|
||||
"""The published places containing this school, as the school page needs
|
||||
them: a name to write in the link, a count so the anchor can say what it
|
||||
leads to, and the canonical path.
|
||||
|
||||
`phases` carries the phase variants this school actually appears on, which
|
||||
is usually one and is two for an all-through school — it is listed on both
|
||||
pages, so there is no tie to break.
|
||||
|
||||
Membership is read straight from the registry's own `phase_urns` rather
|
||||
than re-derived from the school's phase string. The registry is the one
|
||||
place that decides which phases a place publishes and who is on them;
|
||||
computing it a second time here is how a page comes to link a school to a
|
||||
phase page that does not list it, or to a route that does not exist. That
|
||||
is also why outcodes need no special case: they carry empty `phase_urns`,
|
||||
so they report no phase links on their own.
|
||||
"""
|
||||
payload = []
|
||||
for place in places_for_urn(get_place_index(), int(urn)):
|
||||
phases = [
|
||||
{
|
||||
"phase": phase,
|
||||
"count": len(phase_urns),
|
||||
"url": f"{_place_url(place)}/{phase}",
|
||||
}
|
||||
for phase, phase_urns in sorted(place.phase_urns.items())
|
||||
if int(urn) in phase_urns
|
||||
]
|
||||
payload.append({
|
||||
"kind": place.kind,
|
||||
"slug": place.slug,
|
||||
"name": place.name,
|
||||
"count": len(place.urns),
|
||||
"url": _place_url(place),
|
||||
"phases": phases,
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
def _place_sitemap_rows(kinds: tuple[str, ...]) -> list[str]:
|
||||
"""A <url> per place, plus a phase variant wherever that phase clears the
|
||||
threshold on its own.
|
||||
@@ -902,6 +963,13 @@ async def get_school_details(request: Request, urn: int):
|
||||
|
||||
return {
|
||||
"school_info": school_info,
|
||||
# Where this school sits in the location layer, for the page's link
|
||||
# module and breadcrumb. Derived from the same registry the place
|
||||
# pages and the sitemap use, so a link is never offered for a page
|
||||
# that does not exist. Empty is a valid answer: a school whose town
|
||||
# and authority both fall below the publish threshold has nowhere to
|
||||
# point, and the page renders without the module.
|
||||
"places": _places_payload(urn),
|
||||
"yearly_data": clean_for_json(school_data),
|
||||
# Supplementary data (null if not yet populated by Kestra)
|
||||
"ofsted": supplementary.get("ofsted"),
|
||||
|
||||
+158
-13
@@ -839,17 +839,151 @@ def _format_cohort_year(year) -> str | None:
|
||||
return text
|
||||
|
||||
|
||||
_PUPIL_GROUPS = ("disadvantaged", "other", "all")
|
||||
|
||||
|
||||
def _lone_hidden_groups(groups: dict) -> list:
|
||||
"""Pupil groups hiding exactly one category — solvable by subtraction."""
|
||||
return [
|
||||
key for key, group in groups.items()
|
||||
if sum(1 for c in group["categories"] if c["status"] == "suppressed") == 1
|
||||
]
|
||||
|
||||
|
||||
def _lone_hidden_categories(groups: dict) -> list:
|
||||
"""Categories hidden in exactly one of several pupil groups."""
|
||||
lone = []
|
||||
categories = {c["category"] for g in groups.values() for c in g["categories"]}
|
||||
for category in categories:
|
||||
found = [
|
||||
c for g in groups.values() for c in g["categories"]
|
||||
if c["category"] == category
|
||||
]
|
||||
hidden = [c for c in found if c["status"] == "suppressed"]
|
||||
if len(hidden) == 1 and len(found) > 1:
|
||||
lone.append(category)
|
||||
return lone
|
||||
|
||||
|
||||
def disclosure_invariant_holds(groups: dict) -> bool:
|
||||
"""Every row and every column hides none, or at least two.
|
||||
|
||||
Public so the tests can assert it directly rather than re-deriving it.
|
||||
"""
|
||||
return not _lone_hidden_groups(groups) and not _lone_hidden_categories(groups)
|
||||
|
||||
|
||||
def _mask_for_disclosure(groups: dict) -> None:
|
||||
"""Withhold further cells until nothing suppressed can be solved for.
|
||||
|
||||
Not rendering a figure is not the same as not publishing it. This endpoint
|
||||
is public and unauthenticated, so anything left in the payload is
|
||||
published, whatever the UI chooses to draw — the same reasoning the
|
||||
admission_distance field carries in app.py.
|
||||
|
||||
Two identities let a caller solve for a withheld cell:
|
||||
|
||||
* within a pupil group, the categories sum to the cohort, so a group with
|
||||
exactly ONE suppressed category gives it away as cohort - sum(rest);
|
||||
* across groups, disadvantaged + other = all for every category, so a
|
||||
category suppressed in exactly ONE of the three gives itself away.
|
||||
|
||||
DfE's own answer is secondary suppression: withhold a second cell so the
|
||||
residual spans two unknowns and identifies neither.
|
||||
|
||||
Where no companion can do that — a sparse cohort whose every other category
|
||||
is `not_applicable`, which is common in special schools and alternative
|
||||
provision — there is nothing left to withhold, so the pupil group is
|
||||
DROPPED entirely. An earlier version simply gave up here and returned with
|
||||
the violation intact and no signal, which is the one outcome this function
|
||||
must never produce: a disclosure-control pass that fails silently is worse
|
||||
than none, because everything downstream trusts it.
|
||||
|
||||
Mutates `groups` in place. Guaranteed to return with
|
||||
disclosure_invariant_holds(groups) true.
|
||||
"""
|
||||
|
||||
def suppress(cell):
|
||||
if cell["status"] == "published":
|
||||
cell["status"] = "suppressed"
|
||||
cell["pupils"] = None
|
||||
cell["percentage"] = None
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_companion(candidates) -> bool:
|
||||
"""Withhold a second cell so the residual spans two unknowns.
|
||||
|
||||
The companion must carry pupils. Suppressing a zero looks like
|
||||
secondary suppression and protects nothing: the residual still equals
|
||||
the original withheld figure exactly. Returns False when no cell can
|
||||
do the job, which escalates to dropping the group.
|
||||
"""
|
||||
published = [c for c in candidates if c["status"] == "published"]
|
||||
useful = sorted(
|
||||
(c for c in published if (c["pupils"] or 0) > 0),
|
||||
key=lambda c: c["pupils"],
|
||||
)
|
||||
if useful:
|
||||
return suppress(useful[0])
|
||||
# Every remaining cell is zero or not applicable: withholding any of
|
||||
# them leaves the residual equal to the original figure.
|
||||
return False
|
||||
|
||||
# Fixpoint: each new suppression can break the other identity. Terminates
|
||||
# because every pass either adds a suppression, drops a group, or stops.
|
||||
while not disclosure_invariant_holds(groups):
|
||||
changed = False
|
||||
|
||||
for category in _lone_hidden_categories(groups):
|
||||
siblings = [
|
||||
c for g in groups.values() for c in g["categories"]
|
||||
if c["category"] == category
|
||||
]
|
||||
if add_companion(siblings):
|
||||
changed = True
|
||||
|
||||
for key in _lone_hidden_groups(groups):
|
||||
if add_companion(groups[key]["categories"]):
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
continue
|
||||
|
||||
# Nothing left to withhold. Drop the groups that are still solvable,
|
||||
# and any category still solvable across the groups that remain.
|
||||
for key in _lone_hidden_groups(groups):
|
||||
del groups[key]
|
||||
changed = True
|
||||
|
||||
for category in _lone_hidden_categories(groups):
|
||||
for group in groups.values():
|
||||
for cell in group["categories"]:
|
||||
if cell["category"] == category and suppress(cell):
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
# Unreachable given the two escalations above, but a masking pass
|
||||
# must never spin or exit unsafely. Withhold everything.
|
||||
groups.clear()
|
||||
return
|
||||
|
||||
|
||||
def _destinations_block(rows: list) -> dict | None:
|
||||
"""Shape destination rows for one phase into the API's block.
|
||||
|
||||
Carries `status` through untouched and emits no computed totals. The only
|
||||
aggregates present are ones DfE published itself; whether showing one is
|
||||
safe depends on how many of its components are suppressed, which the
|
||||
frontend decides (lib/destinations.ts, rule R2).
|
||||
Applies secondary suppression before returning, so no caller of this public
|
||||
endpoint can solve for a figure DfE withheld. See _mask_for_disclosure.
|
||||
|
||||
Deliberately does NOT compute a residual, a "remaining pupils" figure, or
|
||||
any total that would close a gap left by a suppressed category — the
|
||||
categories sum to the cohort, so such a figure names the withheld cell.
|
||||
Aggregate measures are dropped entirely. DfE publishes them, and they would
|
||||
be useful for a "what is published for this group" fallback, but nothing
|
||||
renders them today and an aggregate spanning exactly one suppressed
|
||||
component names that component. An unused field that leaks is not a
|
||||
trade-off worth carrying — re-add them with their own guard if the fallback
|
||||
is ever built.
|
||||
|
||||
Deliberately computes no residual, no "remaining pupils" figure, and no
|
||||
total that would close a gap left by a suppressed category.
|
||||
"""
|
||||
if not rows:
|
||||
return None
|
||||
@@ -864,23 +998,34 @@ def _destinations_block(rows: list) -> dict | None:
|
||||
for row in rows:
|
||||
group = groups.setdefault(
|
||||
row["pupil_group"],
|
||||
{"cohort": row.get("cohort_pupils"), "categories": [], "aggregates": {}},
|
||||
{"cohort": row.get("cohort_pupils"), "categories": []},
|
||||
)
|
||||
measure = row["destination_measure"]
|
||||
published = row.get("status") == "published"
|
||||
# Belt and braces: percentage is derived from the same source cell as
|
||||
# pupils, but publishing one without the other would hand back the
|
||||
# cohort (pupils / percentage) and with it the residual.
|
||||
cell = {
|
||||
"category": measure,
|
||||
"pupils": row.get("pupils"),
|
||||
"percentage": row.get("percentage"),
|
||||
"pupils": row.get("pupils") if published else None,
|
||||
"percentage": row.get("percentage") if published else None,
|
||||
"status": row.get("status"),
|
||||
}
|
||||
if measure in _AGGREGATE_MEASURES:
|
||||
group["aggregates"][measure[len("agg_"):]] = cell
|
||||
else:
|
||||
group["categories"].append(cell)
|
||||
continue
|
||||
group["categories"].append(cell)
|
||||
|
||||
if not groups:
|
||||
return None
|
||||
|
||||
_mask_for_disclosure(groups)
|
||||
|
||||
# Masking can empty the block entirely — a sparse cohort where no group
|
||||
# could be made safe. Return None so the section is absent rather than
|
||||
# rendering an empty shell.
|
||||
if not groups:
|
||||
return None
|
||||
|
||||
return {"cohort_year": _format_cohort_year(latest_year), "groups": groups}
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,23 @@ REGISTRY: dict[str, Flag] = {
|
||||
),
|
||||
added=date(2026, 8, 26),
|
||||
),
|
||||
Flag(
|
||||
name="about_page",
|
||||
description=(
|
||||
"The /about page, its footer link, its sitemap entry, and the "
|
||||
"named-author byline on every blog post."
|
||||
),
|
||||
added=date(2026, 9, 8),
|
||||
),
|
||||
Flag(
|
||||
name="blog",
|
||||
description=(
|
||||
"The /blog index, post pages, the RSS feed, their footer link "
|
||||
"and their sitemap entries. Not /admin: posts must be "
|
||||
"writable before the blog is readable."
|
||||
),
|
||||
added=date(2026, 9, 8),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +296,48 @@ def _locality_places(df, publishable: set[int],
|
||||
return out
|
||||
|
||||
|
||||
# Ordered authority → town/locality → outcode, widest first, because that is
|
||||
# the order a breadcrumb reads. The link module re-sorts for its own purposes.
|
||||
_PLACE_ORDER = {"authority": 0, "town": 1, "locality": 2, "outcode": 3}
|
||||
|
||||
|
||||
def build_place_index(registry: dict[str, Place]) -> dict[int, tuple[Place, ...]]:
|
||||
"""URN → the published places containing it, built once per registry.
|
||||
|
||||
The reverse of the registry, and the thing school pages link out through.
|
||||
Derived from the registry rather than maintained beside it, so the two
|
||||
cannot disagree about which places exist: a place below the publish
|
||||
threshold is absent from the registry, so it is absent from here too, and
|
||||
a link is never offered for a page that does not exist.
|
||||
|
||||
Built as an index rather than scanned per call because /api/schools/{urn}
|
||||
is the site's highest-traffic endpoint. Scanning meant walking every place
|
||||
and doing a tuple membership test against each — on the order of 10^5
|
||||
comparisons per request, repeated for every school page view. One pass at
|
||||
registry-build time replaces all of it with a dict lookup.
|
||||
"""
|
||||
grouped: dict[int, list[Place]] = {}
|
||||
for place in registry.values():
|
||||
for urn in place.urns:
|
||||
grouped.setdefault(int(urn), []).append(place)
|
||||
|
||||
return {
|
||||
urn: tuple(sorted(places,
|
||||
key=lambda p: (_PLACE_ORDER.get(p.kind, 9), p.slug)))
|
||||
for urn, places in grouped.items()
|
||||
}
|
||||
|
||||
|
||||
def places_for_urn(index: dict[int, tuple[Place, ...]], urn: int) -> tuple[Place, ...]:
|
||||
"""The published places containing this school, widest first.
|
||||
|
||||
Empty is a real answer, not a failure: a school whose town and authority
|
||||
both fall below the publish threshold has nowhere to link, and the page
|
||||
renders without the module.
|
||||
"""
|
||||
return index.get(int(urn), ())
|
||||
|
||||
|
||||
def build_place_registry(df) -> dict[str, Place]:
|
||||
"""Every place the site publishes, keyed by "<kind>:<slug>"."""
|
||||
if df.empty or "urn" not in df.columns:
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"""The destinations serialiser's contract: it carries suppression through, and
|
||||
never emits a total that closes a gap left by a suppressed category.
|
||||
"""The destinations serialiser's contract.
|
||||
|
||||
The destination categories sum to the cohort, so an aggregate that happens to
|
||||
equal the residual names the withheld figure exactly. See
|
||||
docs/superpowers/specs/2026-08-28-destination-measures-design.md.
|
||||
Not rendering a figure is not the same as not publishing it. This endpoint is
|
||||
public and unauthenticated, so whatever the payload carries is published,
|
||||
whatever the UI draws. The categories sum to the cohort and the pupil groups
|
||||
sum to each other, so a lone suppressed cell is solvable by subtraction — the
|
||||
serialiser adds secondary suppression to prevent it.
|
||||
|
||||
See docs/superpowers/specs/2026-08-28-destination-measures-design.md.
|
||||
"""
|
||||
|
||||
from backend.data_loader import _destinations_block, _format_cohort_year
|
||||
from backend.data_loader import (
|
||||
_destinations_block, _format_cohort_year, disclosure_invariant_holds,
|
||||
)
|
||||
|
||||
|
||||
def _row(group, measure, pupils, status, cohort=180, percentage=None, year=202223):
|
||||
@@ -43,39 +48,6 @@ def test_published_category_keeps_its_figures():
|
||||
assert cat["status"] == "published"
|
||||
|
||||
|
||||
def test_no_closing_total_is_emitted_for_a_partially_suppressed_group():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
||||
_row("all", "sixth_form_college", None, "suppressed"),
|
||||
_row("all", "further_education", 61, "published", percentage=33.9),
|
||||
_row("all", "apprenticeship", 8, "published", percentage=4.4),
|
||||
_row("all", "employment", 6, "published", percentage=3.3),
|
||||
_row("all", "not_sustained", 5, "published", percentage=2.8),
|
||||
_row("all", "not_captured", 4, "published", percentage=2.2),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
group = block["groups"]["all"]
|
||||
published = sum(c["pupils"] for c in group["categories"] if c["pupils"] is not None)
|
||||
residual = group["cohort"] - published
|
||||
for value in group["aggregates"].values():
|
||||
if value is None or value.get("pupils") is None:
|
||||
continue
|
||||
assert value["pupils"] != residual, (
|
||||
"an aggregate equal to the residual identifies the suppressed cell"
|
||||
)
|
||||
|
||||
|
||||
def test_aggregates_are_separated_from_categories():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
||||
_row("all", "agg_sustained_all", 171, "published", percentage=95.0),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
group = block["groups"]["all"]
|
||||
assert [c["category"] for c in group["categories"]] == ["school_sixth_form"]
|
||||
assert group["aggregates"]["sustained_all"]["pupils"] == 171
|
||||
|
||||
|
||||
def test_only_the_latest_year_is_served():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 60, "published", year=202122),
|
||||
@@ -110,3 +82,188 @@ def test_format_cohort_year_handles_the_six_digit_form():
|
||||
|
||||
def test_empty_rows_yield_none_not_an_empty_shell():
|
||||
assert _destinations_block([]) is None
|
||||
|
||||
|
||||
# ── Disclosure control ──────────────────────────────────────────────────────
|
||||
#
|
||||
# The rendering guards in lib/destinations.ts stop a withheld figure being
|
||||
# DRAWN. They do nothing about it being COMPUTED: this endpoint is public and
|
||||
# unauthenticated, so whatever the payload carries is published. These tests
|
||||
# are the ones that matter.
|
||||
|
||||
def _solve_residual(group):
|
||||
"""What any caller can work out: cohort minus everything published."""
|
||||
published = [c["pupils"] for c in group["categories"] if c["pupils"] is not None]
|
||||
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
|
||||
return group["cohort"] - sum(published), len(hidden)
|
||||
|
||||
|
||||
def test_a_lone_suppressed_category_cannot_be_solved_for():
|
||||
"""Whitley Bay High School's real 2022/23 disadvantaged group: further
|
||||
education withheld, everything else published, cohort 41. Before secondary
|
||||
suppression the payload gave the answer away as 41 - 23 = 18."""
|
||||
rows = [
|
||||
_row("disadvantaged", "school_sixth_form", 15, "published", cohort=41),
|
||||
_row("disadvantaged", "sixth_form_college", 0, "published", cohort=41),
|
||||
_row("disadvantaged", "further_education", None, "suppressed", cohort=41),
|
||||
_row("disadvantaged", "apprenticeship", 1, "published", cohort=41),
|
||||
_row("disadvantaged", "employment", 2, "published", cohort=41),
|
||||
_row("disadvantaged", "not_sustained", 3, "published", cohort=41),
|
||||
_row("disadvantaged", "not_captured", 2, "published", cohort=41),
|
||||
]
|
||||
group = _destinations_block(rows)["groups"]["disadvantaged"]
|
||||
residual, hidden = _solve_residual(group)
|
||||
assert hidden >= 2, "a lone suppressed cell must gain a companion"
|
||||
assert residual != 18, "the withheld figure is recoverable from the payload"
|
||||
|
||||
|
||||
def test_every_group_hides_none_or_at_least_two_categories():
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published"),
|
||||
_row("all", "sixth_form_college", None, "suppressed"),
|
||||
_row("all", "further_education", 61, "published"),
|
||||
_row("all", "apprenticeship", 8, "published"),
|
||||
_row("all", "employment", 6, "published"),
|
||||
_row("all", "not_sustained", 5, "published"),
|
||||
_row("all", "not_captured", 4, "published"),
|
||||
]
|
||||
group = _destinations_block(rows)["groups"]["all"]
|
||||
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
|
||||
assert len(hidden) >= 2
|
||||
|
||||
|
||||
def test_a_category_hidden_in_one_group_is_hidden_in_a_second():
|
||||
"""disadvantaged + other = all for every category, so a category withheld
|
||||
in exactly one of the three is recoverable from the other two."""
|
||||
rows = []
|
||||
for measure, a, d, o in [
|
||||
("school_sixth_form", 75, None, 58),
|
||||
("further_education", 61, 27, 34),
|
||||
("apprenticeship", 8, 4, 4),
|
||||
("employment", 6, 1, 5),
|
||||
("not_sustained", 5, 3, 2),
|
||||
("not_captured", 4, 2, 2),
|
||||
]:
|
||||
rows.append(_row("all", measure, a, "published", cohort=159))
|
||||
rows.append(_row("disadvantaged", measure, d,
|
||||
"published" if d is not None else "suppressed", cohort=37))
|
||||
rows.append(_row("other", measure, o, "published", cohort=122))
|
||||
|
||||
groups = _destinations_block(rows)["groups"]
|
||||
measures = {c["category"] for g in groups.values() for c in g["categories"]}
|
||||
assert len(measures) == 6, "the fixture's six measures must all be checked"
|
||||
|
||||
for measure in sorted(measures):
|
||||
hidden = sum(
|
||||
1 for g in groups.values() for c in g["categories"]
|
||||
if c["category"] == measure and c["status"] == "suppressed"
|
||||
)
|
||||
# The invariant is "none, or at least two" — not "at least two".
|
||||
assert hidden != 1, f"{measure} is solvable across the pupil groups"
|
||||
|
||||
|
||||
def test_a_suppressed_cell_never_keeps_its_percentage():
|
||||
"""percentage / pupils would hand back the cohort, and with it the residual."""
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
|
||||
_row("all", "sixth_form_college", None, "suppressed", percentage=11.7),
|
||||
_row("all", "further_education", 61, "published", percentage=33.9),
|
||||
]
|
||||
group = _destinations_block(rows)["groups"]["all"]
|
||||
for cell in group["categories"]:
|
||||
if cell["status"] != "published":
|
||||
assert cell["pupils"] is None
|
||||
assert cell["percentage"] is None
|
||||
|
||||
|
||||
def test_aggregates_are_not_served():
|
||||
"""An aggregate spanning exactly one suppressed component names it, and
|
||||
nothing renders them today."""
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", 75, "published"),
|
||||
_row("all", "agg_sustained_all", 171, "published"),
|
||||
]
|
||||
group = _destinations_block(rows)["groups"]["all"]
|
||||
assert [c["category"] for c in group["categories"]] == ["school_sixth_form"]
|
||||
assert "aggregates" not in group
|
||||
|
||||
|
||||
def test_a_fully_published_group_is_left_alone():
|
||||
"""Secondary suppression must not cost anything where nothing is withheld —
|
||||
this is the all-pupils view on every mainstream secondary."""
|
||||
rows = [
|
||||
_row("all", m, p, "published")
|
||||
for m, p in [("school_sixth_form", 75), ("sixth_form_college", 21),
|
||||
("further_education", 61), ("apprenticeship", 8),
|
||||
("employment", 6), ("not_sustained", 5), ("not_captured", 4)]
|
||||
]
|
||||
group = _destinations_block(rows)["groups"]["all"]
|
||||
assert all(c["status"] == "published" for c in group["categories"])
|
||||
assert len(group["categories"]) == 7
|
||||
|
||||
|
||||
def test_the_invariant_is_asserted_directly_not_re_derived():
|
||||
"""A group with one suppressed category and nothing else to withhold."""
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", None, "suppressed", cohort=9),
|
||||
_row("all", "sixth_form_college", None, "not_applicable", cohort=9),
|
||||
_row("all", "further_education", None, "not_applicable", cohort=9),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
assert block is None or disclosure_invariant_holds(block["groups"])
|
||||
|
||||
|
||||
def test_a_sparse_cohort_with_no_companion_drops_the_group():
|
||||
"""Special schools and AP routinely have one suppressed category and every
|
||||
other one not applicable. There is nothing left to withhold, so the group
|
||||
goes — an earlier version returned here with the violation intact."""
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", None, "suppressed", cohort=9),
|
||||
_row("all", "sixth_form_college", None, "not_applicable", cohort=9),
|
||||
_row("all", "further_education", None, "not_applicable", cohort=9),
|
||||
_row("all", "apprenticeship", None, "not_applicable", cohort=9),
|
||||
_row("all", "employment", None, "not_applicable", cohort=9),
|
||||
_row("all", "not_sustained", None, "not_applicable", cohort=9),
|
||||
_row("all", "not_captured", None, "not_applicable", cohort=9),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
assert block is None or "all" not in block["groups"], (
|
||||
"a group that cannot be made safe must not be served"
|
||||
)
|
||||
|
||||
|
||||
def test_zeros_are_not_treated_as_a_usable_companion():
|
||||
"""Suppressing a zero protects nothing — the residual is unchanged. With
|
||||
only zeros available the group must be dropped, not falsely 'fixed'."""
|
||||
rows = [
|
||||
_row("all", "school_sixth_form", None, "suppressed", cohort=5),
|
||||
_row("all", "sixth_form_college", 0, "published", cohort=5),
|
||||
_row("all", "further_education", 0, "published", cohort=5),
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
if block and "all" in block["groups"]:
|
||||
group = block["groups"]["all"]
|
||||
published = sum(c["pupils"] for c in group["categories"]
|
||||
if c["pupils"] is not None)
|
||||
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
|
||||
assert len(hidden) != 1, "a zero companion leaves the figure solvable"
|
||||
assert group["cohort"] - published != 5
|
||||
|
||||
|
||||
def test_masking_always_terminates_in_a_safe_state():
|
||||
"""Exhaustive over every suppression pattern of a four-category group."""
|
||||
from itertools import product
|
||||
MEASURES = ["school_sixth_form", "sixth_form_college",
|
||||
"further_education", "apprenticeship"]
|
||||
for statuses in product(["published", "suppressed", "not_applicable"],
|
||||
repeat=len(MEASURES)):
|
||||
rows = [
|
||||
_row("all", m, 3 if st == "published" else None, st, cohort=12)
|
||||
for m, st in zip(MEASURES, statuses)
|
||||
]
|
||||
block = _destinations_block(rows)
|
||||
if block is None:
|
||||
continue
|
||||
assert disclosure_invariant_holds(block["groups"]), (
|
||||
f"invariant broken for {statuses}"
|
||||
)
|
||||
@@ -8,7 +8,8 @@ import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from backend.places import MIN_SCHOOLS, build_place_registry
|
||||
from backend.places import (MIN_SCHOOLS, build_place_index,
|
||||
build_place_registry, places_for_urn)
|
||||
|
||||
|
||||
def _df(rows: list[dict]) -> pd.DataFrame:
|
||||
@@ -418,3 +419,79 @@ def test_an_authority_still_publishes_phase_variants():
|
||||
and /schools/authority/[la]/[phase] is the route that serves it."""
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Maidstone", "Kent")))
|
||||
assert reg["authority:kent"].publishes_phase("primary")
|
||||
|
||||
|
||||
# ── The reverse index: which published places contain a school ──────────────
|
||||
#
|
||||
# School pages link out to the location layer through this. It is the whole
|
||||
# point of the index: before it, ~27k school pages linked to nothing on the
|
||||
# site and stranded whatever authority they held.
|
||||
|
||||
def test_a_school_resolves_to_every_published_place_containing_it():
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Brentwood", "Essex")))
|
||||
places = places_for_urn(build_place_index(reg), 100000)
|
||||
|
||||
kinds = {p.kind for p in places}
|
||||
assert "town" in kinds
|
||||
assert "authority" in kinds
|
||||
|
||||
|
||||
def test_a_school_in_an_unpublished_town_still_resolves_to_its_authority():
|
||||
# A town below the threshold has no page, so there is no link to offer —
|
||||
# but the authority above it clears the threshold on the same schools and
|
||||
# is where that reader should be sent.
|
||||
reg = build_place_registry(_df(
|
||||
_town(MIN_SCHOOLS - 1, "Tinytown", "Essex")
|
||||
+ _town(MIN_SCHOOLS, "Brentwood", "Essex", start=200000)
|
||||
))
|
||||
places = places_for_urn(build_place_index(reg), 100000)
|
||||
|
||||
# The town is below the threshold, so it has no page and must not be
|
||||
# offered as a link. The authority above it does, and is the right target.
|
||||
assert all(p.slug != "tinytown" for p in places)
|
||||
assert "authority" in {p.kind for p in places}
|
||||
|
||||
|
||||
def test_an_unknown_urn_resolves_to_nothing_rather_than_raising():
|
||||
# A school page renders for any URN the API knows; the link module is not
|
||||
# entitled to take the page down when it has nothing to say.
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Brentwood", "Essex")))
|
||||
assert places_for_urn(build_place_index(reg), 999999) == ()
|
||||
|
||||
|
||||
def test_the_index_is_consistent_with_the_registry_it_was_built_from():
|
||||
# The invariant that matters: a link module must never offer a place whose
|
||||
# page does not exist, and never omit one that does.
|
||||
reg = build_place_registry(_df(
|
||||
_town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
+ _town(MIN_SCHOOLS, "Bedford", "Bedford", start=300000)
|
||||
))
|
||||
index = build_place_index(reg)
|
||||
for key, place in reg.items():
|
||||
for urn in place.urns:
|
||||
assert place in places_for_urn(index, urn), (
|
||||
f"{urn} is in {key} but the index does not say so")
|
||||
|
||||
|
||||
def test_the_index_holds_no_school_the_registry_does_not():
|
||||
# The reverse direction of the invariant above. An index entry for a URN
|
||||
# no published place contains would put a link on a page for a place that
|
||||
# does not list that school.
|
||||
reg = build_place_registry(_df(
|
||||
_town(MIN_SCHOOLS, "Brentwood", "Essex")
|
||||
+ _town(MIN_SCHOOLS - 1, "Tinytown", "Essex", start=400000)
|
||||
))
|
||||
index = build_place_index(reg)
|
||||
|
||||
for urn, places in index.items():
|
||||
for place in places:
|
||||
assert urn in place.urns
|
||||
assert place.key in reg
|
||||
|
||||
|
||||
def test_the_index_preserves_the_widest_first_order():
|
||||
# The breadcrumb reads authority then town, and takes this order as given.
|
||||
reg = build_place_registry(_df(_town(MIN_SCHOOLS, "Brentwood", "Essex")))
|
||||
kinds = [p.kind for p in places_for_urn(build_place_index(reg), 100000)]
|
||||
|
||||
assert kinds.index("authority") < kinds.index("town")
|
||||
@@ -56,6 +56,11 @@ def client(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
app_module, "get_supplementary_data", lambda db, urn: {}
|
||||
)
|
||||
# The place registry is a module-level cache, so without this the endpoint
|
||||
# answers from whatever registry an earlier test happened to leave behind
|
||||
# — and a `places == []` assertion is satisfied by a stale registry just
|
||||
# as well as by this fixture's own data, which makes it prove nothing.
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
return TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
@@ -69,3 +74,174 @@ def test_nan_gias_fields_serialize_as_null(client):
|
||||
assert info["capacity"] is None
|
||||
assert info["total_pupils"] is None
|
||||
assert info["school_name"] == "West London Performing Arts Academy"
|
||||
|
||||
|
||||
# ── Links out to the location layer ─────────────────────────────────────────
|
||||
#
|
||||
# School pages carried no link into the site at all: the only anchor on the
|
||||
# template pointed at the school's own website, so ~27k pages received
|
||||
# whatever authority the site had and sent it off-site. `places` is what the
|
||||
# link module and the breadcrumb are built from.
|
||||
|
||||
def test_places_is_present_even_when_the_school_belongs_to_none(client):
|
||||
# This fixture's single school cannot clear any publish threshold, so the
|
||||
# honest answer is an empty list. The key must still be there: a missing
|
||||
# key and "no places" are different things to the page rendering it.
|
||||
body = client.get("/api/schools/150275").json()
|
||||
assert body["places"] == []
|
||||
|
||||
|
||||
def test_places_names_only_pages_that_exist(monkeypatch):
|
||||
from backend import app as app_module
|
||||
from backend.places import MIN_SCHOOLS
|
||||
|
||||
def _df():
|
||||
return pd.DataFrame([
|
||||
{
|
||||
"urn": 100000 + i,
|
||||
"school_name": f"Brentwood School {i}",
|
||||
"town": "Brentwood",
|
||||
"local_authority": "Essex",
|
||||
"postcode": "CM15 8AA",
|
||||
"phase": "Primary",
|
||||
"year": 202425,
|
||||
"rwm_expected_pct": 60.0,
|
||||
"attainment_8_score": np.nan,
|
||||
"ofsted_grade": 2.0,
|
||||
"ofsted_date": None,
|
||||
}
|
||||
for i in range(MIN_SCHOOLS)
|
||||
])
|
||||
|
||||
monkeypatch.setattr(app_module, "load_school_data", _df)
|
||||
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
client = TestClient(app_module.app, raise_server_exceptions=False)
|
||||
|
||||
places = client.get("/api/schools/100000").json()["places"]
|
||||
assert places, "a school in a published town must offer links"
|
||||
|
||||
by_kind = {p["kind"]: p for p in places}
|
||||
assert by_kind["town"]["url"] == "/schools/brentwood"
|
||||
assert by_kind["authority"]["url"] == "/schools/authority/essex"
|
||||
|
||||
# Every entry carries what the link text needs, and a count, so the anchor
|
||||
# can say what it leads to rather than "click here".
|
||||
for place in places:
|
||||
assert place["name"]
|
||||
assert place["count"] >= 1
|
||||
assert place["url"].startswith("/schools/")
|
||||
|
||||
|
||||
def _brentwood_df(phase: str = "Primary", n: int = None):
|
||||
from backend.places import MIN_SCHOOLS
|
||||
n = n if n is not None else MIN_SCHOOLS
|
||||
return lambda: pd.DataFrame([
|
||||
{
|
||||
"urn": 100000 + i,
|
||||
"school_name": f"Brentwood School {i}",
|
||||
"town": "Brentwood", "local_authority": "Essex",
|
||||
"postcode": "CM15 8AA", "phase": phase, "year": 202425,
|
||||
"rwm_expected_pct": 60.0, "attainment_8_score": 50.0,
|
||||
"ofsted_grade": 2.0, "ofsted_date": None,
|
||||
}
|
||||
for i in range(n)
|
||||
])
|
||||
|
||||
|
||||
def _places_for(monkeypatch, df_factory, urn: int):
|
||||
from backend import app as app_module
|
||||
monkeypatch.setattr(app_module, "load_school_data", df_factory)
|
||||
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
client = TestClient(app_module.app, raise_server_exceptions=False)
|
||||
return client.get(f"/api/schools/{urn}").json()["places"]
|
||||
|
||||
|
||||
def test_a_place_offers_the_phase_page_this_school_appears_on(monkeypatch):
|
||||
# "primary schools in brentwood" is the query the phase pages exist for,
|
||||
# and ~950 of them were once reachable by nothing at all.
|
||||
places = _places_for(monkeypatch, _brentwood_df("Primary"), 100000)
|
||||
town = next(p for p in places if p["kind"] == "town")
|
||||
|
||||
assert town["phases"], "a primary school in a published primary town has a link"
|
||||
assert town["phases"][0]["url"] == "/schools/brentwood/primary"
|
||||
assert town["phases"][0]["count"] >= 1
|
||||
|
||||
|
||||
def test_an_all_through_school_offers_both_phase_pages(monkeypatch):
|
||||
# It genuinely appears on both, so there is no tie to break.
|
||||
places = _places_for(monkeypatch, _brentwood_df("All-through"), 100000)
|
||||
town = next(p for p in places if p["kind"] == "town")
|
||||
|
||||
assert {p["phase"] for p in town["phases"]} == {"primary", "secondary"}
|
||||
|
||||
|
||||
def test_outcodes_never_offer_a_phase_page(monkeypatch):
|
||||
# The registry gives outcodes no phase route — nobody searches "primary
|
||||
# schools in SW11" — and computing them anyway once put a link to a
|
||||
# nonexistent route on all 1,720 outcode pages.
|
||||
places = _places_for(monkeypatch, _brentwood_df("Primary"), 100000)
|
||||
outcode = next((p for p in places if p["kind"] == "outcode"), None)
|
||||
|
||||
if outcode is not None:
|
||||
assert outcode["phases"] == []
|
||||
|
||||
|
||||
def test_a_school_absent_from_the_phase_page_is_not_linked_to_it(monkeypatch):
|
||||
# The check is URN membership in the registry's own phase list, not a
|
||||
# re-derivation of the phase mapping. A secondary school must not be sent
|
||||
# to a primary phase page that does not list it.
|
||||
from backend.places import MIN_SCHOOLS
|
||||
|
||||
def df():
|
||||
rows = [
|
||||
{"urn": 100000 + i, "school_name": f"P{i}", "town": "Brentwood",
|
||||
"local_authority": "Essex", "postcode": "CM15 8AA",
|
||||
"phase": "Primary", "year": 202425, "rwm_expected_pct": 60.0,
|
||||
"attainment_8_score": np.nan, "ofsted_grade": 2.0,
|
||||
"ofsted_date": None}
|
||||
for i in range(MIN_SCHOOLS)
|
||||
]
|
||||
rows.append({
|
||||
"urn": 900000, "school_name": "Lone Secondary", "town": "Brentwood",
|
||||
"local_authority": "Essex", "postcode": "CM15 8AA",
|
||||
"phase": "Secondary", "year": 202425, "rwm_expected_pct": np.nan,
|
||||
"attainment_8_score": 50.0, "ofsted_grade": 2.0, "ofsted_date": None,
|
||||
})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
places = _places_for(monkeypatch, df, 900000)
|
||||
town = next(p for p in places if p["kind"] == "town")
|
||||
|
||||
# The town publishes a primary page, but this secondary school is not on
|
||||
# it, and there are too few secondaries for a secondary page.
|
||||
assert town["phases"] == []
|
||||
|
||||
|
||||
def test_the_place_index_rebuilds_when_the_registry_is_replaced(monkeypatch):
|
||||
"""The reverse index is cached; a stale one would put another dataset's
|
||||
places on a school page. Invalidation is an identity check against the
|
||||
registry rather than a second flag, so this asserts the check works."""
|
||||
from backend import app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "_place_registry", None)
|
||||
monkeypatch.setattr(app_module, "_place_index", None)
|
||||
monkeypatch.setattr(app_module, "_place_index_source", None)
|
||||
monkeypatch.setattr(app_module, "load_school_data", _brentwood_df("Primary"))
|
||||
|
||||
first = app_module.get_place_index()
|
||||
assert 100000 in first
|
||||
|
||||
# Same registry object, so the index is reused rather than rebuilt.
|
||||
assert app_module.get_place_index() is first
|
||||
|
||||
# Drop the registry the way every test that touches place data does. The
|
||||
# index must follow it, not survive it.
|
||||
app_module._place_registry = None
|
||||
monkeypatch.setattr(app_module, "load_school_data",
|
||||
_brentwood_df("Primary", n=0))
|
||||
|
||||
rebuilt = app_module.get_place_index()
|
||||
assert rebuilt is not first
|
||||
assert 100000 not in rebuilt, "the index outlived the registry it came from"
|
||||
@@ -23,6 +23,52 @@ Key files:
|
||||
- `backend/data_loader.py` - Data queries, geocoding, legacy DataFrame compatibility
|
||||
- `backend/schemas.py` - Column mappings, metric definitions, LA code mappings
|
||||
|
||||
### Content / CMS (Payload)
|
||||
|
||||
Payload CMS runs **inside** the Next.js app — one image, one container, no
|
||||
separate service. It powers `/blog`; `/about` is a plain coded page.
|
||||
|
||||
- **Admin panel:** `/admin`. The only authenticated surface on the site.
|
||||
`noindex` via both `robots.txt` and `X-Robots-Tag`.
|
||||
- **CMS API:** `/cms-api`, **not** `/api`. `/api/*` is a catch-all proxy to
|
||||
FastAPI (`app/(frontend)/api/[...path]`) which would silently swallow every
|
||||
admin call and forward it to the backend. Mount points are defined once in
|
||||
`lib/payloadRoutes.ts`.
|
||||
- **Database:** the existing Postgres, in its own `payload` schema, so no
|
||||
pipeline operation on `public` — including
|
||||
`scripts/migrate_csv_to_db.py --drop` — can reach blog content.
|
||||
- **Uploads:** the `payload_media` Docker volume at `/app/media`. Not
|
||||
reproducible from the pipeline; must be backed up.
|
||||
- **New env vars:** `DATABASE_URL` and `PAYLOAD_SECRET` on the frontend service.
|
||||
Staging must use a different `PAYLOAD_SECRET` from production.
|
||||
- Publishing workflow and house style: `nextjs-app/docs/PUBLISHING.md`.
|
||||
- **Admin field components resolve through a generated import map**
|
||||
(`app/(payload)/admin/importMap.js`). Payload hands the client a *path* per
|
||||
field and looks it up there; a missing entry renders no field and reports no
|
||||
error, while `required` still blocks the save. After adding or changing any
|
||||
field, editor or lexical feature, run `npm run generate:importmap` in
|
||||
`nextjs-app/` and commit the result.
|
||||
|
||||
### Two route groups
|
||||
|
||||
`nextjs-app/app/` has no root `layout.tsx`. It cannot: Payload's admin panel
|
||||
ships its own root layout rendering `<html>`/`<body>`, and Next permits
|
||||
multiple root layouts only when no `app/layout.tsx` exists.
|
||||
|
||||
- `app/(frontend)/` — the site. Its `layout.tsx` is the site's root layout.
|
||||
- `app/(payload)/` — the admin panel and `/cms-api`.
|
||||
|
||||
Route groups are invisible to routing, so every public URL is unchanged.
|
||||
|
||||
**The metadata file conventions stay at the `app/` root** — `robots.ts`,
|
||||
`opengraph-image.tsx`, `icon.png`, `apple-icon.png`. Inside a route group Next
|
||||
treats them as segment-scoped: it renames `/icon.png` to `/icon-<hash>.png` and
|
||||
drops `/robots.txt` entirely. Route handlers are unaffected.
|
||||
|
||||
The build must succeed with `DATABASE_URL` unset, because CI builds it that
|
||||
way. Never call `getCachedPayload()` at module scope, and never add
|
||||
`generateStaticParams` to a DB-backed route.
|
||||
|
||||
### Frontend (Vanilla JS)
|
||||
- Single-page application with hash-based routing
|
||||
- Chart.js for data visualization
|
||||
|
||||
@@ -18,7 +18,14 @@
|
||||
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
|
||||
# UNLEASH_API_TOKEN — Unleash *client* token, environment: development
|
||||
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
|
||||
# PAYLOAD_SECRET — Payload CMS encryption secret. REQUIRED: long and
|
||||
# random, and DIFFERENT from production's. Sharing
|
||||
# it would let a staging session authenticate
|
||||
# against production.
|
||||
# AIRFLOW_ADMIN_USER — Airflow admin username (default: admin)
|
||||
# AIRFLOW_ADMIN_PASSWORD — Airflow admin password. REQUIRED: the api-server
|
||||
# refuses to start without it, rather than falling
|
||||
# back to a generated one that changes on restart.
|
||||
# STAGING_DB_IP — macvlan IP for staging Postgres (default 10.0.1.190)
|
||||
# STAGING_FRONTEND_IP — macvlan IP for staging frontend (default 10.0.1.151)
|
||||
|
||||
@@ -86,9 +93,20 @@ services:
|
||||
- FASTAPI_URL=http://backend:80/api
|
||||
- TYPESENSE_URL=http://typesense:8108
|
||||
- TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme}
|
||||
# Payload CMS runs inside this container, in the `payload` schema of the
|
||||
# staging database. Staging has its own stack, its own Postgres and its
|
||||
# own admin account — never production's.
|
||||
- DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||
- PAYLOAD_SECRET=${PAYLOAD_SECRET:?set PAYLOAD_SECRET in the staging Portainer stack environment}
|
||||
volumes:
|
||||
# Portainer prefixes volume names with the stack name, so this is
|
||||
# automatically isolated from production's media.
|
||||
- payload_media:/app/media
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
backend: {}
|
||||
macvlan:
|
||||
@@ -124,7 +142,23 @@ services:
|
||||
airflow-api-server:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:staging
|
||||
container_name: sc_staging_airflow_api
|
||||
command: airflow api-server --port 8080
|
||||
# The simple auth manager generates a random password on first start and
|
||||
# writes it to a file, so every container restart invalidates the last one.
|
||||
# Writing the file ourselves from an environment variable makes the login
|
||||
# deterministic. Airflow does not generate anything when the file exists.
|
||||
#
|
||||
# Built with python rather than echo/printf so a password containing quotes,
|
||||
# backslashes or spaces is escaped correctly by json.dumps. An unset
|
||||
# AIRFLOW_ADMIN_PASSWORD raises KeyError and the container exits: falling
|
||||
# back to a generated password would silently undo the point of this.
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
set -euo pipefail
|
||||
mkdir -p /opt/airflow
|
||||
python -c "import json, os, pathlib; pathlib.Path('/opt/airflow/simple_auth_manager_passwords.json').write_text(json.dumps({os.environ.get('AIRFLOW_ADMIN_USER', 'admin'): os.environ['AIRFLOW_ADMIN_PASSWORD']}))"
|
||||
exec airflow api-server --port 8080
|
||||
ports:
|
||||
- "8081:8080"
|
||||
environment:
|
||||
@@ -136,6 +170,8 @@ services:
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-staging-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/simple_auth_manager_passwords.json
|
||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?set AIRFLOW_ADMIN_PASSWORD in the Portainer stack environment}
|
||||
AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs
|
||||
PG_HOST: sc_database
|
||||
PG_PORT: "5432"
|
||||
@@ -221,3 +257,4 @@ volumes:
|
||||
typesense_data:
|
||||
airflow_logs:
|
||||
unleash_cache:
|
||||
payload_media:
|
||||
@@ -9,7 +9,13 @@
|
||||
# TYPESENSE_SEARCH_KEY — Typesense search-only key (exposed to frontend)
|
||||
# UNLEASH_URL — http://<unleash-ip>:4242/api (empty = all flags off)
|
||||
# UNLEASH_API_TOKEN — Unleash *client* token, environment: production
|
||||
# AIRFLOW_ADMIN_USER — Airflow admin username (password auto-generated, see api-server logs)
|
||||
# PAYLOAD_SECRET — Payload CMS encryption secret. REQUIRED: long and
|
||||
# random. Changing it invalidates every admin
|
||||
# session. Staging MUST use a different value.
|
||||
# AIRFLOW_ADMIN_USER — Airflow admin username (default: admin)
|
||||
# AIRFLOW_ADMIN_PASSWORD — Airflow admin password. REQUIRED: the api-server
|
||||
# refuses to start without it, rather than falling
|
||||
# back to a generated one that changes on restart.
|
||||
|
||||
services:
|
||||
|
||||
@@ -75,9 +81,21 @@ services:
|
||||
- FASTAPI_URL=http://backend:80/api
|
||||
- TYPESENSE_URL=http://typesense:8108
|
||||
- TYPESENSE_API_KEY=${TYPESENSE_SEARCH_KEY:-changeme}
|
||||
# Payload CMS runs inside this container. It reaches Postgres over the
|
||||
# `backend` network and keeps its tables in the `payload` schema, so no
|
||||
# pipeline operation on `public` can touch blog content.
|
||||
- DATABASE_URL=postgresql://${DB_USERNAME}:${DB_PASSWORD}@sc_database:5432/${DB_DATABASE_NAME}
|
||||
# Same :? form as AIRFLOW_ADMIN_PASSWORD: refuse to start rather than
|
||||
# boot with an empty secret and silently accept forged sessions.
|
||||
- PAYLOAD_SECRET=${PAYLOAD_SECRET:?set PAYLOAD_SECRET in the Portainer stack environment}
|
||||
volumes:
|
||||
# Blog images. Not reproducible from the pipeline — must be backed up.
|
||||
- payload_media:/app/media
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
sc_database:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
backend: {}
|
||||
macvlan:
|
||||
@@ -113,7 +131,23 @@ services:
|
||||
airflow-api-server:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:prod
|
||||
container_name: schoolcompare_airflow_api
|
||||
command: airflow api-server --port 8080
|
||||
# The simple auth manager generates a random password on first start and
|
||||
# writes it to a file, so every container restart invalidates the last one.
|
||||
# Writing the file ourselves from an environment variable makes the login
|
||||
# deterministic. Airflow does not generate anything when the file exists.
|
||||
#
|
||||
# Built with python rather than echo/printf so a password containing quotes,
|
||||
# backslashes or spaces is escaped correctly by json.dumps. An unset
|
||||
# AIRFLOW_ADMIN_PASSWORD raises KeyError and the container exits: falling
|
||||
# back to a generated password would silently undo the point of this.
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
set -euo pipefail
|
||||
mkdir -p /opt/airflow
|
||||
python -c "import json, os, pathlib; pathlib.Path('/opt/airflow/simple_auth_manager_passwords.json').write_text(json.dumps({os.environ.get('AIRFLOW_ADMIN_USER', 'admin'): os.environ['AIRFLOW_ADMIN_PASSWORD']}))"
|
||||
exec airflow api-server --port 8080
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
@@ -125,6 +159,8 @@ services:
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "${AIRFLOW_ADMIN_USER:-admin}:admin"
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/simple_auth_manager_passwords.json
|
||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:?set AIRFLOW_ADMIN_PASSWORD in the Portainer stack environment}
|
||||
AIRFLOW__LOGGING__BASE_LOG_FOLDER: /opt/airflow/logs
|
||||
PG_HOST: sc_database
|
||||
PG_PORT: "5432"
|
||||
@@ -210,3 +246,4 @@ volumes:
|
||||
typesense_data:
|
||||
airflow_logs:
|
||||
unleash_cache:
|
||||
payload_media:
|
||||
+19
-1
@@ -105,7 +105,23 @@ services:
|
||||
airflow-api-server:
|
||||
image: privaterepo.sitaru.org/tudor/school_compare-pipeline:latest
|
||||
container_name: schoolcompare_airflow_api
|
||||
command: airflow api-server --port 8080
|
||||
# The simple auth manager generates a random password on first start and
|
||||
# writes it to a file, so every container restart invalidates the last one.
|
||||
# Writing the file ourselves from an environment variable makes the login
|
||||
# deterministic. Airflow does not generate anything when the file exists.
|
||||
#
|
||||
# Built with python rather than echo/printf so a password containing quotes,
|
||||
# backslashes or spaces is escaped correctly by json.dumps. An unset
|
||||
# AIRFLOW_ADMIN_PASSWORD raises KeyError and the container exits: falling
|
||||
# back to a generated password would silently undo the point of this.
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
set -euo pipefail
|
||||
mkdir -p /opt/airflow
|
||||
python -c "import json, os, pathlib; pathlib.Path('/opt/airflow/simple_auth_manager_passwords.json').write_text(json.dumps({os.environ.get('AIRFLOW_ADMIN_USER', 'admin'): os.environ['AIRFLOW_ADMIN_PASSWORD']}))"
|
||||
exec airflow api-server --port 8080
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment: &airflow-env
|
||||
@@ -117,6 +133,8 @@ services:
|
||||
AIRFLOW__API_AUTH__JWT_SECRET: "school-compare-airflow-jwt-secret-key-long-enough-for-sha512"
|
||||
AIRFLOW__API_AUTH__JWT_ISSUER: airflow
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "admin:admin"
|
||||
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_PASSWORDS_FILE: /opt/airflow/simple_auth_manager_passwords.json
|
||||
AIRFLOW_ADMIN_PASSWORD: ${AIRFLOW_ADMIN_PASSWORD:-admin}
|
||||
PG_HOST: db
|
||||
PG_PORT: "5432"
|
||||
PG_USER: schoolcompare
|
||||
|
||||
@@ -98,6 +98,12 @@ fail the E2E gate. That's the point: staging absorbs the risk.
|
||||
pr-checks status checks (frontend, backend, builds, ai-review) to pass.
|
||||
5. **Bootstrap staging data via Airflow** (no prod dump — staging populates
|
||||
itself from source, exercising the pipeline image end-to-end):
|
||||
- Set `AIRFLOW_ADMIN_PASSWORD` in the stack environment first. The
|
||||
api-server refuses to start without it. Airflow's simple auth manager
|
||||
otherwise generates a password on first start and writes it to a file, so
|
||||
the login changes every time the container restarts; the stack writes that
|
||||
file itself from this variable instead. `AIRFLOW_ADMIN_USER` defaults to
|
||||
`admin`.
|
||||
- Open the staging Airflow UI (`http://<host>:8081`) and trigger, in order:
|
||||
`school_data_daily`, `school_data_monthly_ofsted`, then the manual-schedule
|
||||
`school_data_annual_ees` and `school_data_annual_idaci`.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -42,17 +42,57 @@ Those are the precise numbers the `c` exists to hide, and in a random 400-school
|
||||
sample **22% of mainstream secondaries** have exactly one suppressed category in
|
||||
their disadvantaged group. This is the normal case, not an edge case.
|
||||
|
||||
Two rules follow, and everything else in this document is downstream of them.
|
||||
Three rules follow, and everything else in this document is downstream of them.
|
||||
|
||||
**R1 — Never render a derived remainder.** Not as a number, and not as a bar
|
||||
segment: a segment sized by the residual can be read straight off the axis. Where
|
||||
any category in a pupil group is suppressed, the page shows the published
|
||||
categories, says the rest are withheld, and stops.
|
||||
**R1 — Never *publish* enough to derive a remainder.**
|
||||
|
||||
**R2 — Never aggregate across a suppression boundary.** A group total is
|
||||
publishable only when DfE published that total itself, or when the aggregate
|
||||
spans **two or more** suppressed cells. Summing published components to fill a
|
||||
gap is R1 with extra steps.
|
||||
An earlier draft of this rule said "never *render* a derived remainder", and
|
||||
that was the defect code review caught in PR #137. Not drawing a number does
|
||||
nothing to stop it being computed: `GET /api/schools/{urn}` is public and
|
||||
unauthenticated, so anything in the payload is published whatever the UI
|
||||
chooses to draw. The rendering guards shipped; the payload still carried the
|
||||
cohort and every published category, and `cohort - sum(published)` returned
|
||||
Whitley Bay's withheld figure exactly.
|
||||
|
||||
The rule is therefore about the serialiser, and the UI guards are a second line
|
||||
of defence behind it. Two identities have to be closed:
|
||||
|
||||
- within a pupil group the categories sum to the cohort, so a group with
|
||||
exactly **one** suppressed category gives it away;
|
||||
- across groups, disadvantaged + other = all for every category, so a category
|
||||
suppressed in exactly **one** of the three gives itself away.
|
||||
|
||||
`_mask_for_disclosure` applies DfE's own answer — secondary suppression —
|
||||
withholding a companion cell until every row and every column hides either none
|
||||
or at least two. It iterates, because each new suppression can break the other
|
||||
identity, and terminates because cells are only ever added.
|
||||
|
||||
The companion must carry pupils. Suppressing a zero looks like secondary
|
||||
suppression and protects nothing: the residual still equals the original
|
||||
withheld figure.
|
||||
|
||||
Where no companion can do the job — a sparse cohort whose every other category
|
||||
is `not_applicable`, routine in special schools and alternative provision — the
|
||||
pupil group is **dropped from the payload entirely**. A first version simply
|
||||
returned at that point with the violation intact and no signal, which review
|
||||
caught: a disclosure-control pass that fails silently is worse than none,
|
||||
because everything downstream trusts it. The function now cannot terminate
|
||||
except in a state where `disclosure_invariant_holds()` is true, and an
|
||||
exhaustive test sweeps all 81 suppression patterns of a four-category group to
|
||||
prove it.
|
||||
|
||||
Measured cost on the 400-school sample: the all-pupils bar survives on **94%**
|
||||
of mainstream secondaries rather than 100%. That is the price of not
|
||||
republishing what DfE withheld.
|
||||
|
||||
**R2 — Never aggregate across a suppression boundary.** Summing published
|
||||
components to fill a gap is R1 with extra steps.
|
||||
|
||||
DfE's own aggregates (`Sustained education destination`, `Sustained education,
|
||||
employment & apprenticeships`) are ingested but **not served**. An aggregate
|
||||
spanning exactly one suppressed component names it, and nothing renders them
|
||||
today — an unused field that leaks is not a trade-off worth carrying. They can
|
||||
be re-added with their own guard if the fallback ladder is ever built.
|
||||
|
||||
**R3 — The three pupil groups are one disclosure surface, not three.**
|
||||
Disadvantaged and Not-known-to-be-disadvantaged partition All pupils, so
|
||||
@@ -116,12 +156,17 @@ the counts — the published percentages do not sum to 100.
|
||||
|
||||
Random 400-school sample, 2022/23, mainstream secondaries (n=262):
|
||||
|
||||
| View | Published | Consequence |
|
||||
|---|---|---|
|
||||
| All pupils, all categories | **100%** | Full bar works everywhere |
|
||||
| Disadvantaged, headline rate | 95% | Gap panel works |
|
||||
| Disadvantaged, three grouped cards | 68% | Degrades card by card |
|
||||
| Disadvantaged, all six categories | **20%** | Bar unusable for this group |
|
||||
| View | As published by DfE | After R1–R3 masking | Consequence |
|
||||
|---|---|---|---|
|
||||
| All pupils, all categories | 100% | **94%** | Bar works nearly everywhere |
|
||||
| Disadvantaged, headline rate | 95% | 95% | Gap panel works |
|
||||
| Disadvantaged, three grouped cards | 68% | 68% | Degrades card by card |
|
||||
| Disadvantaged, all six categories | 20% | **20%** | Bar unusable for this group |
|
||||
|
||||
The middle column is what the site actually serves. Masking costs the
|
||||
all-pupils bar on 6% of mainstream secondaries — those are schools where a
|
||||
category was suppressed in exactly one pupil group and no non-zero companion
|
||||
existed below the all-pupils row.
|
||||
|
||||
Special schools and alternative provision are far worse: 13% and 41% respectively
|
||||
have the whole cohort suppressed even for all pupils. The empty state is
|
||||
@@ -325,10 +370,12 @@ and the staging E2E gate runs post-merge.
|
||||
|
||||
## Risks
|
||||
|
||||
**A later change reintroduces the disclosure.** The likeliest route is someone
|
||||
applying `safe_numeric` to a destination column for consistency, or adding a
|
||||
`coalesce` in a mart. Mitigation is the dbt test plus the unit tests on
|
||||
`canAggregate()` — the rule has to be executable, not documentary.
|
||||
**A later change reintroduces the disclosure.** The likeliest routes are
|
||||
applying `safe_numeric` to a destination column for consistency, adding a
|
||||
`coalesce` in a mart, or — as happened in review — enforcing a disclosure rule
|
||||
at the rendering layer instead of the publishing layer. Mitigation is the dbt
|
||||
tests plus `backend/tests/test_destinations_api.py`, which reconstructs the
|
||||
residual the way an attacker would and asserts it no longer resolves.
|
||||
|
||||
**The two-year lag reads as staleness.** Mitigated by dating the cohort in the
|
||||
section header rather than only in a tooltip.
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# Giving schoolcompare a human author: an About page and a blog
|
||||
|
||||
**Date:** 2026-09-02
|
||||
**Status:** Design — awaiting review
|
||||
**Scope:** A named author for the site, an `/about` page, and a Payload-CMS-backed
|
||||
blog at `/blog`.
|
||||
|
||||
## Why
|
||||
|
||||
The site reads as synthetic. Not because of its tone, but because of three
|
||||
specific absences:
|
||||
|
||||
1. **Nobody is accountable for the numbers.** There is no author, no statement
|
||||
of why the site exists, and no one who can be wrong. The only human trace on
|
||||
the entire site is `contact@schoolcompare.co.uk` in the footer.
|
||||
2. **No visible judgement.** Every figure is presented as though it fell out of
|
||||
a machine. Hundreds of editorial decisions went into this codebase — which
|
||||
metrics to show, when a benchmark is invalid, what to suppress — and not one
|
||||
of them is visible to a reader. `isSpecialSchool()` silently drops the
|
||||
England comparison for special schools and PRUs because that comparison is
|
||||
meaningless; nowhere does the site *say* so.
|
||||
3. **The voice is institutional third person.** "schoolcompare brings it all
|
||||
into one place." "Built for parents, governors, journalists." That is
|
||||
brochure register, and it is precisely the register that machine-generated
|
||||
content defaults to.
|
||||
|
||||
There is a second, independent reason. The SEO programme
|
||||
(`2026-08-20-seo-programme-design.md`) defines eight workstreams and none of
|
||||
them address E-E-A-T or authorship. School performance data is YMYL territory;
|
||||
an anonymous site republishing DfE figures has no authorship signal at all. This
|
||||
work fills that hole, and the blog gives W6 (explainer content) somewhere to
|
||||
live.
|
||||
|
||||
### The failure mode to avoid
|
||||
|
||||
The standard fix — a stock photo and "Hi, I'm Tudor, and I'm passionate about
|
||||
education!" — reads as *more* synthetic than the current coldness. Manufactured
|
||||
warmth is a stronger machine-tell than plain institutional voice. Everything
|
||||
here has to be specific, occasionally awkward, and willing to be unflattering,
|
||||
or it makes the problem worse.
|
||||
|
||||
## Positioning
|
||||
|
||||
The author is **Tudor**: first name only, real photograph, no surname, no
|
||||
employer named.
|
||||
|
||||
The credibility claim is deliberately **not** educational expertise. The About
|
||||
page states plainly: *"I'm not an education expert."* Authority comes from two
|
||||
things that are actually true:
|
||||
|
||||
- **Experience.** A parent going through primary admissions in south-west London
|
||||
right now. Google's E-E-A-T leads with Experience, and lived experience of the
|
||||
thing is exactly what the DfE's own service lacks.
|
||||
- **Method.** Every number's provenance is stated, so a reader can check the
|
||||
site rather than trust it.
|
||||
|
||||
This is more durable than borrowed expertise: it cannot be undermined by someone
|
||||
noticing the author has no teaching qualification.
|
||||
|
||||
**Consequence for the design.** A `Person` entity with no surname is a weak
|
||||
search signal and cannot be corroborated off-site. The credibility load
|
||||
therefore shifts onto the methodology being visibly rigorous. That is a design
|
||||
constraint, not a caveat — it is why the About page carries a substantial
|
||||
"how this is built and where it can be wrong" section rather than a short bio.
|
||||
|
||||
### Voice rules
|
||||
|
||||
Applied to About and every post. Recorded here so the voice does not drift.
|
||||
|
||||
- First person singular. "I built", not "we provide".
|
||||
- Concrete over general. "when we were looking at schools in Wandsworth" beats
|
||||
any amount of stated warmth.
|
||||
- State limits before someone else finds them. Every post that presents a
|
||||
metric says what it does not show.
|
||||
- No mission statements, no "passionate about", no invented team.
|
||||
- No em dashes. One of the clearest tells of machine-written prose, which is
|
||||
the exact problem this work exists to fix.
|
||||
- Short sentences. The existing code comments in this repo are already written
|
||||
this way; the prose should match.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:**
|
||||
|
||||
- `/about` — a coded page (not CMS-managed).
|
||||
- `/blog` and `/blog/[slug]` — Payload-backed, with an index and post pages.
|
||||
- Payload CMS installed into the existing Next application.
|
||||
- Footer and navigation links to both.
|
||||
- `Person`, `Organization`, `BlogPosting`, `BreadcrumbList` JSON-LD.
|
||||
- RSS feed and sitemap integration.
|
||||
- One first post, so the blog does not launch empty.
|
||||
|
||||
**Out (deliberately):**
|
||||
|
||||
- Rewriting existing homepage/how-it-works copy into first person. Worth doing,
|
||||
but it would double the review surface of this PR. Separate change.
|
||||
- In-product signed notes on school pages (the "distributed humanity" idea).
|
||||
Revisit once About and the blog exist.
|
||||
- Comments, newsletter, author accounts beyond one.
|
||||
- A team page. There is no team.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Topology
|
||||
|
||||
Payload 3 installs **into the existing Next application** and serves `/admin`
|
||||
from the same container. One image, one deploy, no new service. This is
|
||||
Payload 3's native model and it makes on-demand revalidation trivial, because
|
||||
the CMS hooks run in the same process as the Next cache.
|
||||
|
||||
Accepted costs: the public site's image now carries Payload, so a CMS security
|
||||
patch redeploys the whole site; and the image grows substantially.
|
||||
|
||||
### Two collisions that must be handled
|
||||
|
||||
**1. `/api` is already taken.** `app/api/[...path]/route.ts` is a catch-all that
|
||||
proxies `/api/*` to FastAPI at runtime. Payload's default API route is also
|
||||
`/api`. Left alone, these fight, and the failure is not clean — the catch-all
|
||||
would swallow Payload's admin API calls and forward them to FastAPI.
|
||||
|
||||
Payload's API route is therefore remapped:
|
||||
|
||||
```ts
|
||||
routes: { api: '/cms-api', admin: '/admin' }
|
||||
```
|
||||
|
||||
with its route group at `app/(payload)/cms-api/[...slug]/route.ts`. The
|
||||
`/cms-api` prefix must also be added to the FastAPI proxy's excluded-paths list
|
||||
as a defensive second line.
|
||||
|
||||
**2. `next.config.js` is CommonJS.** Payload's `withPayload()` wrapper is ESM
|
||||
only. The config must become `next.config.mjs`, converting `module.exports` to
|
||||
`export default` and wrapping the export. All existing content — the standalone
|
||||
output, `outputFileTracingIncludes`, the staging `X-Robots-Tag` header block,
|
||||
the CSP — carries over unchanged. This is mechanical but it touches the file
|
||||
that controls staging's noindex, so it needs care and an explicit test.
|
||||
|
||||
### Database
|
||||
|
||||
Payload uses the existing `sc_database` Postgres instance, in its **own
|
||||
`payload` schema**:
|
||||
|
||||
```ts
|
||||
db: postgresAdapter({
|
||||
pool: { connectionString: process.env.DATABASE_URL },
|
||||
schemaName: 'payload',
|
||||
})
|
||||
```
|
||||
|
||||
The frontend container is already on the `backend` Docker network, so it can
|
||||
reach `sc_database:5432` with no networking change. It needs a new
|
||||
`DATABASE_URL` environment variable.
|
||||
|
||||
Schema isolation is not cosmetic. `public` currently holds the application
|
||||
tables and Airflow's metadata, and `scripts/migrate_csv_to_db.py --drop` exists
|
||||
to drop and reimport. Blog content living in its own schema means no data
|
||||
pipeline operation can destroy it.
|
||||
|
||||
**Verified 2026-09-02** (this was an open question when the spec was written).
|
||||
`--drop` calls `run_full_migration()` in `backend/migration.py`, which drops
|
||||
exactly two tables by name:
|
||||
|
||||
```python
|
||||
ks2_tables = ["school_results", "schools"]
|
||||
for tname in ks2_tables:
|
||||
if tname in existing:
|
||||
Base.metadata.tables[tname].drop(bind=engine)
|
||||
```
|
||||
|
||||
There is no `Base.metadata.drop_all()` anywhere in `backend/`, and no
|
||||
`DROP SCHEMA`. The only other drop is `_apply_schema_drops()`, a single
|
||||
schema-qualified `DROP TABLE IF EXISTS marts.fact_parent_view CASCADE`.
|
||||
Nothing sets `search_path`, so the SQLAlchemy metadata resolves to `public`,
|
||||
and `inspector.get_table_names()` does not even enumerate other schemas.
|
||||
|
||||
So the guarantee is stronger than schema isolation alone: `--drop` targets two
|
||||
named tables that Payload does not have, and would not reach `posts`, `media`
|
||||
or `users` even if they shared a schema. The `payload` schema remains the right
|
||||
choice — it protects against a *future* broadening of that script rather than
|
||||
today's behaviour — but the safety claim rests on verified code, not on
|
||||
assumption.
|
||||
|
||||
Putting CMS tables in this instance is consistent with existing practice —
|
||||
Airflow already stores its metadata there.
|
||||
|
||||
### Migrations
|
||||
|
||||
Payload's Postgres adapter auto-pushes schema in development and requires
|
||||
explicit migrations in production. Use `prodMigrations`, which runs pending
|
||||
migrations during server initialisation:
|
||||
|
||||
```ts
|
||||
db: postgresAdapter({ /* ... */, prodMigrations: migrations })
|
||||
```
|
||||
|
||||
This is preferred over a one-shot init container (the `airflow-init` pattern)
|
||||
because the app is a single long-running process and there is no ordering
|
||||
problem to solve. Migration files are generated with `payload migrate:create`
|
||||
and committed, so schema changes travel through the same PR and staging gate as
|
||||
code.
|
||||
|
||||
### Media
|
||||
|
||||
Uploads go to a Docker named volume, consistent with `postgres_data`,
|
||||
`typesense_data` and `airflow_logs`.
|
||||
|
||||
- `staticDir` must be an **absolute** path in Payload 3: `/app/media`.
|
||||
- The container runs as `nextjs` (uid 1001). The Dockerfile must
|
||||
`mkdir -p /app/media && chown nextjs:nodejs /app/media` **before** the volume
|
||||
is mounted, or Docker will create the mountpoint root-owned and every upload
|
||||
will fail with EACCES.
|
||||
- `sharp` moves from `devDependencies` to `dependencies` — Payload needs it at
|
||||
runtime to generate `imageSizes`.
|
||||
- The volume must be added to the backup routine alongside Postgres. A blog
|
||||
post's images are not reproducible from the pipeline.
|
||||
|
||||
### Rendering
|
||||
|
||||
**Constraint:** CI builds the image with no database reachable. Blog pages
|
||||
therefore cannot use build-time `generateStaticParams` — that would either fail
|
||||
the build or bake in an empty post list.
|
||||
|
||||
Instead: ISR. Post and index pages declare a `revalidate` window and render on
|
||||
first request, with Payload `afterChange` / `afterDelete` hooks calling
|
||||
`revalidatePath('/blog')` and `revalidatePath('/blog/' + slug)` for immediate
|
||||
publication. Because Payload runs in the same process, the hook calls
|
||||
`revalidatePath` from `next/cache` directly — no webhook, no shared secret.
|
||||
|
||||
The ISR cache lives on container disk and is cleared by a redeploy. For a
|
||||
single container serving a handful of posts this is fine.
|
||||
|
||||
### Collections
|
||||
|
||||
- **`posts`** — `title`, `slug`, `publishedAt`, `excerpt`, `heroImage`
|
||||
(relation to `media`), `content` (Lexical rich text), `seo` group
|
||||
(`metaTitle`, `metaDescription`), `_status` (drafts enabled).
|
||||
- **`media`** — upload collection, `alt` required, `imageSizes` for thumbnail
|
||||
and hero widths, public read access.
|
||||
- **`users`** — Payload's auth collection. One account. Public creation
|
||||
disabled.
|
||||
|
||||
Drafts are enabled so posts can be written over several sittings and previewed
|
||||
before publication.
|
||||
|
||||
**Payload Blocks** are how posts embed live product components — a real trend
|
||||
chart or comparison table inside a post, rendered from live data rather than
|
||||
screenshotted. This is the main thing the CMS has to earn back against
|
||||
file-based MDX, and it directly serves the goal: showing judgement in context.
|
||||
Ship with one block (a callout/aside for "what this number doesn't tell you");
|
||||
add a live-chart block once a post needs it.
|
||||
|
||||
### Security
|
||||
|
||||
`/admin` is the first authenticated surface on this site. Public, hardened:
|
||||
|
||||
- `PAYLOAD_SECRET` — long, random, set in the Portainer stack environment, never
|
||||
committed. The same variable must exist in staging with a *different* value.
|
||||
- Strong unique password on the single admin account.
|
||||
- Login rate limiting via Payload's `maxLoginAttempts` / `lockTime`.
|
||||
- `X-Robots-Tag: noindex, nofollow` on `/admin/*` and `/cms-api/*`, and a
|
||||
`robots.ts` disallow. The admin panel must never be indexed.
|
||||
- Public user creation disabled; no open registration.
|
||||
- Verify the existing CSP `frame-ancestors` directive does not break the admin
|
||||
panel.
|
||||
|
||||
Residual risk, accepted: a future Payload authentication CVE is live against the
|
||||
public internet. Mitigation is prompt patching, which the staging→prod pipeline
|
||||
already supports. If this becomes uncomfortable, restricting `/admin` at the
|
||||
proxy to LAN/VPN is a one-line change later.
|
||||
|
||||
Staging note: staging runs the same image on `stx.`, so it gets its own admin
|
||||
panel and its own database. It must have its own `PAYLOAD_SECRET` and its own
|
||||
credentials — never production's.
|
||||
|
||||
## Deployment changes
|
||||
|
||||
- `nextjs-app/Dockerfile` — create and chown `/app/media`; ensure Payload's
|
||||
admin bundle and `sharp` survive standalone output file tracing.
|
||||
- `docker-compose.portainer.yml` and the staging equivalent — add
|
||||
`DATABASE_URL` and `PAYLOAD_SECRET` to the `frontend` service, add a
|
||||
`payload_media` volume mounted at `/app/media`, and add
|
||||
`depends_on: sc_database`.
|
||||
- Document both new environment variables in the compose header comment block,
|
||||
which is where this stack records its configuration.
|
||||
|
||||
## SEO
|
||||
|
||||
- `Person` (Tudor, with photo) and `Organization` JSON-LD on `/about`.
|
||||
- `BlogPosting` + `BreadcrumbList` on post pages, with `author` referencing the
|
||||
same `Person`.
|
||||
- Canonical URLs on `/blog` and every post.
|
||||
- Posts and `/about` added to the existing sitemap (`app/sitemap.xml/route.ts`
|
||||
and `app/sitemaps/[...parts]`). Post URLs come from Payload at request time.
|
||||
- RSS feed at `/blog/rss.xml`.
|
||||
- Footer links to both pages, under a new "About" column.
|
||||
|
||||
**Navigation is deliberately left alone.** `Navigation.tsx` renders a bottom tab
|
||||
bar on mobile that already carries four items (Search, Compare, Rankings,
|
||||
Admissions). A fifth tab makes each one cramped at 320px, and About and Blog are
|
||||
both lower-intent than any of the four. Both live in the footer; About
|
||||
additionally gets a byline link from every post, which is where a reader who
|
||||
cares actually asks the question. Revisit only if analytics show people hunting
|
||||
for it.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit (Jest):
|
||||
|
||||
- Post rendering, including a post with no hero image and one with no excerpt.
|
||||
- Slug generation and collision handling.
|
||||
- JSON-LD shape for `BlogPosting` and `Person`.
|
||||
- The `next.config.mjs` conversion preserves the staging `X-Robots-Tag` rule —
|
||||
this guards the riskiest mechanical change in the plan.
|
||||
|
||||
E2E (Playwright, `e2e/`, required by CLAUDE.md for user-facing change):
|
||||
|
||||
- `/about` renders, shows the author name and photo, and is reachable from the
|
||||
footer and nav.
|
||||
- `/blog` lists at least one post; clicking through reaches the post.
|
||||
- A post page renders title, date, body and byline.
|
||||
- `/admin` responds with `noindex` and does not leak a stack trace when
|
||||
unauthenticated.
|
||||
|
||||
Note the known constraint: new journeys cannot be proven in PR checks, because
|
||||
the staging E2E gate runs post-merge.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `next.config.mjs` conversion silently drops the staging noindex header, making staging a crawlable duplicate | Unit test asserting the header rule; verify on staging before promotion |
|
||||
| Payload API route collides with the FastAPI `/api` proxy | Remap to `/cms-api`; add to the proxy's exclusion list |
|
||||
| Media volume mounts root-owned; all uploads fail with EACCES | `mkdir`+`chown` in the Dockerfile before the mount; test an upload on staging |
|
||||
| Build fails or bakes empty content because CI has no DB | No build-time DB access; ISR only |
|
||||
| A pipeline `--drop` destroys blog content | Separate `payload` schema; verify `--drop` blast radius before building |
|
||||
| Media volume not backed up; images unrecoverable | Add `payload_media` to the backup routine |
|
||||
| Payload auth CVE exposed publicly | Prompt patching; proxy restriction available as a fallback |
|
||||
| Blog launches empty or goes stale | Ship with one post; cadence is explicitly "a few times a year", so no cadence is promised anywhere on the page — no dates implying a schedule |
|
||||
|
||||
## Sequence
|
||||
|
||||
Each step is independently reviewable and mergeable.
|
||||
|
||||
1. **Payload foundation** — install, `next.config.mjs` conversion, `payload`
|
||||
schema, `/cms-api` remap, `users` collection, `/admin` hardening, compose and
|
||||
Dockerfile changes. No public-facing change yet. Verify on staging that the
|
||||
site is unchanged and `/admin` works.
|
||||
2. **`/about`** — coded page, photo, `Person`/`Organization` JSON-LD, footer and
|
||||
nav links, e2e journey. Independently valuable and does not depend on the
|
||||
blog.
|
||||
3. **Blog** — `posts` and `media` collections, `/blog` index and post pages, ISR
|
||||
plus revalidation hooks, RSS, sitemap, structured data, e2e journeys.
|
||||
4. **First post** — written in the admin panel, published through the normal
|
||||
flow, proving the whole path end to end.
|
||||
|
||||
Step 1 carries all the infrastructure risk and none of the visible benefit, so
|
||||
it should be verified on staging carefully before step 2 starts.
|
||||
|
||||
## Dependencies on Tudor
|
||||
|
||||
- **A photograph.** Blocks step 2. Nothing else in the plan is blocked by it.
|
||||
- **The first post's subject.** Blocks step 4 only. Suggested: what school
|
||||
performance data cannot tell you — it demonstrates judgement, is genuinely
|
||||
useful, and is the kind of thing an anonymous or machine-written site will not
|
||||
publish.
|
||||
- ~~Confirmation that `scripts/migrate_csv_to_db.py --drop` is schema-scoped.~~
|
||||
**Resolved 2026-09-02** — verified in `backend/migration.py`; see the
|
||||
Database section. No action needed.
|
||||
+255
-9
@@ -1304,6 +1304,52 @@ test('with the distance feature off, the section is absent rather than empty', a
|
||||
.toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* A secondary school carrying an EES admissions row, which is what makes its
|
||||
* Admissions section render while the distance feature is dark.
|
||||
*/
|
||||
async function secondarySchoolWithAdmissions(page: Page) {
|
||||
const list = await page.request.get('/api/schools?phase=secondary&page_size=40');
|
||||
if (!list.ok()) return null;
|
||||
const body = await list.json();
|
||||
for (const s of (body?.schools ?? []).slice(0, 25)) {
|
||||
const res = await page.request.get(`/api/schools/${s.urn}`);
|
||||
if (!res.ok()) continue;
|
||||
const detail = await res.json();
|
||||
if (detail?.admissions == null) continue;
|
||||
return { urn: s.urn as number };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
test('with the distance feature off, a secondary page makes no claim about publication', async ({ page }) => {
|
||||
/*
|
||||
* Shipping dark must not put words in the council's mouth. The secondary
|
||||
* template is the only one that words the absence, and "X has not published
|
||||
* a cut-off distance for this school" is false wherever X does publish and
|
||||
* we are simply withholding it.
|
||||
*
|
||||
* This is why the API omits the key rather than sending null: absent means
|
||||
* "cut-offs are not published at all", null means "this school has none".
|
||||
* Only the second is a fact about the school, and only the second is sayable.
|
||||
*/
|
||||
test.skip(await distanceFeatureIsOn(page),
|
||||
'the admission_distance flag is on in this environment');
|
||||
|
||||
const found = await secondarySchoolWithAdmissions(page);
|
||||
test.skip(found === null, 'no secondary school in the sample has an admissions row');
|
||||
|
||||
await page.goto(`/school/${found!.urn}`);
|
||||
await expect(page.locator('h1').first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The Admissions section is still there — this is not a test that the whole
|
||||
// section vanished, which would pass for the wrong reason.
|
||||
await expect(page.locator('#admissions')).toHaveCount(1);
|
||||
|
||||
await expect(page.getByText(/has not published a cut-off distance/)).toHaveCount(0);
|
||||
await expect(page.getByText(/Contact the admissions authority/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('/api/flags is not reachable from the public internet', async ({ page }) => {
|
||||
// It names every unreleased feature and whether it is on. Next reads it
|
||||
// server-side over the Docker network; the public proxy must deny it.
|
||||
@@ -1889,6 +1935,63 @@ async function firstPlaceOfKind(page: Page, kind: string) {
|
||||
return hit as { kind: string; slug: string; name: string; count: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* The round trip. Place pages always linked down to school pages; school
|
||||
* pages linked nowhere on the site, so the ~27k of them that carry most of
|
||||
* the inbound authority stranded it — their only anchor pointed at the
|
||||
* school's own website.
|
||||
*
|
||||
* Asserting both directions is the point. A one-way link is what already
|
||||
* existed and is not what this journey is for.
|
||||
*/
|
||||
test('a school page links back into the location layer, and the place page links down', async ({ page }) => {
|
||||
const town = await firstPlaceOfKind(page, 'town');
|
||||
|
||||
// Start from the place page and take its first school, so the pair is
|
||||
// guaranteed to be genuinely related rather than a hardcoded guess.
|
||||
await page.goto(`/schools/${town.slug}`);
|
||||
const schoolHref = await page.locator('a[href^="/school/"]').first()
|
||||
.getAttribute('href');
|
||||
expect(schoolHref, 'the town page listed no school to follow').toBeTruthy();
|
||||
|
||||
await page.goto(schoolHref!);
|
||||
|
||||
// Down: the school page must offer a link back to the town it sits in.
|
||||
const backToTown = page.locator(`a[href="/schools/${town.slug}"]`);
|
||||
await expect(backToTown).toHaveCount(1);
|
||||
await expect(backToTown).toBeVisible();
|
||||
|
||||
// The anchor says what it leads to, which is worth more than "see more".
|
||||
await expect(backToTown).toContainText(town.name, { ignoreCase: true });
|
||||
await expect(backToTown).toContainText(/\d+ schools?/);
|
||||
|
||||
// And the breadcrumb resolves the school into a real hierarchy.
|
||||
const blocks = await page.locator('script[type="application/ld+json"]')
|
||||
.allTextContents();
|
||||
const graph = blocks.join(' ');
|
||||
expect(graph).toContain('"BreadcrumbList"');
|
||||
// The narrower type, not the EducationalOrganization parent it used to be.
|
||||
expect(graph).toContain('"School"');
|
||||
|
||||
/*
|
||||
* The phase variants are the pages this most needs to reach: ~950 of them
|
||||
* were once reachable by nothing at all, absent from every sitemap and
|
||||
* unlinked from the place page. Conditional because not every school sits
|
||||
* in a town that publishes one.
|
||||
*/
|
||||
const phaseLink = page.locator(`a[href^="/schools/${town.slug}/"]`).first();
|
||||
if (await phaseLink.count()) {
|
||||
const phaseHref = await phaseLink.getAttribute('href');
|
||||
expect((await page.request.get(phaseHref!)).status()).toBe(200);
|
||||
await expect(phaseLink).toContainText(/primary|secondary/);
|
||||
}
|
||||
|
||||
// Following it lands on a real page, not a 404.
|
||||
await backToTown.click();
|
||||
await page.waitForURL(new RegExp(`/schools/${town.slug}$`));
|
||||
await expect(page.locator('h1')).toContainText(town.name, { ignoreCase: true });
|
||||
});
|
||||
|
||||
for (const [kind, prefix, article] of [
|
||||
['town', '/schools/', 'a'],
|
||||
['authority', '/schools/authority/', 'an'],
|
||||
@@ -2369,11 +2472,20 @@ test('with autosuggest off, the search box is a plain input', async ({ page }) =
|
||||
|
||||
// ── Destination measures ───────────────────────────────────────────────────
|
||||
//
|
||||
// These journeys need marts.fact_ks4_destinations to be populated, which only
|
||||
// happens after the annual EES DAG runs. Until then the helper below fails the
|
||||
// suite loudly rather than skipping: a silent skip here would let a genuine
|
||||
// regression in the sections ride along unnoticed, which is exactly what the
|
||||
// distance journeys were changed to avoid.
|
||||
// Two failure modes have to be told apart here, and conflating them is how
|
||||
// this suite would either hide a regression or block the promotion pipeline:
|
||||
//
|
||||
// * the backend does not serve the `destinations` field at all — a code
|
||||
// regression, or a deploy that did not land. FAILS.
|
||||
// * the field is served but every school is empty — the annual EES DAG has
|
||||
// not run on this environment yet. SKIPS, loudly.
|
||||
//
|
||||
// The second is a data-load precondition, not a defect, and it is true for
|
||||
// every commit between this merging and the DAG being triggered. Failing on it
|
||||
// would redden the staging gate for unrelated work. This is not the quiet skip
|
||||
// 4f01fbd removed from the distance journeys: that one hid a broken feature
|
||||
// behind a flag check, whereas the assertion that the code is deployed and
|
||||
// correctly shaped still runs here on every commit.
|
||||
|
||||
async function secondaryWithDestinations(page: Page): Promise<{
|
||||
urn: string; destinations: any;
|
||||
@@ -2385,17 +2497,28 @@ async function secondaryWithDestinations(page: Page): Promise<{
|
||||
.filter((s: { phase?: string; attainment_8_score?: number | null }) =>
|
||||
s.phase === 'Secondary' && s.attainment_8_score != null)
|
||||
.map((s: { urn: number }) => String(s.urn));
|
||||
expect(urns.length).toBeGreaterThan(0);
|
||||
|
||||
let served = false;
|
||||
for (const urn of urns.slice(0, 25)) {
|
||||
const detail = await page.request.get(`/api/schools/${urn}`);
|
||||
if (!detail.ok()) continue;
|
||||
const data = await detail.json();
|
||||
// The key must exist, even as null. Its absence means the backend in front
|
||||
// of us does not know about destinations at all.
|
||||
if ('destinations' in data) served = true;
|
||||
if (data.destinations?.ks4) return { urn, destinations: data.destinations };
|
||||
}
|
||||
throw new Error(
|
||||
'No secondary school returned a destinations block. Either the annual EES '
|
||||
+ 'DAG has not run on this environment, or the destinations marts are empty.',
|
||||
);
|
||||
|
||||
expect(served,
|
||||
'GET /api/schools/{urn} served no `destinations` key at all — the backend '
|
||||
+ 'is missing this feature, not merely missing its data').toBeTruthy();
|
||||
|
||||
test.skip(true,
|
||||
'No school has destination data yet: the annual EES DAG has not run on '
|
||||
+ 'this environment. The API shape is correct, so this is a data-load '
|
||||
+ 'precondition rather than a regression.');
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
|
||||
test('a secondary school page says where its Year 11 leavers went', async ({ page }) => {
|
||||
@@ -2488,3 +2611,126 @@ test('the destinations section never claims a pupil stayed at this school', asyn
|
||||
const text = (await section.textContent()) ?? '';
|
||||
expect(text).not.toMatch(/stayed on (here|at this school)/i);
|
||||
});
|
||||
|
||||
/**
|
||||
* The About page and the blog exist to give the site a named human author.
|
||||
* These journeys assert the load-bearing parts of that — a name, a face, the
|
||||
* honesty claim, and a resolvable Person entity — rather than exact copy,
|
||||
* which will be edited.
|
||||
*
|
||||
* Both are behind flags (about_page, blog), so each has a lit journey and a
|
||||
* dark one. Flag state is read from the observable effect rather than from
|
||||
* /api/flags, which the public proxy denies on purpose — the same approach
|
||||
* distanceFeatureIsOn() takes above.
|
||||
*/
|
||||
async function aboutPageIsOn(page: Page): Promise<boolean> {
|
||||
return (await page.request.get('/about')).ok();
|
||||
}
|
||||
|
||||
async function blogIsOn(page: Page): Promise<boolean> {
|
||||
return (await page.request.get('/blog')).ok();
|
||||
}
|
||||
|
||||
test('with the about page off, it is absent rather than empty', async ({ page }) => {
|
||||
test.skip(await aboutPageIsOn(page), 'the about_page flag is on in this environment');
|
||||
|
||||
// Dark means the URL does not exist, not that it renders empty: a 404 is
|
||||
// what stops a crawler keeping the page in its index.
|
||||
expect((await page.request.get('/about')).status()).toBe(404);
|
||||
|
||||
// A footer link into a 404 is the failure this flag has to avoid.
|
||||
await page.goto('/');
|
||||
await expect(page.locator('footer a[href="/about"]')).toHaveCount(0);
|
||||
|
||||
// And a sitemap must never advertise a URL that 404s.
|
||||
const sitemap = await page.request.get('/content-sitemap.xml');
|
||||
expect(await sitemap.text()).not.toContain('/about');
|
||||
});
|
||||
|
||||
test('with the blog off, it is absent rather than empty', async ({ page }) => {
|
||||
test.skip(await blogIsOn(page), 'the blog flag is on in this environment');
|
||||
|
||||
expect((await page.request.get('/blog')).status()).toBe(404);
|
||||
expect((await page.request.get('/blog/rss.xml')).status()).toBe(404);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('footer a[href="/blog"]')).toHaveCount(0);
|
||||
|
||||
const sitemap = await page.request.get('/content-sitemap.xml');
|
||||
expect(await sitemap.text()).not.toContain('/blog');
|
||||
|
||||
// The admin panel is deliberately NOT flagged: posts have to be writable
|
||||
// before the blog is readable, or there is nothing to turn on.
|
||||
expect((await page.request.get('/admin')).status()).not.toBe(404);
|
||||
});
|
||||
|
||||
test('the about page names a human author and is reachable from the footer', async ({ page }) => {
|
||||
test.skip(!(await aboutPageIsOn(page)), 'the about_page flag is off in this environment');
|
||||
|
||||
await page.goto('/');
|
||||
const aboutLink = page.locator('footer a[href="/about"]');
|
||||
await expect(aboutLink).toBeVisible();
|
||||
await aboutLink.click();
|
||||
await page.waitForURL(/\/about$/);
|
||||
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText('Tudor');
|
||||
await expect(page.locator('img[alt*="Tudor"]')).toBeVisible();
|
||||
|
||||
// The credibility claim is lived experience plus stated provenance, not
|
||||
// expertise. If this sentence ever disappears the positioning has drifted.
|
||||
await expect(page.getByText(/not an education expert/i)).toBeVisible();
|
||||
|
||||
const jsonLd = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.first()
|
||||
.textContent();
|
||||
expect(jsonLd).toContain('"Person"');
|
||||
// First name only — a surname here would be the one place it leaks.
|
||||
expect(jsonLd).not.toMatch(/familyName/);
|
||||
});
|
||||
|
||||
test('the blog lists posts and each one renders with a byline', async ({ page }) => {
|
||||
test.skip(!(await blogIsOn(page)), 'the blog flag is off in this environment');
|
||||
|
||||
await page.goto('/blog');
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
|
||||
const postLinks = page.locator('a[href^="/blog/"]');
|
||||
// Data invariant: staging must carry at least one published post. If this
|
||||
// fails, the environment has no content rather than the code being broken.
|
||||
expect(await postLinks.count()).toBeGreaterThan(0);
|
||||
|
||||
await postLinks.first().click();
|
||||
await page.waitForURL(/\/blog\/.+/);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await expect(page.getByText(/^By Tudor/)).toBeVisible();
|
||||
|
||||
const jsonLd = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.first()
|
||||
.textContent();
|
||||
expect(jsonLd).toContain('"BlogPosting"');
|
||||
});
|
||||
|
||||
test('the admin panel is not indexable', async ({ page }) => {
|
||||
const response = await page.request.get('/admin');
|
||||
expect(response.headers()['x-robots-tag']).toContain('noindex');
|
||||
});
|
||||
|
||||
test('the content sitemap lists the about page and is advertised in robots', async ({ page }) => {
|
||||
const sitemap = await page.request.get('/content-sitemap.xml');
|
||||
// Served whatever the flags say: robots.txt names it unconditionally, and
|
||||
// with both dark it is a valid empty urlset rather than a 404.
|
||||
expect(sitemap.ok()).toBeTruthy();
|
||||
|
||||
if (await aboutPageIsOn(page)) {
|
||||
expect(await sitemap.text()).toContain('/about');
|
||||
}
|
||||
|
||||
// The school corpus sitemap is proxied from FastAPI; this one is Next's.
|
||||
// robots.txt must advertise both or the blog never gets discovered.
|
||||
const robots = await page.request.get('/robots.txt');
|
||||
const body = await robots.text();
|
||||
expect(body).toContain('/sitemap.xml');
|
||||
expect(body).toContain('/content-sitemap.xml');
|
||||
});
|
||||
@@ -39,3 +39,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -53,6 +53,13 @@ COPY --from=builder /app/.next/static ./.next/static
|
||||
# a miss here is a silent 500 on /opengraph-image, not a build failure.
|
||||
COPY --from=builder /app/assets ./assets
|
||||
|
||||
# Payload writes uploads here, and the compose file mounts a named volume over
|
||||
# it. The directory must exist and be owned by the runtime user BEFORE the
|
||||
# mount: Docker seeds a fresh named volume from the image path, so a missing or
|
||||
# root-owned directory here makes every upload fail with EACCES at runtime,
|
||||
# long after the build passed. The chown below covers it.
|
||||
RUN mkdir -p /app/media
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// environment provides — under jsdom this suite fails on import, not on an
|
||||
// assertion.
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GET } from '@/app/api/[...path]/route';
|
||||
import { GET } from '@/app/(frontend)/api/[...path]/route';
|
||||
|
||||
function request(path: string) {
|
||||
return new NextRequest(`http://localhost:3000/api/${path}`);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { metadata } from '@/app/(frontend)/about/page';
|
||||
import { personJsonLd, organizationJsonLd } from '@/lib/jsonld';
|
||||
|
||||
describe('/about metadata', () => {
|
||||
it('canonicalises to the bare path', () => {
|
||||
expect(metadata.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/about');
|
||||
});
|
||||
});
|
||||
|
||||
describe('author structured data', () => {
|
||||
it('describes a Person with a first name and a photo', () => {
|
||||
const person = personJsonLd();
|
||||
expect(person['@type']).toBe('Person');
|
||||
expect(person.name).toBe('Tudor');
|
||||
expect(person.image).toBe('https://www.schoolcompare.co.uk/brand/tudor.jpg');
|
||||
expect(person.url).toBe('https://www.schoolcompare.co.uk/about');
|
||||
});
|
||||
|
||||
it('never publishes a surname or an employer', () => {
|
||||
// Author identity constraint: first name only. A surname here would be
|
||||
// the one place it leaks, since JSON-LD is machine-read and archived.
|
||||
const serialised = JSON.stringify(personJsonLd());
|
||||
expect(serialised).not.toMatch(/familyName|Sitaru/i);
|
||||
expect(serialised).not.toMatch(/worksFor|affiliation/i);
|
||||
});
|
||||
|
||||
it('describes the site as an Organization the Person authors for', () => {
|
||||
const org = organizationJsonLd();
|
||||
expect(org['@type']).toBe('Organization');
|
||||
expect(org.name).toBe('schoolcompare');
|
||||
expect(org.url).toBe('https://www.schoolcompare.co.uk');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The blog index imports getCachedPayload, which pulls in Payload — ESM-only,
|
||||
* and next/jest will not transform node_modules. Mocking that one module keeps
|
||||
* the page's metadata testable without loading the CMS; the mock is never
|
||||
* called, because `metadata` is a static export evaluated at import time.
|
||||
*/
|
||||
jest.mock('@/lib/payload', () => ({ getCachedPayload: jest.fn() }));
|
||||
|
||||
import { metadata } from '@/app/(frontend)/blog/page';
|
||||
import { blogPostingJsonLd, breadcrumbJsonLd } from '@/lib/jsonld';
|
||||
|
||||
const post = {
|
||||
title: 'What the data cannot tell you',
|
||||
slug: 'what-the-data-cannot-tell-you',
|
||||
excerpt: 'Results describe one year group on a handful of days.',
|
||||
publishedAt: '2026-09-15T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('/blog metadata', () => {
|
||||
it('canonicalises to the bare path', () => {
|
||||
expect(metadata.alternates?.canonical)
|
||||
.toBe('https://www.schoolcompare.co.uk/blog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlogPosting structured data', () => {
|
||||
it('names the same Person entity the about page declares', () => {
|
||||
// By @id, not by repeating the person: search engines must resolve every
|
||||
// post and the about page to one author entity, or the site has several.
|
||||
const ld = blogPostingJsonLd(post, { namedAuthor: true });
|
||||
expect(ld['@type']).toBe('BlogPosting');
|
||||
expect(ld.author['@id']).toBe('https://www.schoolcompare.co.uk/about#tudor');
|
||||
expect(ld.publisher['@id']).toBe('https://www.schoolcompare.co.uk#organization');
|
||||
});
|
||||
|
||||
it('attributes to the organization when the about page is dark', () => {
|
||||
/*
|
||||
* The two flags are independent, so blog-on-about-off is a reachable
|
||||
* state. The Person entity lives at /about#tudor and that URL 404s while
|
||||
* the flag is dark, so claiming it would declare an author that resolves
|
||||
* to nothing — worse for the blog's credibility than having no named
|
||||
* author at all. Attribute to the publisher instead.
|
||||
*/
|
||||
const ld = blogPostingJsonLd(post, { namedAuthor: false });
|
||||
expect(ld.author['@id']).toBe('https://www.schoolcompare.co.uk#organization');
|
||||
expect(JSON.stringify(ld)).not.toContain('/about');
|
||||
});
|
||||
|
||||
it('carries a self-referencing canonical url and the publish date', () => {
|
||||
const ld = blogPostingJsonLd(post, { namedAuthor: true });
|
||||
expect(ld.url).toBe(
|
||||
'https://www.schoolcompare.co.uk/blog/what-the-data-cannot-tell-you',
|
||||
);
|
||||
expect(ld.datePublished).toBe('2026-09-15T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('breadcrumbs', () => {
|
||||
it('places the post under the blog index', () => {
|
||||
const ld = breadcrumbJsonLd(post);
|
||||
expect(ld.itemListElement[0].item).toBe('https://www.schoolcompare.co.uk/blog');
|
||||
expect(ld.itemListElement[1].item).toBe(
|
||||
'https://www.schoolcompare.co.uk/blog/what-the-data-cannot-tell-you',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { metadata as homeMetadata } from '@/app/page';
|
||||
import { metadata as rankingsMetadata } from '@/app/rankings/page';
|
||||
import { metadata as admissionsMetadata } from '@/app/admissions/page';
|
||||
import { generateMetadata as compareMetadata } from '@/app/compare/page';
|
||||
import { metadata as homeMetadata } from '@/app/(frontend)/page';
|
||||
import { metadata as rankingsMetadata } from '@/app/(frontend)/rankings/page';
|
||||
import { metadata as admissionsMetadata } from '@/app/(frontend)/admissions/page';
|
||||
import { generateMetadata as compareMetadata } from '@/app/(frontend)/compare/page';
|
||||
import { metadata as rootMetadata } from '@/app/(frontend)/layout';
|
||||
|
||||
describe('canonical URLs', () => {
|
||||
it('the homepage canonicalises to the bare root', () => {
|
||||
@@ -128,3 +129,41 @@ describe('C1 snippet copy', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The share card must be declared, not inherited.
|
||||
*
|
||||
* `app/opengraph-image.tsx` is a metadata file convention, and it does attach
|
||||
* to routes in the app root segment — `_not-found` gets an og:image from it.
|
||||
* It does NOT attach to the site's pages, which live in the `(frontend)`
|
||||
* route group whose own layout is a root layout. Staging served og:title,
|
||||
* og:description, og:url, og:site_name and og:type and no og:image at all,
|
||||
* so every link pasted into a chat rendered bare.
|
||||
*
|
||||
* The file stays at the app root, because /robots.txt and /icon.png depend on
|
||||
* it being there. The site's root layout points at the route it generates.
|
||||
*/
|
||||
describe('the share card', () => {
|
||||
it('declares an opengraph image on the site root layout', () => {
|
||||
// No og:image means every link pasted into a chat renders bare.
|
||||
const images = rootMetadata.openGraph?.images;
|
||||
expect(images).toBeTruthy();
|
||||
expect(JSON.stringify(images)).toContain('/opengraph-image');
|
||||
});
|
||||
|
||||
it('declares a twitter image too', () => {
|
||||
// twitter.card is summary_large_image. Claiming a large-image card and
|
||||
// supplying no image is worse than claiming a summary card.
|
||||
// Metadata['twitter'] is a union and `card` is not on every member, so
|
||||
// this reads the serialised shape rather than narrowing the type.
|
||||
const twitter = JSON.stringify(rootMetadata.twitter);
|
||||
expect(twitter).toContain('summary_large_image');
|
||||
expect(twitter).toContain('/opengraph-image');
|
||||
});
|
||||
|
||||
it('resolves the card to an absolute url via metadataBase', () => {
|
||||
// The e2e journey does `new URL(ogUrl)`, which throws on a relative path.
|
||||
expect(rootMetadata.metadataBase?.toString())
|
||||
.toBe('https://www.schoolcompare.co.uk/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* next.config.mjs carries the staging noindex rule. Breaking it turns
|
||||
* stx.schoolcompare.co.uk into a fully crawlable duplicate of production,
|
||||
* and nothing else in the suite would notice.
|
||||
*
|
||||
* The non-null assertions are deliberate: every key asserted here is optional
|
||||
* on NextConfig, and a missing one is precisely the regression under test, so
|
||||
* the assertion below should fail the test rather than the compile.
|
||||
*/
|
||||
import nextConfig from '@/next.config.mjs';
|
||||
|
||||
async function headerRules() {
|
||||
return nextConfig.headers!();
|
||||
}
|
||||
|
||||
describe('next.config.mjs', () => {
|
||||
it('keeps the staging host out of the index', async () => {
|
||||
const headers = await headerRules();
|
||||
const stagingRule = headers.find((rule) =>
|
||||
rule.has?.some(
|
||||
(cond) => cond.type === 'host' && cond.value === 'stx.schoolcompare.co.uk',
|
||||
),
|
||||
);
|
||||
expect(stagingRule).toBeDefined();
|
||||
expect(stagingRule!.headers).toContainEqual({
|
||||
key: 'X-Robots-Tag',
|
||||
value: 'noindex, nofollow',
|
||||
});
|
||||
});
|
||||
|
||||
it('still emits standalone output for the Docker runner', () => {
|
||||
expect(nextConfig.output).toBe('standalone');
|
||||
});
|
||||
|
||||
it('still traces the share-card fonts into the standalone bundle', () => {
|
||||
expect(nextConfig.outputFileTracingIncludes!['/opengraph-image']).toEqual([
|
||||
'./assets/**',
|
||||
]);
|
||||
});
|
||||
|
||||
it('still allows the analytics subdomain to frame the site', async () => {
|
||||
const headers = await headerRules();
|
||||
const csp = headers
|
||||
.flatMap((rule) => rule.headers)
|
||||
.find((header) => header.key === 'Content-Security-Policy');
|
||||
expect(csp).toBeDefined();
|
||||
expect(csp!.value).toContain('https://analytics.schoolcompare.co.uk');
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin surface', () => {
|
||||
it('serves noindex on the admin panel and the CMS API', async () => {
|
||||
// robots.txt disallows these too, but a Disallow only blocks crawling — a
|
||||
// URL found from an external link can still be indexed without ever being
|
||||
// fetched. This header is what actually keeps them out.
|
||||
const headers = await headerRules();
|
||||
for (const source of ['/admin/:path*', '/cms-api/:path*']) {
|
||||
const rule = headers.find((entry) => entry.source === source);
|
||||
expect(rule).toBeDefined();
|
||||
expect(rule!.headers).toContainEqual({
|
||||
key: 'X-Robots-Tag',
|
||||
value: 'noindex, nofollow',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateMetadata as placeMeta } from '@/app/schools/[place]/page';
|
||||
import { generateMetadata as placeMeta } from '@/app/(frontend)/schools/[place]/page';
|
||||
|
||||
jest.mock('@/lib/places', () => ({
|
||||
...jest.requireActual('@/lib/places'),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import robots from '@/app/robots';
|
||||
|
||||
describe('robots.txt', () => {
|
||||
it('disallows the admin panel and the CMS API', () => {
|
||||
const rules = robots().rules;
|
||||
const rule = Array.isArray(rules) ? rules[0] : rules;
|
||||
expect(rule.disallow).toEqual(
|
||||
expect.arrayContaining(['/api/', '/_next/', '/admin/', '/cms-api/']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sitemap discovery', () => {
|
||||
it('lists both the proxied school sitemap and the Next-owned content sitemap', () => {
|
||||
expect(robots().sitemap).toEqual([
|
||||
'https://www.schoolcompare.co.uk/sitemap.xml',
|
||||
'https://www.schoolcompare.co.uk/content-sitemap.xml',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ const ALL_PUBLISHED = [
|
||||
|
||||
const fullPhase: DestinationPhase = {
|
||||
cohort_year: '2022/23',
|
||||
groups: { all: { cohort: 180, categories: ALL_PUBLISHED, aggregates: {} } },
|
||||
groups: { all: { cohort: 180, categories: ALL_PUBLISHED } },
|
||||
};
|
||||
|
||||
const suppressedPhase: DestinationPhase = {
|
||||
@@ -36,7 +36,6 @@ const suppressedPhase: DestinationPhase = {
|
||||
cell('apprenticeship', 8), cell('employment', 6),
|
||||
cell('not_sustained', 5), cell('not_captured', 4),
|
||||
],
|
||||
aggregates: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -90,3 +89,61 @@ describe('DestinationsSection', () => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the detail table keeps the three statuses apart', () => {
|
||||
// 'suppressed' and 'not_applicable' are different claims, and the mart, the
|
||||
// SQLAlchemy model and the serialiser all preserve the difference. The table
|
||||
// used to key its Share column off `percentage === null`, which is true for
|
||||
// both, so a category that simply does not apply was labelled "withheld" —
|
||||
// while the Pupils column beside it rendered blank.
|
||||
const mixedPhase: DestinationPhase = {
|
||||
cohort_year: '2022/23',
|
||||
groups: {
|
||||
all: {
|
||||
cohort: 180,
|
||||
categories: [
|
||||
cell('school_sixth_form', 75),
|
||||
cell('sixth_form_college', null, 'suppressed'),
|
||||
cell('further_education', null, 'suppressed'),
|
||||
cell('apprenticeship', null, 'not_applicable'),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const rowFor = (container: HTMLElement, category: string) =>
|
||||
Array.from(container.querySelectorAll('tbody tr'))
|
||||
.find(tr => tr.textContent?.includes(category));
|
||||
|
||||
it('never labels a not-applicable category as withheld', () => {
|
||||
const { container } = render(<DestinationsSection destinations={mixedPhase} />);
|
||||
const row = rowFor(container, 'Apprenticeship');
|
||||
expect(row).toBeTruthy();
|
||||
expect(row!.textContent).not.toMatch(/withheld/i);
|
||||
});
|
||||
|
||||
it('labels a genuinely suppressed category as withheld in both columns', () => {
|
||||
const { container } = render(<DestinationsSection destinations={mixedPhase} />);
|
||||
const row = rowFor(container, 'Sixth-form college');
|
||||
expect(row).toBeTruthy();
|
||||
expect(row!.querySelectorAll('td')).toHaveLength(2);
|
||||
Array.from(row!.querySelectorAll('td')).forEach(td =>
|
||||
expect(td.textContent).toMatch(/withheld/i));
|
||||
});
|
||||
|
||||
it('the two columns of a row never disagree about what the row is', () => {
|
||||
const { container } = render(<DestinationsSection destinations={mixedPhase} />);
|
||||
Array.from(container.querySelectorAll('tbody tr')).forEach(tr => {
|
||||
const cells = Array.from(tr.querySelectorAll('td'))
|
||||
.map(td => /withheld/i.test(td.textContent ?? ''));
|
||||
expect(new Set(cells).size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a published category its real figures', () => {
|
||||
const { container } = render(<DestinationsSection destinations={mixedPhase} />);
|
||||
const row = rowFor(container, 'State-funded school sixth form');
|
||||
expect(row!.textContent).toMatch(/75/);
|
||||
expect(row!.textContent).toMatch(/42%/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The footer is the only navigational route to /about and /blog, so it is
|
||||
* where a dark flag would otherwise leave a link into a 404.
|
||||
*
|
||||
* Both props default to false. A caller that forgets to pass them hides the
|
||||
* links, which is the direction that cannot break a page — the same reasoning
|
||||
* as backend/flags.py's "every flag defaults to False".
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Footer } from '@/components/Footer';
|
||||
|
||||
describe('footer feature links', () => {
|
||||
it('links to both when both flags are on', () => {
|
||||
render(<Footer aboutEnabled blogEnabled />);
|
||||
expect(screen.getByRole('link', { name: /who's behind this/i }))
|
||||
.toHaveAttribute('href', '/about');
|
||||
expect(screen.getByRole('link', { name: /^blog$/i }))
|
||||
.toHaveAttribute('href', '/blog');
|
||||
});
|
||||
|
||||
it('omits the about link when that flag is dark', () => {
|
||||
render(<Footer blogEnabled />);
|
||||
expect(screen.queryByRole('link', { name: /who's behind this/i })).toBeNull();
|
||||
expect(screen.getByRole('link', { name: /^blog$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the blog link when that flag is dark', () => {
|
||||
render(<Footer aboutEnabled />);
|
||||
expect(screen.queryByRole('link', { name: /^blog$/i })).toBeNull();
|
||||
expect(screen.getByRole('link', { name: /who's behind this/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops the whole section when both are dark, not an empty heading', () => {
|
||||
// Shipping dark means the footer renders as it did before the feature
|
||||
// existed, not as a section with its contents removed.
|
||||
render(<Footer />);
|
||||
expect(screen.queryByRole('heading', { name: /^about$/i })).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: /who's behind this/i })).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: /^blog$/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults to dark when a caller passes nothing', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.queryByRole('link', { name: /who's behind this/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The module that ends the stranding: before it, a school page's only anchor
|
||||
* pointed at the school's own website, so ~27k pages sent authority off-site
|
||||
* and none of it reached the location layer.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { NearbyPlaces } from '@/components/school/NearbyPlaces';
|
||||
|
||||
const essex = { kind: 'authority', slug: 'essex', name: 'Essex', count: 480, url: '/schools/authority/essex', phases: [] };
|
||||
const brentwood = { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 37, url: '/schools/brentwood', phases: [] };
|
||||
const cm15 = { kind: 'outcode', slug: 'cm15', name: 'CM15', count: 12, url: '/schools/near/cm15', phases: [] };
|
||||
|
||||
describe('NearbyPlaces', () => {
|
||||
it('links to every place the school belongs to', () => {
|
||||
render(<NearbyPlaces places={[essex, brentwood, cm15]} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: /Brentwood/ }))
|
||||
.toHaveAttribute('href', '/schools/brentwood');
|
||||
expect(screen.getByRole('link', { name: /Essex/ }))
|
||||
.toHaveAttribute('href', '/schools/authority/essex');
|
||||
expect(screen.getByRole('link', { name: /CM15/ }))
|
||||
.toHaveAttribute('href', '/schools/near/cm15');
|
||||
});
|
||||
|
||||
it('says how many schools each link leads to', () => {
|
||||
// An anchor that states its destination's size is worth more to a reader
|
||||
// and to a crawler than "see more".
|
||||
render(<NearbyPlaces places={[brentwood]} />);
|
||||
expect(screen.getByRole('link', { name: /37 schools in Brentwood/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing at all when the school has no published places', () => {
|
||||
// Not an empty heading. A school whose town and authority both fall below
|
||||
// the threshold has nowhere to point, and the page should look as it did
|
||||
// before the module existed.
|
||||
const { container } = render(<NearbyPlaces places={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('puts the narrowest place first, which is the most useful link', () => {
|
||||
// The API orders widest-first for the breadcrumb; a reader on a school
|
||||
// page wants its town before its county.
|
||||
render(<NearbyPlaces places={[essex, brentwood, cm15]} />);
|
||||
const hrefs = screen.getAllByRole('link').map((a) => a.getAttribute('href'));
|
||||
expect(hrefs.indexOf('/schools/brentwood'))
|
||||
.toBeLessThan(hrefs.indexOf('/schools/authority/essex'));
|
||||
});
|
||||
|
||||
it('handles a singular count without saying "1 schools"', () => {
|
||||
render(<NearbyPlaces places={[{ ...brentwood, count: 1 }]} />);
|
||||
expect(screen.getByRole('link', { name: /1 school in Brentwood/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links the phase page the school appears on', () => {
|
||||
// "primary schools in brentwood" is the query these pages exist for.
|
||||
render(<NearbyPlaces places={[{
|
||||
...brentwood,
|
||||
phases: [{ phase: 'primary', count: 22, url: '/schools/brentwood/primary' }],
|
||||
}]} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: /22 primary schools in Brentwood/ }))
|
||||
.toHaveAttribute('href', '/schools/brentwood/primary');
|
||||
});
|
||||
|
||||
it('links both phase pages for an all-through school', () => {
|
||||
render(<NearbyPlaces places={[{
|
||||
...brentwood,
|
||||
phases: [
|
||||
{ phase: 'primary', count: 22, url: '/schools/brentwood/primary' },
|
||||
{ phase: 'secondary', count: 9, url: '/schools/brentwood/secondary' },
|
||||
],
|
||||
}]} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: /22 primary schools/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /9 secondary schools/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a phase link next to the place it belongs to', () => {
|
||||
// Grouping matters: "22 primary schools in Brentwood" directly after
|
||||
// "37 schools in Brentwood" reads as one place, not two unrelated links.
|
||||
render(<NearbyPlaces places={[essex, {
|
||||
...brentwood,
|
||||
phases: [{ phase: 'primary', count: 22, url: '/schools/brentwood/primary' }],
|
||||
}]} />);
|
||||
|
||||
const hrefs = screen.getAllByRole('link').map((a) => a.getAttribute('href'));
|
||||
expect(hrefs.indexOf('/schools/brentwood/primary'))
|
||||
.toBe(hrefs.indexOf('/schools/brentwood') + 1);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ const phase: DestinationPhase = {
|
||||
{ category: 'employment', pupils: 13, percentage: 13.5, status: 'published' },
|
||||
{ category: 'not_sustained', pupils: 6, percentage: 6.3, status: 'published' },
|
||||
],
|
||||
aggregates: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The trail has to be written by something, and it has to be written on every
|
||||
* route — not only the ones that happen to track an event.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
const recordVisitedPath = jest.fn();
|
||||
let pathname = '/schools/brentwood';
|
||||
|
||||
jest.mock('next/navigation', () => ({ usePathname: () => pathname }));
|
||||
jest.mock('@/lib/analytics', () => ({
|
||||
recordVisitedPath: (p: string) => recordVisitedPath(p),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { RouteTrail } = require('@/components/RouteTrail');
|
||||
|
||||
describe('RouteTrail', () => {
|
||||
beforeEach(() => recordVisitedPath.mockClear());
|
||||
|
||||
it('records the page it is mounted on', () => {
|
||||
render(<RouteTrail />);
|
||||
expect(recordVisitedPath).toHaveBeenCalledWith('/schools/brentwood');
|
||||
});
|
||||
|
||||
it('records each new route as the user moves through the app', () => {
|
||||
const { rerender } = render(<RouteTrail />);
|
||||
pathname = '/school/115429-brentwood-school';
|
||||
rerender(<RouteTrail />);
|
||||
expect(recordVisitedPath).toHaveBeenLastCalledWith(
|
||||
'/school/115429-brentwood-school');
|
||||
});
|
||||
|
||||
it('renders nothing, so it can sit anywhere in the layout', () => {
|
||||
const { container } = render(<RouteTrail />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -32,10 +32,17 @@ function stylesheets(dir: string): string[] {
|
||||
}
|
||||
|
||||
/** Innermost `selector { body }` pairs. Nested at-rules never match as rules,
|
||||
* because their body contains braces. */
|
||||
* because their body contains braces.
|
||||
*
|
||||
* Comments are stripped before matching rather than after, so that the whole
|
||||
* selector survives. Taking only its last line — which is what stripping a
|
||||
* leading comment used to require — silently discarded every selector in a
|
||||
* grouped rule but the final one, and a safety guard that cannot see half its
|
||||
* input fails open. */
|
||||
function rules(css: string): Array<{ selector: string; body: string }> {
|
||||
return Array.from(css.matchAll(/([^{}]+)\{([^{}]*)\}/g), (m) => ({
|
||||
selector: m[1].trim().split('\n').pop()!.trim(),
|
||||
const bare = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
return Array.from(bare.matchAll(/([^{}]+)\{([^{}]*)\}/g), (m) => ({
|
||||
selector: m[1].trim().replace(/\s*\n\s*/g, ' '),
|
||||
body: m[2],
|
||||
}));
|
||||
}
|
||||
@@ -45,6 +52,15 @@ const THEMED_COLOR = /(?:^|[^-])color:\s*var\(--/;
|
||||
|
||||
const files = stylesheets(COMPONENTS);
|
||||
|
||||
/** Component sources, for the third-party-surface rule below. */
|
||||
function sources(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return sources(full);
|
||||
return entry.name.endsWith('.tsx') ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('dark-theme safety', () => {
|
||||
it('finds stylesheets to check', () => {
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
@@ -77,6 +93,76 @@ describe('dark-theme safety', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The same defect one stylesheet further out.
|
||||
*
|
||||
* The rules above scan our own CSS modules. They cannot see a surface painted
|
||||
* by a third-party sheet: leaflet.css hardcodes `background: white` on
|
||||
* `.leaflet-popup-content-wrapper` and `.leaflet-popup-tip`, and
|
||||
* LeafletMapInner builds its popup as an HTML string with inline
|
||||
* `color: var(--text-primary)`. Neither half lives in a .module.css, so the
|
||||
* module scan passed while dark mode rendered #E9EEF0 on #FFFFFF — 1.17:1,
|
||||
* with the school name and the headline figure effectively invisible.
|
||||
*
|
||||
* globals.css already pulls the rest of Leaflet's chrome onto the tokens (the
|
||||
* attribution bar, the zoom controls) for exactly this reason. The popup was
|
||||
* simply missed.
|
||||
*/
|
||||
describe('third-party surfaces under themed text', () => {
|
||||
const GLOBALS = path.join(__dirname, '..', '..', 'app', '(frontend)', 'globals.css');
|
||||
|
||||
/** Leaflet surfaces our own code writes token-coloured text onto. */
|
||||
const LEAFLET_POPUP_SURFACES = [
|
||||
'.leaflet-popup-content-wrapper',
|
||||
'.leaflet-popup-tip',
|
||||
];
|
||||
|
||||
it('still finds a component painting themed text into a Leaflet popup', () => {
|
||||
// Guards the rule below against passing vacuously if the popups are ever
|
||||
// rewritten as React components rather than HTML strings.
|
||||
const themed = sources(COMPONENTS).filter((file) => {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
return /bindPopup\(/.test(src) && /color:var\(--|color: var\(--/.test(src);
|
||||
});
|
||||
|
||||
expect(themed.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('themes the Leaflet popup surface, because the text on it is themed', () => {
|
||||
const globals = rules(fs.readFileSync(GLOBALS, 'utf8'));
|
||||
|
||||
const unthemed = LEAFLET_POPUP_SURFACES.filter((surface) => {
|
||||
const rule = globals.find((r) => r.selector.includes(surface));
|
||||
return !rule || !/background[^;]*var\(--/.test(rule.body);
|
||||
});
|
||||
|
||||
// Leaflet's white is not a colour this site owns. Either the surface
|
||||
// follows the theme or the text on it must be literal — and the text is
|
||||
// already themed.
|
||||
expect(unthemed).toEqual([]);
|
||||
});
|
||||
|
||||
it('never puts a literal white label on a themed fill', () => {
|
||||
/*
|
||||
* The mirror image of the module-CSS rule above, and the half of the popup
|
||||
* that theming the card does not reach. "View Details" is
|
||||
* `background:var(--status-above);color:white`; --status-above is #36743F
|
||||
* in light but #7FCB8A in dark, so the label went from 5.63:1 to 1.94:1.
|
||||
*
|
||||
* --text-inverse is the token for ink on a saturated fill — #FFFFFF in
|
||||
* light, #111A20 in dark — and the popup's Ofsted badge already uses it.
|
||||
*/
|
||||
const offenders = sources(COMPONENTS).flatMap((file) => {
|
||||
const src = fs.readFileSync(file, 'utf8');
|
||||
return Array.from(
|
||||
src.matchAll(/background:\s*var\(--[^;"']*;[^"']*?color:\s*(white|#fff\b|#ffffff\b)/gi),
|
||||
() => path.relative(COMPONENTS, file));
|
||||
});
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Destination measures add the first new colour family since the palette was
|
||||
* set. The tokens have to exist in both blocks or the section renders one
|
||||
@@ -85,7 +171,7 @@ describe('dark-theme safety', () => {
|
||||
*/
|
||||
describe('destination tokens', () => {
|
||||
const css = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'app', 'globals.css'), 'utf8');
|
||||
path.join(__dirname, '..', '..', 'app', '(frontend)', 'globals.css'), 'utf8');
|
||||
|
||||
const TOKENS = [
|
||||
'--dest-sixthform', '--dest-sfcollege', '--dest-fecollege',
|
||||
|
||||
@@ -98,6 +98,17 @@ describe('secondary detail page', () => {
|
||||
|
||||
expect(screen.getByText(/has not published a cut-off distance/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('makes no claim about publication when the feature is switched off', () => {
|
||||
// Absent, not null. The API omits the key entirely while the
|
||||
// admission_distance flag is off, and "Islington has not published a
|
||||
// cut-off distance" is then a statement about us, not about Islington —
|
||||
// false wherever the authority does publish one.
|
||||
renderSecondarySchoolDetail({ ...secondaryFixture, admissionDistance: undefined });
|
||||
|
||||
expect(screen.queryByText(/has not published a cut-off distance/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Contact the admissions authority/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ── The Distance section ───────────────────────────────────────────────
|
||||
|
||||
@@ -62,3 +62,97 @@ describe('getNavigationSource', () => {
|
||||
expect(getNavigationSource()).toBe('direct');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* The defect the existing suite could not see.
|
||||
*
|
||||
* Every test above sets document.referrer, which the browser writes only when
|
||||
* a *document* loads. Every internal navigation in this app is an App Router
|
||||
* soft navigation — history.pushState, no new document — so document.referrer
|
||||
* keeps naming whatever opened the tab for the whole session. Verified on
|
||||
* staging: /schools/brentwood → click a school → URL changes to /school/…
|
||||
* and document.referrer is still "".
|
||||
*
|
||||
* So `from` reported 'direct' for essentially every in-app journey, and the
|
||||
* suite passed because it only ever exercised the full-page-load path.
|
||||
*/
|
||||
function freshAnalytics() {
|
||||
let mod!: typeof import('@/lib/analytics');
|
||||
jest.isolateModules(() => {
|
||||
mod = require('@/lib/analytics');
|
||||
});
|
||||
return mod;
|
||||
}
|
||||
|
||||
function at(path: string) {
|
||||
window.history.pushState({}, '', path);
|
||||
}
|
||||
|
||||
describe('getNavigationSource across a soft navigation', () => {
|
||||
afterEach(() => {
|
||||
referrer('');
|
||||
at('/');
|
||||
});
|
||||
|
||||
it('attributes a school view to the place page the user actually came from', () => {
|
||||
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
||||
at('/schools/brentwood');
|
||||
recordVisitedPath('/schools/brentwood');
|
||||
|
||||
at('/school/115429-brentwood-school');
|
||||
recordVisitedPath('/school/115429-brentwood-school');
|
||||
|
||||
expect(source()).toBe('place');
|
||||
});
|
||||
|
||||
it('does not depend on whether the new path was recorded first', () => {
|
||||
// The trail is written by a layout-level effect and read by a page-level
|
||||
// one. React orders those by tree position, which is not a contract worth
|
||||
// resting a measurement on, so the answer must be the same either way.
|
||||
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
||||
recordVisitedPath('/rankings');
|
||||
at('/school/115429-brentwood-school');
|
||||
|
||||
expect(source()).toBe('rankings');
|
||||
});
|
||||
|
||||
it('names the previous page, not the current one, when both are schools', () => {
|
||||
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
||||
at('/school/100010-brecknock-primary-school');
|
||||
recordVisitedPath('/school/100010-brecknock-primary-school');
|
||||
|
||||
at('/school/115429-brentwood-school');
|
||||
recordVisitedPath('/school/115429-brentwood-school');
|
||||
|
||||
expect(source()).toBe('detail');
|
||||
});
|
||||
|
||||
it('looks past a return visit to the page the user came back from', () => {
|
||||
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
||||
for (const p of ['/schools/brentwood', '/school/115429-brentwood-school',
|
||||
'/schools/brentwood']) {
|
||||
at(p);
|
||||
recordVisitedPath(p);
|
||||
}
|
||||
expect(source()).toBe('detail');
|
||||
});
|
||||
|
||||
it('falls back to the referrer on a real document load, where it is true', () => {
|
||||
// A fresh module is a fresh document: nothing has been recorded, and
|
||||
// document.referrer is meaningful again.
|
||||
const { getNavigationSource: source } = freshAnalytics();
|
||||
at('/school/115429-brentwood-school');
|
||||
referrer(`${ORIGIN}/schools/barnet`);
|
||||
|
||||
expect(source()).toBe('place');
|
||||
});
|
||||
|
||||
it('still reads an arrival from outside as direct', () => {
|
||||
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
||||
at('/schools/brentwood');
|
||||
recordVisitedPath('/schools/brentwood');
|
||||
referrer('https://www.google.com/search?q=schools+in+brentwood');
|
||||
|
||||
expect(source()).toBe('direct');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
canAggregate, aggregateCells, canRenderPublishedAggregate,
|
||||
canAggregate, aggregateCells,
|
||||
canRenderBar, toBarSegments, CARD_GROUPS,
|
||||
type DestinationCell, type DestinationGroup, type DestinationCategory,
|
||||
} from '@/lib/destinations';
|
||||
@@ -19,7 +19,6 @@ const fullGroup = (): DestinationGroup => ({
|
||||
pub('apprenticeship', 8, 180), pub('employment', 6, 180),
|
||||
pub('not_sustained', 5, 180), pub('not_captured', 4, 180),
|
||||
],
|
||||
aggregates: {},
|
||||
});
|
||||
|
||||
describe('canAggregate — R2, computing from components', () => {
|
||||
@@ -47,26 +46,6 @@ describe('aggregateCells', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRenderPublishedAggregate — R2, a total DfE published itself', () => {
|
||||
it('allows it when no component is suppressed', () => {
|
||||
expect(canRenderPublishedAggregate([
|
||||
pub('school_sixth_form', 75, 180), pub('sixth_form_college', 21, 180),
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
it('REFUSES it when exactly one component is suppressed — the aggregate identifies it', () => {
|
||||
expect(canRenderPublishedAggregate([
|
||||
pub('school_sixth_form', 75, 180), sup('sixth_form_college'),
|
||||
])).toBe(false);
|
||||
});
|
||||
|
||||
it('allows it when two or more components are suppressed', () => {
|
||||
expect(canRenderPublishedAggregate([
|
||||
sup('school_sixth_form'), sup('sixth_form_college'),
|
||||
])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canRenderBar — R1', () => {
|
||||
it('allows a bar when the whole group is published', () => {
|
||||
expect(canRenderBar(fullGroup())).toBe(true);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getFlags } from '@/lib/flags';
|
||||
import { getFlags, FLAGS_REVALIDATE } from '@/lib/flags';
|
||||
|
||||
// jsdom provides no global fetch, so there is nothing for jest.spyOn to attach
|
||||
// to — assign it and restore the original afterwards. This is the first test
|
||||
@@ -31,4 +31,28 @@ describe('getFlags', () => {
|
||||
mockFetch(async () => ({ ok: false, status: 503 }));
|
||||
await expect(getFlags()).resolves.toEqual({});
|
||||
});
|
||||
|
||||
/*
|
||||
* Reading a flag pins the calling route's ISR floor: Next uses the LOWEST
|
||||
* revalidate among a route's fetches for the whole route. That is why the
|
||||
* revalidate is an argument rather than the constant.
|
||||
*
|
||||
* Every SEO route here declares `revalidate = 604800`. A gate that read
|
||||
* flags at the 300s default would drop the whole school and place corpus
|
||||
* from a weekly cache to a 5-minute one, which is a large origin-load
|
||||
* regression to pay for a feature flag.
|
||||
*/
|
||||
it('reads at the 300s floor by default', async () => {
|
||||
mockFetch(async () => ({ ok: true, json: async () => ({}) }));
|
||||
await getFlags();
|
||||
expect((global.fetch as jest.Mock).mock.calls[0][1])
|
||||
.toEqual({ next: { revalidate: FLAGS_REVALIDATE } });
|
||||
});
|
||||
|
||||
it('lets a caller pass its own route floor instead', async () => {
|
||||
mockFetch(async () => ({ ok: true, json: async () => ({}) }));
|
||||
await getFlags(604800);
|
||||
expect((global.fetch as jest.Mock).mock.calls[0][1])
|
||||
.toEqual({ next: { revalidate: 604800 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* School pages had no BreadcrumbList and no links into the location layer.
|
||||
* Both are fixed by the same data — the `places` array the API now returns —
|
||||
* so they are tested together.
|
||||
*/
|
||||
import { schoolBreadcrumbJsonLd } from '@/lib/jsonld';
|
||||
|
||||
const essex = { kind: 'authority', slug: 'essex', name: 'Essex', count: 480, url: '/schools/authority/essex', phases: [] };
|
||||
const brentwood = { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 37, url: '/schools/brentwood', phases: [] };
|
||||
const outcode = { kind: 'outcode', slug: 'cm15', name: 'CM15', count: 12, url: '/schools/near/cm15', phases: [] };
|
||||
|
||||
describe('school breadcrumbs', () => {
|
||||
it('reads home to authority to town to school', () => {
|
||||
const ld = schoolBreadcrumbJsonLd({
|
||||
name: 'Brentwood School', url: '/school/100000-brentwood-school',
|
||||
places: [essex, brentwood],
|
||||
});
|
||||
|
||||
expect(ld['@type']).toBe('BreadcrumbList');
|
||||
expect(ld.itemListElement.map((i) => i.name))
|
||||
.toEqual(['schoolcompare', 'Essex', 'Brentwood', 'Brentwood School']);
|
||||
expect(ld.itemListElement.map((i) => i.position)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('skips a level the school has no published place for', () => {
|
||||
// A school whose town falls below the publish threshold has no town page.
|
||||
// The trail closes over the gap rather than linking to a 404.
|
||||
const ld = schoolBreadcrumbJsonLd({
|
||||
name: 'Lone School', url: '/school/1-lone-school', places: [essex],
|
||||
});
|
||||
|
||||
expect(ld.itemListElement.map((i) => i.name))
|
||||
.toEqual(['schoolcompare', 'Essex', 'Lone School']);
|
||||
expect(ld.itemListElement.map((i) => i.position)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('omits outcodes, which are not a place a breadcrumb reads through', () => {
|
||||
// CM15 is a useful link in the module but nonsense in a trail: nobody
|
||||
// navigates Essex → CM15 → school.
|
||||
const ld = schoolBreadcrumbJsonLd({
|
||||
name: 'Brentwood School', url: '/school/100000-brentwood-school',
|
||||
places: [essex, brentwood, outcode],
|
||||
});
|
||||
|
||||
expect(JSON.stringify(ld)).not.toContain('cm15');
|
||||
});
|
||||
|
||||
it('still produces a valid trail when the school has no places at all', () => {
|
||||
const ld = schoolBreadcrumbJsonLd({
|
||||
name: 'Orphan School', url: '/school/2-orphan-school', places: [],
|
||||
});
|
||||
|
||||
expect(ld.itemListElement.map((i) => i.name)).toEqual(['schoolcompare', 'Orphan School']);
|
||||
});
|
||||
|
||||
it('uses absolute urls, as every other entity on the site does', () => {
|
||||
const ld = schoolBreadcrumbJsonLd({
|
||||
name: 'Brentwood School', url: '/school/100000-brentwood-school',
|
||||
places: [essex, brentwood],
|
||||
});
|
||||
|
||||
for (const item of ld.itemListElement) {
|
||||
expect(item.item).toMatch(/^https:\/\/www\.schoolcompare\.co\.uk\//);
|
||||
}
|
||||
// The root is the homepage: there is no /schools index page to link to.
|
||||
expect(ld.itemListElement[0].item).toBe('https://www.schoolcompare.co.uk/');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,6 @@ const phase = (categories = 1) => ({
|
||||
category: 'school_sixth_form' as const,
|
||||
pupils: 75, percentage: 41.7, status: 'published' as const,
|
||||
})),
|
||||
aggregates: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Payload is ESM-only and next/jest will not transform it, so the collections
|
||||
* cannot be imported and their sanitised config inspected here (see
|
||||
* lib/payloadRoutes.ts for the full reasoning). These assert the source of the
|
||||
* collection definitions instead — enough to catch the settings whose loss is
|
||||
* silent, and cheap. Behaviour is proved by the e2e journeys against staging.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const read = (file: string) =>
|
||||
fs.readFileSync(path.join(__dirname, '..', '..', 'collections', file), 'utf8');
|
||||
|
||||
const POSTS = read('Posts.ts');
|
||||
const MEDIA = read('Media.ts');
|
||||
const CONFIG = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'payload.config.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('posts collection', () => {
|
||||
it('supports drafts, so saving is not publishing', () => {
|
||||
expect(POSTS).toMatch(/drafts:\s*true/);
|
||||
});
|
||||
|
||||
it('has a unique, indexed slug for stable URLs', () => {
|
||||
const slugField = POSTS.slice(POSTS.indexOf("name: 'slug'"));
|
||||
expect(slugField).toMatch(/unique:\s*true/);
|
||||
expect(slugField).toMatch(/index:\s*true/);
|
||||
});
|
||||
|
||||
it('hides drafts from anonymous readers at the access layer', () => {
|
||||
// Payload's docs are explicit: "The `draft` argument alone does not
|
||||
// restrict documents with _status: 'draft' from being returned by the
|
||||
// API." The blog pages' where-clause is not enforcement — a direct GET
|
||||
// /cms-api/posts would return unpublished drafts to anyone. Access
|
||||
// control returning a query constraint is the only thing that stops it.
|
||||
expect(POSTS).toMatch(/_status:\s*\{\s*equals:\s*'published'\s*\}/);
|
||||
expect(POSTS).toMatch(/if\s*\(req\.user\)\s*return true/);
|
||||
});
|
||||
|
||||
it('revalidates the post page when a post changes or is deleted', () => {
|
||||
// /blog/[slug] is ISR — generated on first request and cached — so an edit
|
||||
// to an already-published post would otherwise not appear until the
|
||||
// revalidate window expired, up to an hour of a writer concluding that
|
||||
// saving is broken. The index and feeds are force-dynamic and need no hook.
|
||||
expect(POSTS).toContain('afterChange');
|
||||
expect(POSTS).toContain('afterDelete');
|
||||
expect(POSTS).toMatch(/revalidatePath\(`\/blog\/\$\{[^}]+\}`\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('media collection', () => {
|
||||
it('writes uploads to the mounted volume, by absolute path', () => {
|
||||
// Must match the payload_media mount in docker-compose.portainer.yml.
|
||||
// Payload 3 requires staticDir to be absolute.
|
||||
expect(MEDIA).toMatch(/staticDir:\s*'\/app\/media'/);
|
||||
});
|
||||
|
||||
it('requires alt text on every upload', () => {
|
||||
const altField = MEDIA.slice(MEDIA.indexOf("name: 'alt'"));
|
||||
expect(altField).toMatch(/required:\s*true/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('payload config', () => {
|
||||
it('registers every collection', () => {
|
||||
expect(CONFIG).toMatch(/collections:\s*\[Users,\s*Posts,\s*Media\]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The admin panel does not import field components directly. Payload sends the
|
||||
* client a *path* for each one — a richText field's is
|
||||
* `@payloadcms/richtext-lexical/rsc#RscEntryLexicalField` — and resolves it
|
||||
* through this generated map. An entry that is missing from the map is not an
|
||||
* error the panel reports: the field simply does not render.
|
||||
*
|
||||
* That failure is quietly awful, because `required: true` is enforced on the
|
||||
* server regardless. A writer gets a new-post form with no Content editor and
|
||||
* a save that refuses on a field they were never shown.
|
||||
*
|
||||
* The map is generated by `npx payload generate:importmap`, so it drifts every
|
||||
* time a field or a lexical feature is added and nobody re-runs it. These
|
||||
* assert the entries the current config needs.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const MAP = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'app', '(payload)', 'admin', 'importMap.js'),
|
||||
'utf8',
|
||||
);
|
||||
const POSTS = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'collections', 'Posts.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('admin import map', () => {
|
||||
it('resolves the richText field, so Content renders in the editor', () => {
|
||||
// Guarded because Posts.content is required: without this entry the field
|
||||
// is invisible and the post is unsaveable.
|
||||
expect(POSTS).toMatch(/type:\s*'richText'/);
|
||||
expect(MAP).toContain('@payloadcms/richtext-lexical/rsc#RscEntryLexicalField');
|
||||
});
|
||||
|
||||
it('resolves the richText cell, so the list view can render the column', () => {
|
||||
expect(MAP).toContain('@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell');
|
||||
});
|
||||
|
||||
it('resolves the diff component, which the drafts UI needs', () => {
|
||||
// versions.drafts is on, so the panel offers version comparison.
|
||||
expect(POSTS).toMatch(/drafts:\s*true/);
|
||||
expect(MAP).toContain('@payloadcms/richtext-lexical/rsc#LexicalDiffComponent');
|
||||
});
|
||||
|
||||
it('resolves BlocksFeature, so the Callout block is insertable', () => {
|
||||
expect(POSTS).toContain('BlocksFeature');
|
||||
expect(MAP).toContain('@payloadcms/richtext-lexical/client#BlocksFeatureClient');
|
||||
});
|
||||
|
||||
it('resolves the default toolbar features the editor is built with', () => {
|
||||
// defaultFeatures is spread into the editor config; each one contributes a
|
||||
// client component the toolbar cannot render without.
|
||||
for (const feature of [
|
||||
'BoldFeatureClient',
|
||||
'ItalicFeatureClient',
|
||||
'HeadingFeatureClient',
|
||||
'LinkFeatureClient',
|
||||
'UploadFeatureClient',
|
||||
'UnorderedListFeatureClient',
|
||||
'OrderedListFeatureClient',
|
||||
'InlineToolbarFeatureClient',
|
||||
]) {
|
||||
expect(MAP).toContain(`@payloadcms/richtext-lexical/client#${feature}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The generated migration is schema-qualified to "payload" throughout but does
|
||||
* not create that schema — `schemaName` says where tables go, it does not
|
||||
* create anything. On staging and production, which have never run it, the
|
||||
* whole migration fails with `schema "payload" does not exist`.
|
||||
*
|
||||
* The CREATE SCHEMA is therefore hand-added, which makes it exactly the kind
|
||||
* of edit a regeneration silently discards. This is the guard.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DIR = path.join(__dirname, '..', '..', 'migrations');
|
||||
|
||||
function migrationFiles() {
|
||||
return fs
|
||||
.readdirSync(DIR)
|
||||
.filter((f) => f.endsWith('.ts') && f !== 'index.ts');
|
||||
}
|
||||
|
||||
describe('payload migrations', () => {
|
||||
it('ships at least one migration, so a container has tables to find', () => {
|
||||
expect(migrationFiles().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('creates the payload schema before creating anything in it', () => {
|
||||
const initial = migrationFiles().find((f) => f.includes('initial'))!;
|
||||
const sql = fs.readFileSync(path.join(DIR, initial), 'utf8');
|
||||
|
||||
expect(sql).toMatch(/CREATE SCHEMA IF NOT EXISTS "payload"/);
|
||||
|
||||
// Ordering matters: the schema must be created before the first object
|
||||
// that lives in it, or the migration fails on its first statement.
|
||||
expect(sql.indexOf('CREATE SCHEMA IF NOT EXISTS "payload"'))
|
||||
.toBeLessThan(sql.indexOf('CREATE TABLE "payload"'));
|
||||
});
|
||||
|
||||
it('creates the tables the app queries on boot', () => {
|
||||
const initial = migrationFiles().find((f) => f.includes('initial'))!;
|
||||
const sql = fs.readFileSync(path.join(DIR, initial), 'utf8');
|
||||
for (const table of ['users', 'posts', '_posts_v', 'media', 'payload_migrations']) {
|
||||
expect(sql).toContain(`CREATE TABLE "payload"."${table}"`);
|
||||
}
|
||||
});
|
||||
|
||||
it('is wired into the adapter, so it runs on server init', () => {
|
||||
const config = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'payload.config.ts'), 'utf8',
|
||||
);
|
||||
expect(config).toMatch(/prodMigrations:\s*migrations/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Guards the one thing about Payload's mounting that fails silently.
|
||||
*
|
||||
* payload.config.ts itself cannot be imported here — Payload is ESM-only and
|
||||
* next/jest will not transform it — so this asserts the shared constants and
|
||||
* that the config actually wires them in, by reading its source. The live
|
||||
* proof that /api still reaches FastAPI is the e2e journeys, which call
|
||||
* /api/schools against the running app.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { PAYLOAD_API_ROUTE, PAYLOAD_ADMIN_ROUTE } from '@/lib/payloadRoutes';
|
||||
|
||||
const CONFIG = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'payload.config.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('payload mount points', () => {
|
||||
it('serves the CMS API from /cms-api, never /api', () => {
|
||||
// /api is the FastAPI proxy's catch-all. Payload's default would be
|
||||
// swallowed by it and forwarded to the backend, silently.
|
||||
expect(PAYLOAD_API_ROUTE).toBe('/cms-api');
|
||||
expect(PAYLOAD_API_ROUTE).not.toBe('/api');
|
||||
});
|
||||
|
||||
it('serves the admin panel from /admin', () => {
|
||||
expect(PAYLOAD_ADMIN_ROUTE).toBe('/admin');
|
||||
});
|
||||
|
||||
it('wires both constants into the Payload config', () => {
|
||||
expect(CONFIG).toContain('PAYLOAD_API_ROUTE');
|
||||
expect(CONFIG).toContain('PAYLOAD_ADMIN_ROUTE');
|
||||
});
|
||||
|
||||
it('never hardcodes a routes block that could drift from the constants', () => {
|
||||
expect(CONFIG).not.toMatch(/routes:\s*\{[^}]*api:\s*['"]/);
|
||||
});
|
||||
|
||||
it('isolates CMS tables in their own postgres schema', () => {
|
||||
// Blog content must sit outside `public`, where the app tables, Airflow's
|
||||
// metadata and scripts/migrate_csv_to_db.py --drop all live.
|
||||
expect(CONFIG).toMatch(/schemaName:\s*['"]payload['"]/);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { nationalAveragesFixture } from './schoolFixtures';
|
||||
|
||||
// The shell calls useComparison(), which throws outside the provider. In the
|
||||
// app this wrapper comes from app/layout.tsx.
|
||||
// app this wrapper comes from app/(frontend)/layout.tsx.
|
||||
function withProviders(ui: ReactNode) {
|
||||
return <ComparisonProvider>{ui}</ComparisonProvider>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
.page {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.portrait {
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--brand);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 2.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
/* The opening paragraph carries the page. Larger, and in the primary ink
|
||||
rather than the secondary, so it reads as a voice rather than as body copy.
|
||||
|
||||
Must stay in the descendant form: `.prose p` scores (0,1,1) and would beat a
|
||||
bare `.lede` at (0,1,0), so simplifying this selector silently reverts the
|
||||
lede to ordinary body copy. */
|
||||
.prose .lede {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import { getFlags } from '@/lib/flags';
|
||||
import { personJsonLd, organizationJsonLd } from '@/lib/jsonld';
|
||||
import styles from './About.module.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'About',
|
||||
description:
|
||||
'Who builds schoolcompare, why it exists, and where its numbers come from.',
|
||||
alternates: { canonical: absoluteUrl('/about') },
|
||||
};
|
||||
|
||||
/*
|
||||
* Gated on about_page. The default 300s read is the right floor here: this
|
||||
* page declares no revalidate of its own, so nothing is lost by it, and a flip
|
||||
* lands within five minutes.
|
||||
*
|
||||
* notFound(), not a redirect: while the flag is dark this URL does not exist,
|
||||
* and a 404 is what tells a crawler not to keep it.
|
||||
*/
|
||||
export default async function AboutPage() {
|
||||
const flags = await getFlags();
|
||||
if (flags.about_page !== true) notFound();
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@graph': [personJsonLd(), organizationJsonLd()],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<header className={styles.header}>
|
||||
<Image
|
||||
src="/brand/tudor.jpg"
|
||||
alt="Tudor, who builds schoolcompare"
|
||||
width={96}
|
||||
height={96}
|
||||
className={styles.portrait}
|
||||
priority
|
||||
/>
|
||||
<div>
|
||||
<p className={styles.kicker}>Who's behind this</p>
|
||||
<h1 className={styles.heading}>I'm Tudor. I built this site.</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.prose}>
|
||||
<p className={styles.lede}>
|
||||
I'm a parent in south-west London. When we started looking at
|
||||
primary schools, I found the information I needed was all published,
|
||||
and almost impossible to hold in one place.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
SATs results were in one government table. Ofsted judgements were in a
|
||||
separate service, in a format that had just changed. Admissions
|
||||
distances were buried in council PDFs, a different one per borough,
|
||||
each with its own layout. I ended up building a spreadsheet, and then
|
||||
I got tired of the spreadsheet.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
So I built this instead. It pulls the official figures into one place
|
||||
and puts them side by side, which is what I wanted and could not find.
|
||||
</p>
|
||||
|
||||
<h2 className={styles.subheading}>I'm not an education expert</h2>
|
||||
|
||||
<p>
|
||||
I want to be straightforward about that. I'm not a teacher, a
|
||||
governor, or an education researcher. I have no qualification that
|
||||
makes my opinion about a school worth more than yours.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
What I do have is the problem itself. I'm going through primary
|
||||
admissions right now, and I work with data for a living. That
|
||||
combination is enough to take published figures and present them
|
||||
honestly. It is not enough to tell you which school is right for your
|
||||
child, and this site never tries to.
|
||||
</p>
|
||||
|
||||
<h2 className={styles.subheading}>Where the numbers come from</h2>
|
||||
|
||||
<p>
|
||||
Everything here is official published data: Key Stage 2 and Key Stage
|
||||
4 results and school characteristics from the Department for
|
||||
Education, inspection outcomes from Ofsted, and admissions data from
|
||||
local authorities. Nothing is estimated, modelled or filled in. Where
|
||||
a figure is missing, the page says so rather than showing a guess.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This is an independent site. It is not affiliated with the Department
|
||||
for Education or with Ofsted, and nobody pays to appear on it or to
|
||||
rank higher.
|
||||
</p>
|
||||
|
||||
<h2 className={styles.subheading}>What the data can't tell you</h2>
|
||||
|
||||
<p>
|
||||
A school is not its results. The figures here describe one year group,
|
||||
on a handful of days, measured in a way that suits national statistics
|
||||
rather than your child. A small cohort makes percentages swing wildly.
|
||||
In a class of thirty, one pupil is worth more than three points.
|
||||
Results say nothing at all about whether a child will be happy
|
||||
somewhere.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
I try to build that honesty into the site rather than just say it
|
||||
here. Special schools and pupil referral units are never compared
|
||||
against a mainstream national average, because that comparison is
|
||||
meaningless and makes good schools look like failing ones. Where a
|
||||
number is unreliable, the aim is for the page to tell you before you
|
||||
draw a conclusion from it.
|
||||
</p>
|
||||
|
||||
<h2 className={styles.subheading}>If something's wrong</h2>
|
||||
|
||||
<p>
|
||||
Tell me and I'll fix it. If a figure looks wrong, or a page gives
|
||||
a misleading impression of a school, I genuinely want to know.
|
||||
It's the fastest way this gets better.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="mailto:contact@schoolcompare.co.uk" className={styles.link}>
|
||||
contact@schoolcompare.co.uk
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,76 @@
|
||||
.page {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
}
|
||||
|
||||
.header { margin-bottom: 2.5rem; }
|
||||
|
||||
.kicker {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--brand);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.standfirst {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list { list-style: none; padding: 0; margin: 0; }
|
||||
|
||||
.item {
|
||||
padding: 1.5rem 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.date {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
/* Inter's tabular numerals keep a column of dates aligned. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 0.35rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.itemLink { color: var(--text-primary); text-decoration: none; }
|
||||
.itemLink:hover { color: var(--brand); }
|
||||
|
||||
.excerpt {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link { color: var(--brand); font-weight: 600; }
|
||||
.link:hover { color: var(--brand-strong); }
|
||||
@@ -0,0 +1,87 @@
|
||||
.page {
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.6rem, 5vw, 2.25rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.byline {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Rich-text output: the editor emits plain elements, so these are styled by
|
||||
descendant selector rather than by class. */
|
||||
.prose p {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 2.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 1.75rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
padding-left: 1.35rem;
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
.prose li { margin-bottom: 0.4rem; }
|
||||
|
||||
.prose a { color: var(--brand); font-weight: 500; }
|
||||
.prose a:hover { color: var(--brand-strong); }
|
||||
|
||||
.prose blockquote {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding-left: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.link { color: var(--brand); font-weight: 600; }
|
||||
.link:hover { color: var(--brand-strong); }
|
||||
@@ -0,0 +1,194 @@
|
||||
import { cache } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { RichText } from '@payloadcms/richtext-lexical/react';
|
||||
import type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react';
|
||||
import { getCachedPayload } from '@/lib/payload';
|
||||
import type { Post, Media } from '@/payload-types';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import { getFlags } from '@/lib/flags';
|
||||
import {
|
||||
blogPostingJsonLd,
|
||||
breadcrumbJsonLd,
|
||||
personJsonLd,
|
||||
organizationJsonLd,
|
||||
} from '@/lib/jsonld';
|
||||
import { CalloutBlock } from '@/components/blog/CalloutBlock';
|
||||
import styles from './Post.module.css';
|
||||
|
||||
/*
|
||||
* ISR. Unlike the index, this route has a dynamic param and no
|
||||
* generateStaticParams, so there is nothing for the build to prerender: each
|
||||
* post is generated on first request and cached until the collection's
|
||||
* afterChange hook revalidates it. That hook is what makes an edit to an
|
||||
* already-published post appear immediately.
|
||||
*/
|
||||
export const revalidate = 3600;
|
||||
|
||||
/**
|
||||
* heroImage is `number | Media | null`: an id when the query is shallow, the
|
||||
* populated document at depth 1. Both pages query at depth 1, but narrowing
|
||||
* rather than asserting keeps it correct if that ever changes.
|
||||
*/
|
||||
function heroOf(post: Post): Media | null {
|
||||
return typeof post.heroImage === 'object' && post.heroImage !== null
|
||||
? post.heroImage
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spreads the default converters and adds the one custom block.
|
||||
*
|
||||
* Without the spread, every default node type — paragraphs, headings, links —
|
||||
* loses its renderer and the post body comes out empty.
|
||||
*/
|
||||
const calloutConverters: JSXConvertersFunction = ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
blocks: {
|
||||
// Annotated because the generic block converter cannot infer a custom
|
||||
// block's field shape; String() guards the values regardless.
|
||||
callout: ({ node }: { node: { fields: Record<string, unknown> } }) => (
|
||||
<CalloutBlock
|
||||
tone={String(node.fields.tone ?? 'caveat')}
|
||||
body={String(node.fields.body ?? '')}
|
||||
/>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Wrapped in React's cache() because Next calls generateMetadata and the page
|
||||
* component separately for the same request — without it, every post view runs
|
||||
* this query against Postgres twice. cache() dedupes within a single request
|
||||
* only, so it never serves one visitor's request from another's.
|
||||
*/
|
||||
const findPost = cache(async (slug: string) => {
|
||||
const payload = await getCachedPayload();
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { slug: { equals: slug }, _status: { equals: 'published' } },
|
||||
limit: 1,
|
||||
depth: 1,
|
||||
});
|
||||
return docs[0] ?? null;
|
||||
});
|
||||
|
||||
function summarise(post: Post) {
|
||||
return {
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
excerpt: post.excerpt,
|
||||
publishedAt: post.publishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await findPost(slug);
|
||||
if (!post) return { title: 'Not found' };
|
||||
|
||||
const hero = heroOf(post);
|
||||
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) },
|
||||
openGraph: {
|
||||
type: 'article',
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
url: absoluteUrl(`/blog/${post.slug}`),
|
||||
publishedTime: post.publishedAt,
|
||||
// A post with a hero image shares that; one without falls through to the
|
||||
// generated share card at app/opengraph-image.tsx.
|
||||
...(hero?.url ? { images: [{ url: hero.url }] } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PostPage(
|
||||
{ params }: { params: Promise<{ slug: string }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
/*
|
||||
* Flags read at this route's own declared floor, so gating costs it nothing.
|
||||
* Checked before the post is fetched: a dark blog should not query Payload.
|
||||
*/
|
||||
const flags = await getFlags(3600);
|
||||
if (flags.blog !== true) notFound();
|
||||
const namedAuthor = flags.about_page === true;
|
||||
|
||||
const post = await findPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
const summary = summarise(post);
|
||||
const hero = heroOf(post);
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
/*
|
||||
* The Person entity is anchored at /about#tudor, so it is declared only
|
||||
* when that page exists. Claiming an author whose URL 404s is a worse
|
||||
* signal than attributing the post to the publisher.
|
||||
*/
|
||||
'@graph': [
|
||||
blogPostingJsonLd(summary, { namedAuthor }),
|
||||
breadcrumbJsonLd(summary),
|
||||
...(namedAuthor ? [personJsonLd()] : []),
|
||||
organizationJsonLd(),
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={styles.page}>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
|
||||
<nav className={styles.crumb}>
|
||||
<Link href="/blog" className={styles.link}>Blog</Link>
|
||||
</nav>
|
||||
|
||||
<h1 className={styles.heading}>{summary.title}</h1>
|
||||
|
||||
<p className={styles.byline}>
|
||||
{/* Unlinked while about_page is dark; the flags are independent. */}
|
||||
By {namedAuthor
|
||||
? <Link href="/about" className={styles.link}>Tudor</Link>
|
||||
: 'Tudor'}
|
||||
{' · '}
|
||||
<time dateTime={summary.publishedAt}>
|
||||
{new Date(summary.publishedAt).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
</p>
|
||||
|
||||
{/*
|
||||
A plain <img>, not next/image: Payload already generated the sized
|
||||
derivatives on upload (Media's imageSizes), so routing it through the
|
||||
optimizer would resize an image that is already the right size.
|
||||
*/}
|
||||
{hero?.url && (
|
||||
<img
|
||||
className={styles.hero}
|
||||
src={hero.url}
|
||||
alt={hero.alt ?? ''}
|
||||
width={hero.width ?? undefined}
|
||||
height={hero.height ?? undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.prose}>
|
||||
<RichText data={post.content} converters={calloutConverters} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getCachedPayload } from '@/lib/payload';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import { getFlags } from '@/lib/flags';
|
||||
import styles from './Blog.module.css';
|
||||
|
||||
/*
|
||||
* Dynamic, not ISR.
|
||||
*
|
||||
* This route has no dynamic params, so Next prerenders it at build time — and
|
||||
* CI builds the image with no database reachable, which fails the build. It is
|
||||
* a single indexed query against Postgres on the same Docker network, so
|
||||
* rendering per request is cheap, and it means a newly published post appears
|
||||
* here immediately rather than waiting on a revalidation.
|
||||
*/
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog',
|
||||
description:
|
||||
'Notes on what school performance data shows, and what it does not.',
|
||||
alternates: { canonical: absoluteUrl('/blog') },
|
||||
};
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Date(value).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export default async function BlogIndexPage() {
|
||||
const flags = await getFlags();
|
||||
if (flags.blog !== true) notFound();
|
||||
|
||||
const payload = await getCachedPayload();
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { _status: { equals: 'published' } },
|
||||
sort: '-publishedAt',
|
||||
limit: 50,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<p className={styles.kicker}>Blog</p>
|
||||
<h1 className={styles.heading}>Notes on the numbers</h1>
|
||||
<p className={styles.standfirst}>
|
||||
What school performance data shows, what it doesn't, and how to
|
||||
read it without being misled. Written by{' '}
|
||||
{/* Plain text when about_page is dark: the two flags are
|
||||
independent, so this link would otherwise point at a 404. */}
|
||||
{flags.about_page === true
|
||||
? <Link href="/about" className={styles.link}>Tudor</Link>
|
||||
: 'Tudor'}.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{docs.length === 0 ? (
|
||||
<p className={styles.empty}>No posts yet.</p>
|
||||
) : (
|
||||
<ul className={styles.list}>
|
||||
{docs.map((post) => (
|
||||
<li key={post.id} className={styles.item}>
|
||||
<time className={styles.date} dateTime={String(post.publishedAt)}>
|
||||
{formatDate(String(post.publishedAt))}
|
||||
</time>
|
||||
<h2 className={styles.itemTitle}>
|
||||
<Link href={`/blog/${post.slug}`} className={styles.itemLink}>
|
||||
{post.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className={styles.excerpt}>{post.excerpt}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getCachedPayload } from '@/lib/payload';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import { getFlags } from '@/lib/flags';
|
||||
|
||||
/*
|
||||
* Dynamic, not ISR.
|
||||
*
|
||||
* This route has no dynamic params, so Next prerenders it at build time — and
|
||||
* CI builds the image with no database reachable, which fails the build. It is
|
||||
* a single indexed query against Postgres on the same Docker network, so
|
||||
* rendering per request is cheap, and it means a newly published post appears
|
||||
* here immediately rather than waiting on a revalidation.
|
||||
*/
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value.replace(/[<>&'"]/g, (char) =>
|
||||
({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[char]!));
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
// A dark blog has no feed. 404 rather than an empty channel: an empty feed
|
||||
// is a live feed with nothing in it, which a reader would keep polling.
|
||||
const flags = await getFlags();
|
||||
if (flags.blog !== true) return new Response('Not found', { status: 404 });
|
||||
|
||||
const payload = await getCachedPayload();
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: { _status: { equals: 'published' } },
|
||||
sort: '-publishedAt',
|
||||
limit: 50,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const items = docs.map((post) => `
|
||||
<item>
|
||||
<title>${escapeXml(String(post.title))}</title>
|
||||
<link>${absoluteUrl(`/blog/${post.slug}`)}</link>
|
||||
<guid isPermaLink="true">${absoluteUrl(`/blog/${post.slug}`)}</guid>
|
||||
<description>${escapeXml(String(post.excerpt))}</description>
|
||||
<pubDate>${new Date(String(post.publishedAt)).toUTCString()}</pubDate>
|
||||
</item>`).join('');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>schoolcompare blog</title>
|
||||
<link>${absoluteUrl('/blog')}</link>
|
||||
<description>What school performance data shows, and what it does not.</description>
|
||||
<language>en-GB</language>${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* A second sitemap for the URLs Next owns.
|
||||
*
|
||||
* /sitemap.xml is proxied from FastAPI (app/(frontend)/sitemap.xml), which
|
||||
* knows nothing about Payload — the backend and frontend ship as separate
|
||||
* images. Rather than teach it, the Next-owned URLs get their own sitemap and
|
||||
* robots.txt lists both.
|
||||
*/
|
||||
import { getCachedPayload } from '@/lib/payload';
|
||||
import { absoluteUrl } from '@/lib/site';
|
||||
import { getFlags } from '@/lib/flags';
|
||||
|
||||
/*
|
||||
* Dynamic, not ISR.
|
||||
*
|
||||
* This route has no dynamic params, so Next prerenders it at build time — and
|
||||
* CI builds the image with no database reachable, which fails the build. It is
|
||||
* a single indexed query against Postgres on the same Docker network, so
|
||||
* rendering per request is cheap, and it means a newly published post appears
|
||||
* here immediately rather than waiting on a revalidation.
|
||||
*/
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
/*
|
||||
* A dark page must not be advertised. Submitting a URL that 404s is the one
|
||||
* thing a sitemap is not allowed to do, so each entry is gated on the same
|
||||
* flag that gates the page itself.
|
||||
*
|
||||
* With both flags dark this emits a valid, empty <urlset> rather than a 404:
|
||||
* robots.txt names this sitemap unconditionally, and an empty sitemap is a
|
||||
* well-formed statement that there is nothing here yet.
|
||||
*/
|
||||
const flags = await getFlags();
|
||||
const aboutEnabled = flags.about_page === true;
|
||||
const blogEnabled = flags.blog === true;
|
||||
|
||||
// Only query Payload when the blog is actually being advertised.
|
||||
const docs = blogEnabled
|
||||
? (await (await getCachedPayload()).find({
|
||||
collection: 'posts',
|
||||
where: { _status: { equals: 'published' } },
|
||||
sort: '-publishedAt',
|
||||
limit: 500,
|
||||
depth: 0,
|
||||
})).docs
|
||||
: [];
|
||||
|
||||
const urls: Array<{ loc: string; lastmod: string | null }> = [
|
||||
...(aboutEnabled ? [{ loc: absoluteUrl('/about'), lastmod: null }] : []),
|
||||
...(blogEnabled ? [{ loc: absoluteUrl('/blog'), lastmod: null }] : []),
|
||||
...docs.map((post) => ({
|
||||
loc: absoluteUrl(`/blog/${post.slug}`),
|
||||
lastmod: new Date(String(post.updatedAt ?? post.publishedAt)).toISOString(),
|
||||
})),
|
||||
];
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.map(({ loc, lastmod }) =>
|
||||
` <url><loc>${loc}</loc>${lastmod ? `<lastmod>${lastmod}</lastmod>` : ''}</url>`,
|
||||
).join('\n')}
|
||||
</urlset>`;
|
||||
|
||||
return new Response(xml, {
|
||||
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
@@ -616,6 +616,35 @@ html .leaflet-bar a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/*
|
||||
* The popup, which leaflet.css paints `background: white; color: #333` on both
|
||||
* the card and its tip. The content LeafletMapInner binds into it is themed —
|
||||
* the school name and the headline figure are `var(--text-primary)` — so in
|
||||
* dark mode that was #E9EEF0 on #FFFFFF, a contrast ratio of 1.17:1. The name
|
||||
* and the number were the two least readable things on the page.
|
||||
*
|
||||
* Moving the surface onto --bg-card fixes every foreground at once rather than
|
||||
* one at a time: the muted phase line goes 2.90:1 -> 5.45:1, the vs-national
|
||||
* delta 1.94:1 -> 8.14:1, the Ofsted badge 1.74:1 -> 9.11:1. In light mode
|
||||
* --bg-card is #FFFFFF, so the popup looks as it always did.
|
||||
*/
|
||||
html .leaflet-popup-content-wrapper,
|
||||
html .leaflet-popup-tip {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Leaflet's own selector is `.leaflet-container a.leaflet-popup-close-button`
|
||||
at 0,2,1 — an `html` prefix alone would lose to it. */
|
||||
html .leaflet-container a.leaflet-popup-close-button {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html .leaflet-container a.leaflet-popup-close-button:hover,
|
||||
html .leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Main content column */
|
||||
.main {
|
||||
max-width: 1400px;
|
||||
@@ -4,8 +4,10 @@ import Script from 'next/script';
|
||||
import { Navigation } from '@/components/Navigation';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { ComparisonToast } from '@/components/ComparisonToast';
|
||||
import { RouteTrail } from '@/components/RouteTrail';
|
||||
import { ComparisonProvider } from '@/context/ComparisonProvider';
|
||||
import { SITE_URL } from '@/lib/site';
|
||||
import { getFlags } from '@/lib/flags';
|
||||
import './globals.css';
|
||||
|
||||
// Manrope carries headings and key messaging — the guideline's "friendly,
|
||||
@@ -57,14 +59,32 @@ export const metadata: Metadata = {
|
||||
authors: [{ name: 'schoolcompare' }],
|
||||
manifest: '/manifest.json',
|
||||
// No `icons` key on purpose: setting it here would override the file
|
||||
// conventions. app/icon.svg and app/apple-icon.tsx are the source, and
|
||||
// app/opengraph-image.tsx supplies og:image and twitter:image.
|
||||
// conventions. app/icon.png and app/apple-icon.png are the source.
|
||||
//
|
||||
// og:image and twitter:image are NOT inherited from
|
||||
// app/opengraph-image.tsx — see the note on openGraph.images below. The
|
||||
// icon conventions do reach these pages; the opengraph-image one does not.
|
||||
metadataBase: new URL(SITE_URL),
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'Compare Schools Side by Side | schoolcompare',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||
/*
|
||||
* Declared, not inherited.
|
||||
*
|
||||
* app/opengraph-image.tsx is a metadata file convention, and it does
|
||||
* attach to routes in the app root segment — _not-found gets an og:image
|
||||
* from it. It does not reach the site's pages, which live in the
|
||||
* (frontend) route group whose own layout.tsx is a root layout. Staging
|
||||
* served og:title, og:description, og:url, og:site_name and og:type with
|
||||
* no og:image at all, so every link pasted into a chat rendered bare.
|
||||
*
|
||||
* The file stays at the app root: /robots.txt and /icon.png depend on it
|
||||
* being there, and moving it is what broke those before. This points at
|
||||
* the route it generates instead. metadataBase makes it absolute.
|
||||
*/
|
||||
images: ['/opengraph-image'],
|
||||
url: SITE_URL,
|
||||
siteName: 'schoolcompare',
|
||||
},
|
||||
@@ -74,14 +94,34 @@ export const metadata: Metadata = {
|
||||
title: 'Compare Schools Side by Side | schoolcompare',
|
||||
description:
|
||||
'Put five English schools on one screen — SATs, GCSE results, Ofsted grades, and how close you had to live to get a place.',
|
||||
// The card is summary_large_image; claiming that and supplying no image
|
||||
// is worse than claiming a summary card.
|
||||
images: ['/opengraph-image'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
/*
|
||||
* The footer's About and Blog links are flagged, which makes this the one
|
||||
* place on the site that reads a flag on every route.
|
||||
*
|
||||
* 604800 is deliberate and load-bearing: it is the revalidate every SEO route
|
||||
* here already declares. Next pins a route to the LOWEST revalidate among its
|
||||
* fetches, so reading flags at the 300s default would drop the whole school
|
||||
* and place corpus from a weekly cache to a 5-minute one — a large origin-load
|
||||
* regression to hide two footer links.
|
||||
*
|
||||
* The cost is latency in one direction only. The pages themselves read the
|
||||
* same flags at their own floors and flip within minutes; the footer links
|
||||
* follow within a week. Turning a feature on early therefore shows the page
|
||||
* before its footer link, which is harmless. Turning one off leaves a link to
|
||||
* a 404 until the cache turns over, so a rollback that matters wants a purge.
|
||||
*/
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const flags = await getFlags(604800);
|
||||
return (
|
||||
// The font variable classes must sit on <html>, not <body>. globals.css
|
||||
// declares --font-display on :root as var(--font-manrope) and --font-ui as
|
||||
@@ -114,6 +154,10 @@ export default function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
{/* Records every route so funnel attribution has a previous page to
|
||||
name. document.referrer cannot: a soft navigation creates no
|
||||
document, so the browser never updates it. */}
|
||||
<RouteTrail />
|
||||
<ComparisonProvider>
|
||||
<a href="#main-content" className="skip-link">Skip to main content</a>
|
||||
<Navigation />
|
||||
@@ -121,7 +165,10 @@ export default function RootLayout({
|
||||
{children}
|
||||
</main>
|
||||
<ComparisonToast />
|
||||
<Footer />
|
||||
<Footer
|
||||
aboutEnabled={flags.about_page === true}
|
||||
blogEnabled={flags.blog === true}
|
||||
/>
|
||||
</ComparisonProvider>
|
||||
</body>
|
||||
</html>
|
||||
File renamed without changes.
File renamed without changes.
+28
-3
@@ -7,6 +7,8 @@
|
||||
import { fetchSchoolDetails, fetchSchools, fetchNationalAverages } from '@/lib/api';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { SchoolDetailShell } from '@/components/school/SchoolDetailShell';
|
||||
import { NearbyPlaces } from '@/components/school/NearbyPlaces';
|
||||
import { schoolBreadcrumbJsonLd, type SchoolPlace } from '@/lib/jsonld';
|
||||
import { PrimarySchoolSections } from '@/components/school/PrimarySchoolSections';
|
||||
import { SecondarySchoolSections } from '@/components/school/SecondarySchoolSections';
|
||||
import {
|
||||
@@ -149,6 +151,10 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
}
|
||||
|
||||
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, admission_distance, deprivation, finance, destinations } = data;
|
||||
// Absent on an older API build; the module and the trail both degrade to
|
||||
// nothing rather than throwing, which is how this shipped without a
|
||||
// lockstep deploy of the two images.
|
||||
const places: SchoolPlace[] = data.places ?? [];
|
||||
|
||||
// Redirect bare URN to canonical slug URL
|
||||
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
|
||||
@@ -185,10 +191,19 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
const primaryNavItems = buildNavItems(primaryFlags, navInput);
|
||||
const secondaryNavItems = buildSecondaryNavItems(secondaryFlags, navInput);
|
||||
|
||||
// Generate JSON-LD structured data for SEO
|
||||
/*
|
||||
* `School`, not `EducationalOrganization`.
|
||||
*
|
||||
* Both are valid, but EducationalOrganization is the parent type covering
|
||||
* universities, training providers and nurseries alike. School is the
|
||||
* specific one, and a type that says what the page is about is the whole
|
||||
* point of declaring it. Google's own guidance treats the narrower type as
|
||||
* the correct choice where it applies.
|
||||
*/
|
||||
const structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'EducationalOrganization',
|
||||
'@graph': [{
|
||||
'@type': 'School',
|
||||
name: school_info.school_name,
|
||||
identifier: school_info.urn.toString(),
|
||||
...(school_info.address && {
|
||||
@@ -210,6 +225,15 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
...(school_info.school_type && {
|
||||
additionalType: school_info.school_type,
|
||||
}),
|
||||
},
|
||||
// The trail the page sits at the end of. School pages carried no
|
||||
// breadcrumb at all, while every place page already emitted one.
|
||||
schoolBreadcrumbJsonLd({
|
||||
name: school_info.school_name,
|
||||
url: `/school/${slug}`,
|
||||
places,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -233,7 +257,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
census={census ?? null}
|
||||
admissions={admissions ?? null}
|
||||
admissionsHistory={admissions_history ?? []}
|
||||
admissionDistance={admission_distance ?? null}
|
||||
admissionDistance={admission_distance}
|
||||
deprivation={deprivation ?? null}
|
||||
finance={finance ?? null}
|
||||
nationalAvg={nationalAvg}
|
||||
@@ -264,6 +288,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||
/>
|
||||
</SchoolDetailShell>
|
||||
)}
|
||||
<NearbyPlaces places={places} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import config from '@payload-config';
|
||||
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views';
|
||||
import { importMap } from '../importMap.js';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
export default function NotFound({ params, searchParams }: Args) {
|
||||
return NotFoundPage({ config, importMap, params, searchParams });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import config from '@payload-config';
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views';
|
||||
import { importMap } from '../importMap.js';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] }>;
|
||||
};
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams });
|
||||
|
||||
export default function Page({ params, searchParams }: Args) {
|
||||
return RootPage({ config, importMap, params, searchParams });
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
/** @type import('payload').ImportMap */
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#BlocksFeatureClient": BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Payload's REST API, mounted at /cms-api rather than /api.
|
||||
* See lib/payloadRoutes.ts — /api is the FastAPI proxy's catch-all.
|
||||
*/
|
||||
import config from '@payload-config';
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes';
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -0,0 +1,4 @@
|
||||
import config from '@payload-config';
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes';
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config);
|
||||
@@ -0,0 +1,5 @@
|
||||
import config from '@payload-config';
|
||||
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes';
|
||||
|
||||
export const POST = GRAPHQL_POST(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Root layout for the Payload admin panel.
|
||||
*
|
||||
* This is a SECOND root layout: it renders its own <html>/<body>, as does
|
||||
* app/(frontend)/layout.tsx. Next permits that only while no app/layout.tsx
|
||||
* exists — which is why the site's routes were moved into (frontend). Adding
|
||||
* an app/layout.tsx would nest the admin panel inside the site's nav, footer
|
||||
* and providers and emit nested <html>.
|
||||
*/
|
||||
import type { ServerFunctionClient } from 'payload';
|
||||
import config from '@payload-config';
|
||||
import { RootLayout, handleServerFunctions } from '@payloadcms/next/layouts';
|
||||
import { importMap } from './admin/importMap.js';
|
||||
import '@payloadcms/next/css';
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server';
|
||||
return handleServerFunctions({ ...args, config, importMap });
|
||||
};
|
||||
|
||||
export default function PayloadLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
}
|
||||
@@ -12,9 +12,14 @@ export default function robots(): MetadataRoute.Robots {
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/api/', '/_next/'],
|
||||
// /admin and /cms-api are also served X-Robots-Tag: noindex by
|
||||
// next.config.mjs. A Disallow alone blocks crawling, not indexing.
|
||||
disallow: ['/api/', '/_next/', '/admin/', '/cms-api/'],
|
||||
},
|
||||
],
|
||||
sitemap: absoluteUrl('/sitemap.xml'),
|
||||
// Two sitemaps: /sitemap.xml is proxied from FastAPI and carries the
|
||||
// school corpus; /content-sitemap.xml is Next-owned and carries /about
|
||||
// and the blog. The backend knows nothing about Payload.
|
||||
sitemap: [absoluteUrl('/sitemap.xml'), absoluteUrl('/content-sitemap.xml')],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Block } from 'payload';
|
||||
|
||||
/**
|
||||
* The house block: "what this number doesn't tell you".
|
||||
*
|
||||
* Blocks are the reason this site runs a CMS rather than flat files — a post
|
||||
* can carry live product components, not screenshots of them. This is the
|
||||
* first and simplest one; a live-chart block follows when a post needs it.
|
||||
*/
|
||||
export const Callout: Block = {
|
||||
slug: 'callout',
|
||||
labels: { singular: 'Callout', plural: 'Callouts' },
|
||||
fields: [
|
||||
{
|
||||
name: 'tone',
|
||||
type: 'select',
|
||||
defaultValue: 'caveat',
|
||||
options: [
|
||||
{ label: 'Caveat: what this does not show', value: 'caveat' },
|
||||
{ label: 'Note: useful aside', value: 'note' },
|
||||
],
|
||||
},
|
||||
{ name: 'body', type: 'textarea', required: true },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
/**
|
||||
* Uploads land on a Docker named volume mounted at /app/media. The path is
|
||||
* absolute because Payload 3 requires it, and it must match the payload_media
|
||||
* mount in docker-compose.portainer.yml exactly — a mismatch writes into the
|
||||
* container's own filesystem, where the next redeploy silently discards it.
|
||||
*/
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
access: { read: () => true },
|
||||
upload: {
|
||||
staticDir: '/app/media',
|
||||
mimeTypes: ['image/*'],
|
||||
imageSizes: [
|
||||
{ name: 'thumbnail', width: 400 },
|
||||
{ name: 'hero', width: 1200 },
|
||||
],
|
||||
adminThumbnail: 'thumbnail',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
// Required rather than optional: a decorative-by-default image is an
|
||||
// accessibility regression on a site parents use under time pressure.
|
||||
admin: { description: 'Describe the image for screen readers.' },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { lexicalEditor, BlocksFeature } from '@payloadcms/richtext-lexical';
|
||||
import { Callout } from '@/blocks/Callout';
|
||||
|
||||
/**
|
||||
* Drop the cached copy of a post page when it changes.
|
||||
*
|
||||
* Only the post page needs this. The blog index, the RSS feed and the content
|
||||
* sitemap are force-dynamic — they have no dynamic params, so Next would
|
||||
* prerender them at build time, where CI has no database — which means they
|
||||
* already reflect a change on the next request.
|
||||
*
|
||||
* /blog/[slug] is ISR: generated on first request and cached, so without this
|
||||
* an edit to an already-published post would not appear until the revalidate
|
||||
* window expired — up to an hour of a writer concluding that saving is broken.
|
||||
*
|
||||
* Payload runs in the same process as Next, so this is a direct revalidatePath
|
||||
* call: no webhook, no shared secret, no network hop to get wrong.
|
||||
*/
|
||||
function revalidatePost(slug: string) {
|
||||
revalidatePath(`/blog/${slug}`);
|
||||
}
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
access: {
|
||||
/*
|
||||
* Drafts must be hidden here, not in the pages that query this collection.
|
||||
*
|
||||
* From Payload's own documentation: "The `draft` argument alone does not
|
||||
* restrict documents with `_status: 'draft'` from being returned by the
|
||||
* API." The blog index and post page both filter on `_status`, but that
|
||||
* is a convenience, not a control — a direct GET /cms-api/posts would
|
||||
* hand every unpublished draft to any visitor.
|
||||
*
|
||||
* Returning a query constraint rather than a boolean is the documented
|
||||
* mechanism: Payload merges it into every read for an anonymous caller.
|
||||
*/
|
||||
read: ({ req }) => {
|
||||
if (req.user) return true;
|
||||
return { _status: { equals: 'published' } };
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'publishedAt', '_status'],
|
||||
},
|
||||
versions: {
|
||||
// Posts get written across several sittings and previewed before they go
|
||||
// live. Without drafts, saving is publishing.
|
||||
drafts: true,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [({ doc }) => { revalidatePost(String(doc.slug)); }],
|
||||
afterDelete: [({ doc }) => { revalidatePost(String(doc.slug)); }],
|
||||
},
|
||||
fields: [
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description: 'The URL segment. Never change it after publishing.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
required: true,
|
||||
admin: { position: 'sidebar', date: { pickerAppearance: 'dayOnly' } },
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
maxLength: 200,
|
||||
admin: {
|
||||
description: 'Shown on the index and used as the meta description.',
|
||||
},
|
||||
},
|
||||
{ name: 'heroImage', type: 'upload', relationTo: 'media' },
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
required: true,
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
BlocksFeature({ blocks: [Callout] }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
/**
|
||||
* The site's only authenticated surface. There is one account and no
|
||||
* registration: `create` is closed to everyone, so the first user is seeded
|
||||
* with `payload create-first-user` and no one can add another through the API.
|
||||
*/
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: {
|
||||
// Slows credential stuffing against a panel that is on the public
|
||||
// internet. Five attempts, then a ten-minute lock.
|
||||
maxLoginAttempts: 5,
|
||||
lockTime: 10 * 60 * 1000,
|
||||
},
|
||||
access: {
|
||||
create: () => false,
|
||||
read: ({ req }) => Boolean(req.user),
|
||||
update: ({ req }) => Boolean(req.user),
|
||||
delete: () => false,
|
||||
},
|
||||
admin: { useAsTitle: 'email' },
|
||||
fields: [
|
||||
{
|
||||
name: 'displayName',
|
||||
type: 'text',
|
||||
required: true,
|
||||
// Rendered as the byline on every post. First name only — the site
|
||||
// publishes no surname and no employer.
|
||||
defaultValue: 'Tudor',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -22,7 +22,8 @@
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr 1fr;
|
||||
/* Brand column plus three link columns: Product, Resources, About. */
|
||||
grid-template-columns: 1.6fr 1fr 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@@ -193,6 +194,14 @@
|
||||
color: var(--on-sunken);
|
||||
}
|
||||
|
||||
/* Four columns crush between the tablet range and the 768px collapse, so
|
||||
pair them up first rather than jumping straight to a single column. */
|
||||
@media (max-width: 960px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 2rem 1rem 1.5rem;
|
||||
|
||||
@@ -10,7 +10,17 @@
|
||||
import { LogoMark } from './Logo';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
export function Footer() {
|
||||
/**
|
||||
* Both default to false so a caller that forgets a prop hides the link rather
|
||||
* than pointing it at a page that 404s. Same reasoning as backend/flags.py:
|
||||
* "Every flag defaults to False."
|
||||
*/
|
||||
interface FooterProps {
|
||||
aboutEnabled?: boolean;
|
||||
blogEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function Footer({ aboutEnabled = false, blogEnabled = false }: FooterProps = {}) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
@@ -93,6 +103,27 @@ export function Footer() {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Dropped entirely when both flags are dark, rather than left as an
|
||||
empty heading: shipping dark means the footer renders as it did
|
||||
before the feature existed. */}
|
||||
{(aboutEnabled || blogEnabled) && (
|
||||
<div className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>About</h4>
|
||||
<ul className={styles.links}>
|
||||
{/* The only route to a named human. Deliberately not in the nav:
|
||||
the mobile bottom bar already carries four items, and both of
|
||||
these are lower intent than any of them. Post bylines link
|
||||
here too, which is where a reader actually asks the question. */}
|
||||
{aboutEnabled && (
|
||||
<li><a href="/about" className={styles.link}>Who's behind this</a></li>
|
||||
)}
|
||||
{blogEnabled && (
|
||||
<li><a href="/blog" className={styles.link}>Blog</a></li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.bottom}>
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function LeafletMapInner({ schools, center, zoom, referencePoint,
|
||||
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
||||
</div>
|
||||
${metricHtml}
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:var(--text-inverse);border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
||||
</div>`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Writes the in-app navigation trail that funnel attribution reads.
|
||||
*
|
||||
* Renders nothing. It exists because document.referrer cannot answer "which
|
||||
* page did they come from" in an App Router app: a soft navigation creates no
|
||||
* document, so the browser never updates it. See the trail comment in
|
||||
* lib/analytics.ts.
|
||||
*
|
||||
* Mounted once in the root layout, so every route is recorded — including the
|
||||
* ones that fire no event of their own, which are still somebody else's
|
||||
* previous page.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { recordVisitedPath } from '@/lib/analytics';
|
||||
|
||||
export function RouteTrail() {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
recordVisitedPath(pathname);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.caveat,
|
||||
.note {
|
||||
border-left: 3px solid var(--brand);
|
||||
background: var(--brand-bg);
|
||||
padding: 1rem 1.15rem;
|
||||
margin: 1.75rem 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.note {
|
||||
border-left-color: var(--border-strong);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.body {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.65;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import styles from './CalloutBlock.module.css';
|
||||
|
||||
/**
|
||||
* Renders the Callout block from blocks/Callout.ts. The "caveat" tone is the
|
||||
* one that matters: it is how a post says what a number does not show, in
|
||||
* context, rather than burying it in a closing paragraph.
|
||||
*/
|
||||
export function CalloutBlock({ tone, body }: { tone: string; body: string }) {
|
||||
return (
|
||||
<aside className={tone === 'caveat' ? styles.caveat : styles.note}>
|
||||
<p className={styles.body}>{body}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,6 @@ function toGroup(payload: DestinationGroupPayload): DestinationGroup {
|
||||
return {
|
||||
cohort: payload.cohort ?? 0,
|
||||
cells: payload.categories,
|
||||
aggregates: payload.aggregates,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +47,44 @@ function cellsFor(group: DestinationGroup, card: CardGroup): DestinationCell[] {
|
||||
return group.cells.filter(c => wanted.has(c.category));
|
||||
}
|
||||
|
||||
/**
|
||||
* One cell of the detail table.
|
||||
*
|
||||
* The three statuses are three different statements and the table has to keep
|
||||
* them apart, because the whole pipeline does — the mart, the SQLAlchemy model
|
||||
* and the serialiser all preserve the difference deliberately:
|
||||
*
|
||||
* published the figure
|
||||
* suppressed DfE withheld it to protect a small number of pupils
|
||||
* not_applicable this destination does not apply to this school at all
|
||||
*
|
||||
* An earlier version keyed the share column off `percentage === null`, which is
|
||||
* also true for not_applicable, so a category that simply does not apply was
|
||||
* labelled "withheld" — while the pupils column beside it rendered blank. Both
|
||||
* columns now derive from `status`, so they cannot disagree.
|
||||
*/
|
||||
function cellValue(
|
||||
cell: DestinationCell, cohort: number, kind: 'pupils' | 'share',
|
||||
) {
|
||||
if (cell.status === 'suppressed') {
|
||||
return <span className={styles.withheldMark}>withheld</span>;
|
||||
}
|
||||
const notApplicable = (
|
||||
<span className={styles.notApplicable} title="Does not apply to this school">
|
||||
—
|
||||
</span>
|
||||
);
|
||||
|
||||
if (cell.status !== 'published' || cell.pupils === null) return notApplicable;
|
||||
if (kind === 'pupils') return cell.pupils;
|
||||
|
||||
// Percentages come from the mart, but a published count with no published
|
||||
// percentage is recoverable from the cohort — both halves are published, so
|
||||
// nothing withheld is involved. Same derivation the bar widths use.
|
||||
const share = cell.percentage ?? (cohort > 0 ? (cell.pupils / cohort) * 100 : null);
|
||||
return share === null ? notApplicable : `${Math.round(share)}%`;
|
||||
}
|
||||
|
||||
export function DestinationsView({
|
||||
destinations, phase,
|
||||
}: { destinations: DestinationPhase; phase: 'ks4' | 'ks5' }) {
|
||||
@@ -194,27 +231,19 @@ export function DestinationsView({
|
||||
const cell = group.cells.find(c => c.category === category);
|
||||
if (!cell) return [];
|
||||
const card = cardGroupFor(category);
|
||||
const isWithheld = cell.status === 'suppressed';
|
||||
return [(
|
||||
<tr
|
||||
key={category}
|
||||
data-group={card ?? 'none'}
|
||||
data-status={cell.status}
|
||||
className={dimmed(card) ? styles.dim : ''}
|
||||
>
|
||||
<th scope="row" className={styles.rowName}>
|
||||
<span className={`${styles.swatch} ${styles[category]}`} />
|
||||
{CATEGORY_LABELS[category]}
|
||||
</th>
|
||||
<td>
|
||||
{isWithheld
|
||||
? <span className={styles.withheldMark}>withheld</span>
|
||||
: cell.pupils}
|
||||
</td>
|
||||
<td>
|
||||
{isWithheld || cell.percentage === null
|
||||
? <span className={styles.withheldMark}>withheld</span>
|
||||
: `${Math.round(cell.percentage)}%`}
|
||||
</td>
|
||||
<td>{cellValue(cell, group.cohort, 'pupils')}</td>
|
||||
<td>{cellValue(cell, group.cohort, 'share')}</td>
|
||||
</tr>
|
||||
)];
|
||||
})}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function DistanceSection({
|
||||
admissionDistance,
|
||||
schoolInfo,
|
||||
}: {
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
admissionDistance: SchoolAdmissionDistance | null | undefined;
|
||||
schoolInfo: School;
|
||||
}) {
|
||||
// Without a figure there is nothing to compare against, and without
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Tokens only — the same vocabulary schoolSections.module.css uses, so the
|
||||
module follows both themes without a rule of its own. No hardcoded colour
|
||||
appears here; darkThemeSafety asserts that across the codebase. */
|
||||
|
||||
.section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Matches .sectionTitle in schoolSections.module.css, including the brand
|
||||
rule before the text, so this reads as one more section of the page
|
||||
rather than a footer bolted underneath it. */
|
||||
.heading {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.875rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--border);
|
||||
font-family: var(--font-display);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.heading::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 1em;
|
||||
background: var(--brand);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--brand-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Link from 'next/link';
|
||||
import type { SchoolPlace, SchoolPhasePage } from '@/lib/jsonld';
|
||||
import styles from './NearbyPlaces.module.css';
|
||||
|
||||
/**
|
||||
* Links from a school page into the location layer.
|
||||
*
|
||||
* This exists for a structural reason rather than a decorative one. Before
|
||||
* it, the only anchor on a school page pointed at the school's own website,
|
||||
* so the ~27k pages that carry most of the site's inbound authority passed it
|
||||
* straight off-site and none of it reached the place pages. These links are
|
||||
* what circulate it instead.
|
||||
*
|
||||
* Every entry comes from the place registry via the API, so a link is only
|
||||
* ever offered for a page that exists: a place below the publish threshold is
|
||||
* absent from the registry and therefore absent here.
|
||||
*/
|
||||
|
||||
/** Narrowest first: a reader on a school page wants its town before its
|
||||
* county. The API orders widest-first because that is what the breadcrumb
|
||||
* reads, so the two orders are deliberately different. */
|
||||
const ORDER: Record<string, number> = {
|
||||
town: 0, locality: 0, outcode: 1, authority: 2,
|
||||
};
|
||||
|
||||
function label(place: SchoolPlace): string {
|
||||
const noun = place.count === 1 ? 'school' : 'schools';
|
||||
const preposition = place.kind === 'outcode' ? 'near' : 'in';
|
||||
return `${place.count} ${noun} ${preposition} ${place.name}`;
|
||||
}
|
||||
|
||||
/** "22 primary schools in Brentwood" — the phrasing the query itself uses. */
|
||||
function phaseLabel(place: SchoolPlace, page: SchoolPhasePage): string {
|
||||
const noun = page.count === 1 ? 'school' : 'schools';
|
||||
return `${page.count} ${page.phase} ${noun} in ${place.name}`;
|
||||
}
|
||||
|
||||
export function NearbyPlaces({ places }: { places: SchoolPlace[] }) {
|
||||
if (places.length === 0) return null;
|
||||
|
||||
const sorted = [...places].sort(
|
||||
(a, b) => (ORDER[a.kind] ?? 9) - (ORDER[b.kind] ?? 9),
|
||||
);
|
||||
|
||||
return (
|
||||
<section className={styles.section} aria-labelledby="nearby-places">
|
||||
<h2 id="nearby-places" className={styles.heading}>More schools near here</h2>
|
||||
<ul className={styles.list}>
|
||||
{sorted.flatMap((place) => [
|
||||
<li key={`${place.kind}:${place.slug}`}>
|
||||
<Link href={place.url} className={styles.link}>{label(place)}</Link>
|
||||
</li>,
|
||||
/* Immediately after its own place, so "22 primary schools in
|
||||
Brentwood" reads as part of Brentwood rather than as an
|
||||
unrelated link further down the row. */
|
||||
...place.phases.map((page) => (
|
||||
<li key={`${place.kind}:${place.slug}:${page.phase}`}>
|
||||
<Link href={page.url} className={styles.link}>
|
||||
{phaseLabel(place, page)}
|
||||
</Link>
|
||||
</li>
|
||||
)),
|
||||
])}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -21,10 +21,16 @@ export function SecondaryAdmissionsSection({
|
||||
published cut-off and no EES admissions row. */
|
||||
admissions: SchoolAdmissions | null;
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
admissionDistance: SchoolAdmissionDistance | null | undefined;
|
||||
schoolInfo: School;
|
||||
}) {
|
||||
const cutoff = describeCutoff(admissionDistance);
|
||||
/* Absent means cut-offs are not being published at all; null means this
|
||||
school has no published cut-off. Only the second is a fact about the
|
||||
school, and only the second can be stated. Saying "X has not published a
|
||||
cut-off" while the feature is dark describes us, and is false wherever the
|
||||
authority does publish one. */
|
||||
const featureOn = admissionDistance !== undefined;
|
||||
// Moved with this section from SecondarySchoolDetailView, its only consumer.
|
||||
const admissionsTag = (() => {
|
||||
const policy = schoolInfo.admissions_policy?.toLowerCase() ?? '';
|
||||
@@ -101,7 +107,7 @@ export function SecondaryAdmissionsSection({
|
||||
{CUTOFF_NOTE} {CUTOFF_MEASUREMENT_NOTE}
|
||||
{cutoff.routeNote && <> {cutoff.routeNote}</>}
|
||||
</p>
|
||||
) : (
|
||||
) : featureOn ? (
|
||||
<p className={styles.sectionSubtitle} style={{ marginTop: '1rem' }}>
|
||||
{describeCutoffAbsence({
|
||||
localAuthority: schoolInfo.local_authority,
|
||||
@@ -109,7 +115,7 @@ export function SecondaryAdmissionsSection({
|
||||
admissionsHistory,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,10 @@ export interface SecondarySchoolSectionsProps {
|
||||
/** Needed to tell a year with no published cut-off apart from a year the
|
||||
* school simply was not oversubscribed. */
|
||||
admissionsHistory: SchoolAdmissions[];
|
||||
admissionDistance: SchoolAdmissionDistance | null;
|
||||
/** Absent — not null — while the admission_distance flag is off. The two
|
||||
* mean different things to the reader and must stay distinguishable:
|
||||
* see SecondaryAdmissionsSection, which words the absence. */
|
||||
admissionDistance: SchoolAdmissionDistance | null | undefined;
|
||||
deprivation: SchoolDeprivation | null;
|
||||
finance: SchoolFinance | null;
|
||||
nationalAvg: NationalAverages | null;
|
||||
|
||||
@@ -297,3 +297,11 @@
|
||||
font-size: var(--step--2);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* A destination that does not apply to this school. Deliberately not the
|
||||
withheld badge: "we are not told" and "there is nothing to tell" are
|
||||
different statements, and the rest of the pipeline keeps them apart. */
|
||||
.notApplicable {
|
||||
color: var(--text-muted);
|
||||
cursor: help;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# Publishing to the blog
|
||||
|
||||
The blog is Payload CMS, running inside the Next.js app. There is no separate
|
||||
service and no second deploy. Writing a post is done in the browser and takes
|
||||
effect on the live site within seconds.
|
||||
|
||||
## Before any of this: the flags
|
||||
|
||||
`/blog` and `/about` are behind feature flags (`blog` and `about_page`), and
|
||||
every flag in this system starts off. While `blog` is dark, `/blog`, every post
|
||||
page and the RSS feed return 404, and neither appears in the sitemap or the
|
||||
footer. Posts still save normally, because `/admin` is deliberately **not**
|
||||
flagged: you have to be able to write a post before there is anything worth
|
||||
switching on.
|
||||
|
||||
So a new post published to a dark blog is invisible, and that is working as
|
||||
intended, not a bug. Flip the flag in Unleash when the content is ready.
|
||||
Staging and production hold their own values (`development` and `production`
|
||||
environments), so you can light it on staging first. The page follows a flip
|
||||
within five minutes; the footer link takes up to a week, because it renders in
|
||||
the root layout and is cached at the same weekly floor as the school corpus.
|
||||
|
||||
Both flags are temporary scaffolding, like every flag here: a test starts
|
||||
failing once one is older than 90 days, at which point either the feature is
|
||||
permanent and the flag comes out, or it was never going to ship.
|
||||
|
||||
## Signing in
|
||||
|
||||
`https://www.schoolcompare.co.uk/admin`, one account, no registration. If you
|
||||
need the account seeded on a fresh environment, run against the container:
|
||||
|
||||
```bash
|
||||
npx payload create-first-user
|
||||
```
|
||||
|
||||
Staging has its own admin panel, its own database and its own credentials at
|
||||
`https://stx.schoolcompare.co.uk/admin`. Never reuse production's secret or
|
||||
password there.
|
||||
|
||||
## Writing a post
|
||||
|
||||
**Posts → Create New.** The fields:
|
||||
|
||||
| Field | Notes |
|
||||
|---|---|
|
||||
| **Title** | The `<h1>` and the browser tab. |
|
||||
| **Slug** | The URL segment, in the sidebar. **Never change it after publishing.** It is the canonical URL, and changing it breaks every existing link and discards the page's accumulated search signal. |
|
||||
| **Published at** | The date shown on the post and in the feed. |
|
||||
| **Excerpt** | Max 200 characters. Shown on the index *and* used as the meta description, so write it as a standalone sentence rather than a teaser. |
|
||||
| **Hero image** | Optional. Becomes the social share image; without one, the site's generated card is used. |
|
||||
| **Content** | Rich text. `/` inserts a block. |
|
||||
|
||||
**Save as draft** while you're working; drafts are not public. **Publish** when
|
||||
it's ready.
|
||||
|
||||
### The callout block
|
||||
|
||||
One custom block, `Callout`, with two tones:
|
||||
|
||||
- **Caveat**: what a number does *not* show. This is the one that matters. It
|
||||
is how a post states a limitation in context rather than burying it in a
|
||||
closing paragraph.
|
||||
- **Note**: a useful aside.
|
||||
|
||||
### Images
|
||||
|
||||
Every image requires alt text; the editor will not let you save without it.
|
||||
Uploads go to a Docker volume on the host, which is backed up separately from
|
||||
Postgres. An image is not reproducible from the pipeline the way school data
|
||||
is.
|
||||
|
||||
## How publishing reaches the live site
|
||||
|
||||
- `/blog`, `/blog/rss.xml` and `/content-sitemap.xml` are rendered per request,
|
||||
so a new post appears immediately.
|
||||
- `/blog/[slug]` is cached after its first request. Publishing or editing fires
|
||||
a `revalidatePath` from the collection's `afterChange` hook, which drops that
|
||||
cached copy, so edits appear immediately too.
|
||||
|
||||
If a change doesn't show, it is far more likely the post is still a draft than
|
||||
that the cache is stale.
|
||||
|
||||
## House style
|
||||
|
||||
These rules are why the blog exists. A post that ignores them makes the site
|
||||
read more machine-generated, not less.
|
||||
|
||||
- **First person singular.** "I built", "I found", never "we provide".
|
||||
- **Concrete over general.** "When we were looking at schools in Wandsworth"
|
||||
beats any amount of stated warmth.
|
||||
- **State limits before someone else finds them.** Every post that presents a
|
||||
metric says what it does not show. This is the single strongest signal that a
|
||||
human wrote it: generated content does not volunteer its own weaknesses.
|
||||
- **No mission statements, no "passionate about", no invented team.** There is
|
||||
one person here.
|
||||
- **No em dashes.** They are one of the clearest tells of machine-written
|
||||
prose, which is the whole problem this blog exists to fix. A full stop, a
|
||||
colon, a semicolon or a pair of commas does the job and reads as though a
|
||||
person chose it.
|
||||
- **Short sentences.**
|
||||
- **Never publish a surname, an employer, or a child's name.** The site's author
|
||||
is "Tudor". See `/about`.
|
||||
- **Never invent a figure**, even illustratively. On a site whose whole
|
||||
proposition is official data, a made-up number attached to a real school is
|
||||
the one thing it cannot do, and no illustrative intent survives being
|
||||
screenshotted.
|
||||
File renamed without changes.
+66
-11
@@ -60,22 +60,77 @@ export function track(name: EventName, data?: Payload): void {
|
||||
export type NavigationSource =
|
||||
'search' | 'rankings' | 'compare' | 'detail' | 'place' | 'direct';
|
||||
|
||||
/*
|
||||
* The in-app trail.
|
||||
*
|
||||
* document.referrer is written by the browser only when a *document* loads.
|
||||
* Every internal navigation here is an App Router soft navigation —
|
||||
* history.pushState, no new document — so document.referrer goes on naming
|
||||
* whatever opened the tab (usually nothing, or a search engine) for the whole
|
||||
* session. Reading it to answer "which page did they come from" therefore
|
||||
* returned 'direct' for essentially every in-app journey, including the one
|
||||
* the location layer exists to produce.
|
||||
*
|
||||
* Verified on staging: /schools/brentwood, click a school, the URL becomes
|
||||
* /school/… and document.referrer is still "".
|
||||
*
|
||||
* A module-level trail is the counterpart with exactly the right lifetime. It
|
||||
* survives soft navigation, and it dies on a real document load — which is
|
||||
* precisely when document.referrer becomes meaningful again, so the two cover
|
||||
* each other with no overlap.
|
||||
*/
|
||||
const TRAIL_LIMIT = 4;
|
||||
const trail: string[] = [];
|
||||
|
||||
/** Record a path the user is now on. Called by RouteTrail on every route. */
|
||||
export function recordVisitedPath(path: string): void {
|
||||
if (trail[trail.length - 1] === path) return;
|
||||
trail.push(path);
|
||||
if (trail.length > TRAIL_LIMIT) trail.shift();
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent path that is not the one being viewed.
|
||||
*
|
||||
* Skipping the current path rather than taking trail[length - 2] is what
|
||||
* makes the answer independent of ordering: the trail is written by a
|
||||
* layout-level effect and read by a page-level one, and React orders those by
|
||||
* tree position — not a contract worth resting a measurement on. It also
|
||||
* gives the right answer when the user goes back to a page they came from.
|
||||
*/
|
||||
function previousInAppPath(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const current = window.location.pathname;
|
||||
for (let i = trail.length - 1; i >= 0; i -= 1) {
|
||||
if (trail[i] !== current) return trail[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function classifyPath(p: string): NavigationSource {
|
||||
if (p === '/' || p === '') return 'search';
|
||||
if (p.startsWith('/rankings')) return 'rankings';
|
||||
if (p.startsWith('/compare')) return 'compare';
|
||||
// `/schools/` before `/school/`: they differ by one letter and mean
|
||||
// different things — the location layer versus a single school. Checked
|
||||
// first so the narrower-looking prefix cannot shadow it if either string
|
||||
// is ever edited.
|
||||
if (p.startsWith('/schools/')) return 'place';
|
||||
if (p.startsWith('/school/')) return 'detail';
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
export function getNavigationSource(): NavigationSource {
|
||||
const internal = previousInAppPath();
|
||||
if (internal) return classifyPath(internal);
|
||||
|
||||
// No trail means this is the first page of the document, so the referrer is
|
||||
// the only witness — and an honest one.
|
||||
if (typeof window === 'undefined' || !document.referrer) return 'direct';
|
||||
try {
|
||||
const ref = new URL(document.referrer);
|
||||
if (ref.origin !== window.location.origin) return 'direct';
|
||||
const p = ref.pathname;
|
||||
if (p === '/' || p === '') return 'search';
|
||||
if (p.startsWith('/rankings')) return 'rankings';
|
||||
if (p.startsWith('/compare')) return 'compare';
|
||||
// `/schools/` before `/school/`: they differ by one letter and mean
|
||||
// different things — the location layer versus a single school. Checked
|
||||
// first so the narrower-looking prefix cannot shadow it if either string
|
||||
// is ever edited.
|
||||
if (p.startsWith('/schools/')) return 'place';
|
||||
if (p.startsWith('/school/')) return 'detail';
|
||||
return 'direct';
|
||||
return classifyPath(ref.pathname);
|
||||
} catch {
|
||||
return 'direct';
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@
|
||||
* DfE suppresses individual cells with `c`, and the destination categories sum
|
||||
* to the cohort. So subtracting the published cells from the cohort total
|
||||
* recovers a lone suppressed cell exactly — which is the case on 22% of
|
||||
* mainstream secondaries. The guards below are what stop this module's
|
||||
* consumers doing that by accident, and they are why a percentage is never
|
||||
* reconstructed from a partial sum.
|
||||
* mainstream secondaries.
|
||||
*
|
||||
* The guards here are the SECOND line of defence, not the first. Not drawing a
|
||||
* number does nothing to stop it being computed, so the real fix lives in
|
||||
* backend/data_loader.py::_mask_for_disclosure, which withholds a companion
|
||||
* cell before the figures ever leave the server. These functions keep the UI
|
||||
* honest about what it draws from an already-safe payload.
|
||||
*
|
||||
* See docs/superpowers/specs/2026-08-28-destination-measures-design.md.
|
||||
*/
|
||||
@@ -40,8 +44,6 @@ export interface DestinationCell {
|
||||
export interface DestinationGroup {
|
||||
cohort: number;
|
||||
cells: DestinationCell[];
|
||||
/** Aggregates DfE published itself, keyed by slug. */
|
||||
aggregates: Partial<Record<'sustained_education' | 'sustained_all', DestinationCell>>;
|
||||
}
|
||||
|
||||
/** Display order, which is also bar order: education, then work, then absence. */
|
||||
@@ -80,15 +82,6 @@ export function aggregateCells(
|
||||
return { pupils, percentage: (pupils / cohort) * 100 };
|
||||
}
|
||||
|
||||
/**
|
||||
* R2, the other direction: DfE published this total itself. Showing it beside
|
||||
* the components is safe only when it spans no suppressed component, or two or
|
||||
* more. Exactly one, and the total names the withheld figure.
|
||||
*/
|
||||
export function canRenderPublishedAggregate(components: DestinationCell[]): boolean {
|
||||
return suppressedCount(components) !== 1;
|
||||
}
|
||||
|
||||
/** R1: a bar is drawable only when nothing in the group is withheld. */
|
||||
export function canRenderBar(group: DestinationGroup): boolean {
|
||||
return group.cohort > 0 && group.cells.every(c => c.status === 'published');
|
||||
|
||||
+13
-3
@@ -26,11 +26,21 @@ export const FLAGS_REVALIDATE = 300;
|
||||
const API = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL
|
||||
|| 'http://localhost:8000/api';
|
||||
|
||||
/** Every flag and its value. Never throws: an unreadable flag is a dark one. */
|
||||
export async function getFlags(): Promise<Flags> {
|
||||
/**
|
||||
* Every flag and its value. Never throws: an unreadable flag is a dark one.
|
||||
*
|
||||
* `revalidate` is the caller's, because reading flags pins the whole route to
|
||||
* the lowest revalidate among its fetches. Every SEO route here declares
|
||||
* 604800; gating one at the 300s default would drop it from a weekly cache to
|
||||
* a 5-minute one. Pass the route's own floor and gating costs it nothing.
|
||||
*
|
||||
* The trade is flag-flip latency: a route that revalidates weekly takes up to
|
||||
* a week to notice a flip. Pass a smaller number where a flip must land fast.
|
||||
*/
|
||||
export async function getFlags(revalidate: number = FLAGS_REVALIDATE): Promise<Flags> {
|
||||
try {
|
||||
const res = await fetch(`${API}/flags`, {
|
||||
next: { revalidate: FLAGS_REVALIDATE },
|
||||
next: { revalidate },
|
||||
});
|
||||
if (!res.ok) return {};
|
||||
return await res.json();
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { SITE_URL, absoluteUrl } from '@/lib/site';
|
||||
|
||||
/**
|
||||
* The site's author entity.
|
||||
*
|
||||
* First name only, by choice — see /about. That makes this a weaker search
|
||||
* signal than a fully identified author would be, which is why the About page
|
||||
* carries a substantial methodology section: the credibility has to come from
|
||||
* stated provenance rather than from a corroborable identity.
|
||||
*
|
||||
* Everything that needs an author — the About page, every post byline —
|
||||
* references this one shape, so search engines resolve them all to one entity.
|
||||
*/
|
||||
export function personJsonLd() {
|
||||
return {
|
||||
'@type': 'Person',
|
||||
'@id': `${SITE_URL}/about#tudor`,
|
||||
name: 'Tudor',
|
||||
url: absoluteUrl('/about'),
|
||||
image: absoluteUrl('/brand/tudor.jpg'),
|
||||
description:
|
||||
'Parent in south-west London who built schoolcompare while looking for a primary school.',
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function organizationJsonLd() {
|
||||
return {
|
||||
'@type': 'Organization',
|
||||
'@id': `${SITE_URL}#organization`,
|
||||
name: 'schoolcompare',
|
||||
url: SITE_URL,
|
||||
logo: absoluteUrl('/icon-512.png'),
|
||||
} as const;
|
||||
}
|
||||
|
||||
interface PostSummary {
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* References the Person and Organization by @id rather than repeating them, so
|
||||
* search engines resolve every post and the About page to the one author
|
||||
* entity. Repeating the shape would declare several people with one name.
|
||||
*
|
||||
* `namedAuthor` is the about_page flag. The Person entity is anchored at
|
||||
* /about#tudor, and that URL 404s while the flag is dark, so a post published
|
||||
* in that state must not claim it: an author @id resolving to nothing is a
|
||||
* worse signal than no named author. It falls back to the publisher, which is
|
||||
* always live. The parameter is required rather than defaulted because every
|
||||
* call site has the flag to hand and the wrong default is silent.
|
||||
*/
|
||||
export function blogPostingJsonLd(
|
||||
post: PostSummary,
|
||||
{ namedAuthor }: { namedAuthor: boolean },
|
||||
) {
|
||||
return {
|
||||
'@type': 'BlogPosting',
|
||||
headline: post.title,
|
||||
description: post.excerpt,
|
||||
url: absoluteUrl(`/blog/${post.slug}`),
|
||||
datePublished: post.publishedAt,
|
||||
author: {
|
||||
'@id': namedAuthor ? `${SITE_URL}/about#tudor` : `${SITE_URL}#organization`,
|
||||
},
|
||||
publisher: { '@id': `${SITE_URL}#organization` },
|
||||
} as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* A place the location layer publishes a page for, as the school API reports
|
||||
* it. `count` is what lets a link say "All 37 schools in Brentwood" rather
|
||||
* than "click here".
|
||||
*/
|
||||
export interface SchoolPhasePage {
|
||||
phase: string;
|
||||
count: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SchoolPlace {
|
||||
kind: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
count: number;
|
||||
url: string;
|
||||
/**
|
||||
* The phase variants this school is actually listed on: usually one, two
|
||||
* for an all-through school, none for an outcode, which publishes no phase
|
||||
* route. Decided by the place registry, never re-derived here.
|
||||
*/
|
||||
phases: SchoolPhasePage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The trail a school page sits at the end of: Schools → authority → town.
|
||||
*
|
||||
* Only authority and town/locality appear. An outcode is a useful link in the
|
||||
* module beside this — a parent does search "schools near CM15" — but it is
|
||||
* not a step anyone navigates through, and a breadcrumb that claims otherwise
|
||||
* describes a hierarchy the site does not have.
|
||||
*
|
||||
* Levels are skipped rather than faked. A school whose town falls below the
|
||||
* publish threshold has no town page, so the trail closes over the gap; the
|
||||
* alternative is a breadcrumb linking to a 404.
|
||||
*/
|
||||
export function schoolBreadcrumbJsonLd(
|
||||
school: { name: string; url: string; places: SchoolPlace[] },
|
||||
) {
|
||||
/*
|
||||
* Rooted at the homepage, not at /schools. There is no /schools index page
|
||||
* — the location layer is /schools/[place], /schools/authority/[la] and
|
||||
* /schools/near/[outcode], with nothing at the bare path — so a trail
|
||||
* starting there would open with a link to a 404.
|
||||
*/
|
||||
const trail: Array<{ name: string; url: string }> = [
|
||||
{ name: 'schoolcompare', url: '/' },
|
||||
];
|
||||
|
||||
const authority = school.places.find((p) => p.kind === 'authority');
|
||||
if (authority) trail.push({ name: authority.name, url: authority.url });
|
||||
|
||||
const town = school.places.find((p) => p.kind === 'town' || p.kind === 'locality');
|
||||
if (town) trail.push({ name: town.name, url: town.url });
|
||||
|
||||
trail.push({ name: school.name, url: school.url });
|
||||
|
||||
return {
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: trail.map((step, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: step.name,
|
||||
item: absoluteUrl(step.url),
|
||||
})),
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function breadcrumbJsonLd(post: PostSummary) {
|
||||
return {
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Blog',
|
||||
item: absoluteUrl('/blog'),
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: post.title,
|
||||
item: absoluteUrl(`/blog/${post.slug}`),
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getPayload } from 'payload';
|
||||
import config from '@payload-config';
|
||||
import type { Payload } from 'payload';
|
||||
|
||||
/**
|
||||
* One Payload instance per process. getPayload() is itself memoised by
|
||||
* Payload, but routing every caller through here keeps the config import in a
|
||||
* single place and gives page code one name to mock in tests.
|
||||
*
|
||||
* Never call this at module scope: CI builds the image with no database
|
||||
* reachable, so a build-time connection attempt fails the build.
|
||||
*/
|
||||
export function getCachedPayload(): Promise<Payload> {
|
||||
return getPayload({ config });
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Where Payload mounts, defined once.
|
||||
*
|
||||
* These are imported by payload.config.ts and asserted by
|
||||
* __tests__/payload/routes.test.ts. They live in their own module because
|
||||
* payload.config.ts cannot be imported from a Jest test: Payload ships
|
||||
* ESM-only, and next/jest's transformIgnorePatterns skips node_modules — you
|
||||
* cannot un-ignore a package by appending patterns, and forcing it through
|
||||
* `transpilePackages` would change how the production build bundles Payload
|
||||
* to serve a test. Keeping the values here makes them testable without
|
||||
* loading Payload at all.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Payload's API base. It must NOT be '/api': that path belongs to
|
||||
* app/(frontend)/api/[...path]/route.ts, a catch-all that proxies to FastAPI.
|
||||
* It would swallow every admin API call and forward it to the backend, and
|
||||
* the failure is silent — no error, just wrong responses.
|
||||
*/
|
||||
export const PAYLOAD_API_ROUTE = '/cms-api';
|
||||
|
||||
/** The admin panel. Kept out of the index by robots.txt and X-Robots-Tag. */
|
||||
export const PAYLOAD_ADMIN_ROUTE = '/admin';
|
||||
+11
-2
@@ -1,3 +1,5 @@
|
||||
import type { SchoolPlace } from '@/lib/jsonld';
|
||||
|
||||
/**
|
||||
* TypeScript type definitions for SchoolCompare API
|
||||
* Generated from backend/models.py and backend/schemas.py
|
||||
@@ -346,6 +348,15 @@ export interface SchoolsResponse {
|
||||
|
||||
export interface SchoolDetailsResponse {
|
||||
school_info: School;
|
||||
/**
|
||||
* The published location-layer pages containing this school, widest first.
|
||||
*
|
||||
* Optional because the frontend and backend ship as separate images: a
|
||||
* frontend deployed ahead of the API that serves this must render without
|
||||
* it, not throw. Empty is also a real answer — a school whose town and
|
||||
* authority both fall below the publish threshold has nowhere to link.
|
||||
*/
|
||||
places?: SchoolPlace[];
|
||||
yearly_data: SchoolResult[];
|
||||
absence_data: AbsenceData | null;
|
||||
// Supplementary data (null until Kestra populates)
|
||||
@@ -616,8 +627,6 @@ import type { DestinationCell, PupilGroup } from './destinations';
|
||||
export interface DestinationGroupPayload {
|
||||
cohort: number | null;
|
||||
categories: DestinationCell[];
|
||||
/** Totals DfE published itself. Never computed here — see lib/destinations.ts. */
|
||||
aggregates: Partial<Record<'sustained_education' | 'sustained_all', DestinationCell>>;
|
||||
}
|
||||
|
||||
export interface DestinationPhase {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user