diff --git a/backend/app.py b/backend/app.py index 7836ca7..782dd81 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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_registry, places_for_urn from .schemas import METRIC_DEFINITIONS, RANKING_COLUMNS, SCHOOL_COLUMNS from .utils import clean_for_json, convert_to_native @@ -211,6 +211,28 @@ 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. + + `phase_url` is present only where the place publishes a page for this + school's phase, which is the registry's decision alone — repeating the + threshold rule here is how the page and the sitemap would come to disagree. + """ + payload = [] + for place in places_for_urn(get_place_registry(), int(urn)): + entry = { + "kind": place.kind, + "slug": place.slug, + "name": place.name, + "count": len(place.urns), + "url": _place_url(place), + } + payload.append(entry) + return payload + + def _place_sitemap_rows(kinds: tuple[str, ...]) -> list[str]: """A per place, plus a phase variant wherever that phase clears the threshold on its own. @@ -902,6 +924,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"), diff --git a/backend/places.py b/backend/places.py index 078638c..6476c38 100644 --- a/backend/places.py +++ b/backend/places.py @@ -296,6 +296,23 @@ def _locality_places(df, publishable: set[int], return out +def places_for_urn(registry: dict[str, Place], urn: int) -> tuple[Place, ...]: + """Every published place containing this school, largest kind first. + + The reverse of the registry, and the thing school pages link out through. + Derived from the registry rather than stored beside it, so the two cannot + disagree about which places exist: a link module must never offer a place + whose page does not exist, and a place below the publish threshold is + simply absent from the registry, so it is absent from here too. + + Ordered authority → town/locality → outcode, widest first, because that is + the order a breadcrumb reads and the order the link module lists. + """ + order = {"authority": 0, "town": 1, "locality": 2, "outcode": 3} + found = [p for p in registry.values() if urn in p.urns] + return tuple(sorted(found, key=lambda p: (order.get(p.kind, 9), p.slug))) + + def build_place_registry(df) -> dict[str, Place]: """Every place the site publishes, keyed by ":".""" if df.empty or "urn" not in df.columns: diff --git a/backend/tests/test_places.py b/backend/tests/test_places.py index 99e82e2..c041eda 100644 --- a/backend/tests/test_places.py +++ b/backend/tests/test_places.py @@ -8,7 +8,7 @@ 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_registry, places_for_urn def _df(rows: list[dict]) -> pd.DataFrame: @@ -418,3 +418,54 @@ 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(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(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(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) + )) + for key, place in reg.items(): + for urn in place.urns: + assert place in places_for_urn(reg, urn), ( + f"{urn} is in {key} but the index does not say so") diff --git a/backend/tests/test_school_details.py b/backend/tests/test_school_details.py index f2a20ac..a16bc0b 100644 --- a/backend/tests/test_school_details.py +++ b/backend/tests/test_school_details.py @@ -69,3 +69,60 @@ 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/") diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index cfb614f..2855b4f 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -1935,6 +1935,50 @@ 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"'); + + // 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'], diff --git a/nextjs-app/__tests__/components/NearbyPlaces.test.tsx b/nextjs-app/__tests__/components/NearbyPlaces.test.tsx new file mode 100644 index 0000000..9604bb4 --- /dev/null +++ b/nextjs-app/__tests__/components/NearbyPlaces.test.tsx @@ -0,0 +1,55 @@ +/** + * 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' }; +const brentwood = { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 37, url: '/schools/brentwood' }; +const cm15 = { kind: 'outcode', slug: 'cm15', name: 'CM15', count: 12, url: '/schools/near/cm15' }; + +describe('NearbyPlaces', () => { + it('links to every place the school belongs to', () => { + render(); + + 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(); + 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(); + 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(); + 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(); + expect(screen.getByRole('link', { name: /1 school in Brentwood/ })) + .toBeInTheDocument(); + }); +}); diff --git a/nextjs-app/__tests__/lib/schoolJsonLd.test.ts b/nextjs-app/__tests__/lib/schoolJsonLd.test.ts new file mode 100644 index 0000000..ea68081 --- /dev/null +++ b/nextjs-app/__tests__/lib/schoolJsonLd.test.ts @@ -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' }; +const brentwood = { kind: 'town', slug: 'brentwood', name: 'Brentwood', count: 37, url: '/schools/brentwood' }; +const outcode = { kind: 'outcode', slug: 'cm15', name: 'CM15', count: 12, url: '/schools/near/cm15' }; + +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/'); + }); +}); diff --git a/nextjs-app/app/(frontend)/school/[slug]/page.tsx b/nextjs-app/app/(frontend)/school/[slug]/page.tsx index be0674f..9b3649f 100644 --- a/nextjs-app/app/(frontend)/school/[slug]/page.tsx +++ b/nextjs-app/app/(frontend)/school/[slug]/page.tsx @@ -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 ( @@ -264,6 +288,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) { /> )} + ); } diff --git a/nextjs-app/components/school/NearbyPlaces.module.css b/nextjs-app/components/school/NearbyPlaces.module.css new file mode 100644 index 0000000..3ef4477 --- /dev/null +++ b/nextjs-app/components/school/NearbyPlaces.module.css @@ -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; +} diff --git a/nextjs-app/components/school/NearbyPlaces.tsx b/nextjs-app/components/school/NearbyPlaces.tsx new file mode 100644 index 0000000..2af24a8 --- /dev/null +++ b/nextjs-app/components/school/NearbyPlaces.tsx @@ -0,0 +1,51 @@ +import Link from 'next/link'; +import type { SchoolPlace } 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 = { + 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}`; +} + +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 ( +
+

More schools near here

+
    + {sorted.map((place) => ( +
  • + {label(place)} +
  • + ))} +
+
+ ); +} diff --git a/nextjs-app/lib/jsonld.ts b/nextjs-app/lib/jsonld.ts index 41435e2..aec423a 100644 --- a/nextjs-app/lib/jsonld.ts +++ b/nextjs-app/lib/jsonld.ts @@ -69,6 +69,63 @@ export function blogPostingJsonLd( } 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 SchoolPlace { + kind: string; + slug: string; + name: string; + count: number; + url: string; +} + +/** + * 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', diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index a19d250..927d301 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -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)