0da8ab052b0fbb0eae359ee673d1dd2bdb6791df
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0da8ab052b |
fix(seo): canonicalise on the www host, which is the one that serves 200
The apex 301s to www at Cloudflare, but metadataBase, the school-page canonical, robots.txt's Sitemap: line and the sitemap's own <loc> entries all named the apex. Every one of those pointed Google at a redirect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj |
||
|
|
c9a1892bfb |
feat(admissions): publish the latest cut-off only, holding history back
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m17s
Earlier years are to become a paid feature, so they stop being published.
The load-bearing part is that this is a change to the API, not only to the
page. /api/schools/{urn} is public and unauthenticated: leaving
admission_distance_history in the payload while declining to render it would
have handed the whole record to anyone who opened the network tab. It is
withheld at the source, and the page follows.
Nothing changes upstream. The tap, the plausibility band and
fact_admission_distance are untouched and still load every published year, so
restoring history for entitled callers is a change to one function in
data_loader rather than a re-collection.
What the reader now gets is the latest figure on the Admissions tile, and a
Distance section that answers the question the number alone cannot: whether
their own address falls inside it. Retitled to "How far away are you?", which
is what it now does — the previous title described a record that is no longer
there.
Removed with the history: the trend chart, the year-by-year table, the
per-year verdict strip, the trend summary and the coverage note, along with
their CSS. The section goes from 743px to 417px.
One consequence worth naming. A run of years used to soften a single close
call — a home just outside one year's cut-off was usually inside another. With
one year published, the "too close to call" band is the entire safety margin
between a parent and a place they do not have, so the verdict now names its
year, and the three outcomes are tinted apart rather than distinguished by
wording alone.
The existing stylesheet test earned its keep here: the three verdict classes
were referenced before they were written, and it caught them. Unstyled, a
"beyond the cut-off" result would have been indistinguishable from an "inside"
one — the exact failure the longhand class map was written to prevent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
|
||
|
|
94151c58ea |
fix(admissions): move the cut-off detail into its own section
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 13s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m59s
The admissions card measured 1503px on a live school page — half the height of every section put together, and nearly three times the next largest — with its default view rendering as four tiles adrift in about 1080px of blank card. The cause was a layout trick meeting content it was never sized for. The admissions views are stacked in one grid cell so switching them never shifts layout, and the hidden ones keep their box: only visibility is dropped. That works while the views are comparable. The distance view added in #102 carries a chart, a table and a map, came to 1402px against the tile grid's 316px, and pinned every other view to its height — including the one that renders by default, which nobody had clicked. Rather than only unpinning it, the detail moves out. Every other topic on the page is a section with a nav entry, and "how close did we need to live, and would we have got in?" is a topic, not a variant reading of the intake figures. The headline number stays on the Admissions tile where the intake story is; the record behind it now lives in a Distance section directly below. admissions 1503px -> 554px distance new -> 743px (median section on the page is ~528px) Three further changes, each of which also makes the content better rather than only shorter: * The map renders on request. Before a postcode is entered it is a circle drawn round a school, and it costs a Leaflet bundle and 240px to say so; a successful check opens it automatically, which is the point at which it starts answering something. Map height 320px -> 240px. * The chart appears only at the four published years that let the summary state a direction. Below that we already refuse to call the series a trend, and a line through three points asserts one regardless of what the sentence beneath it admits. The table carries those years anyway, with the reasons a line cannot show. * Three caveat paragraphs become one. They said walking-route twice and made the same point about priorities in two voices. It now sits in CutoffDistanceDetail rather than inside the check, so it still renders for a school with coordinates missing, where there is a table but no map and no check. The new e2e guard asserts no section exceeds 2.5x the median section height. Measuring the card's internals cannot catch this: the tile grid is flex: 1, so it absorbs the stretch and every box still looks full. The first version of this test targeted an arbitrary primary, passed against the live bug, and proved nothing; pointed at a school that actually holds cut-off history it fails on staging with "#admissions is 1459px against a 526px median". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE |
||
|
|
a72323874f |
feat(admissions): add the cut-off history, map and postcode check
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m39s
Completes the last-distance-offered feature against the mockup: the year-by-year record, the same numbers drawn over real streets, and the reader's own address measured against them. Serving the history The first cut deliberately served only the latest year, because a plain series would draw a trend line straight through gaps that are absences of publication, not of a cut-off. That reasoning is answered rather than abandoned: cutoffYearRows classifies every year in the span, and the chart breaks the line rather than interpolating across it. A missing year is not one fact but three. It may be unpublished; it may be a year the school was not oversubscribed; or there may be no record at all. Collapsing them into "no data" throws away the reassuring case and hides the important caveat, so each is stated in words in the table. The claim is held to what the data supports. fact_admissions.oversubscribed compares FIRST PREFERENCES against places, which does not establish that every applicant was offered one — so the copy says "places available on first preferences" and a test asserts the stronger claim never appears. The trend summary is not a verdict It names both endpoints and their years and lets the reader conclude. It is withheld below four published points, and a swing under a tenth of the earlier figure is reported as "broadly the same" rather than dressed up as a direction. The postcode check This is the only place on the site that answers a question about a family rather than a school, so most of the care went into what it refuses to say. postcodes.io returns a centroid covering roughly fifteen addresses, which against a 500 m cut-off is a fifth of the whole distance — so a margin inside 100 m returns "too close to call" rather than a place a family does not have. Unpublished years count as unknown, never as a pass. The limits are stated before the check is used, not revealed with the answer. The postcode is geocoded in the browser and never stored. Both templates Banded and selective secondaries are exactly where this matters most, so the detail is shared. The primary page gives it a third tab; the secondary page is one flat panel by design and renders it inline. Absence is explained rather than reported. A selective school's missing figure is explained by how it admits; a consistently undersubscribed school reads as good news. Also makes the batch loader's test double honour ORDER BY. It was a no-op, so "latest row per URN" was really "first row in the fixture" and the test would have passed with the sort reversed or removed. Verified: 214 frontend tests, 54 backend, 45/53 e2e green against staging (the 8 cut-off journeys skip until the DAG runs). Rendered offline against the real compiled CSS in both themes and at 390px; every new surface clears WCAG AA, measured on composited pixels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE |
||
|
|
88c653215d |
feat(admissions): show the last distance offered where councils publish it
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 31s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m7s
Adds the cut-off distance a parent actually asks about — "how close do we
need to live?" — end to end: a Singer tap, dbt staging and mart models, an
Airflow DAG, and a tile on both detail templates. 3,597 schools across 57
local authorities carry a figure; the rest are unchanged.
There is no national source for this. Each LA publishes its own cut-offs in
its own format, and the collected CSV is transcribed from PDFs, spreadsheets
and web pages — so most of the work here is deciding what is safe to show.
Data
* tap-uk-school-distance loads the CSV verbatim into raw. Keyed on
(urn, year, school_name), because school_name carries the admission
route: (urn, year) alone collides on 118 keys and a reload would have
silently dropped every band but one.
* stg_school_distance applies a 25 m – 25 km plausibility band. The source
contains 0.0-mile rows (published where a school filled on a higher
criterion), 1-metre cut-offs, and one reading 533 miles — ~4% of rows,
all of which would put a visibly wrong number on a live page.
* fact_admission_distance collapses routes to one row per school per year
using the furthest, and keeps route_count so the page can say the figure
is the widest of several bands rather than the one for a given child.
Serving
* Kept out of fact_admissions: that mart is EES-derived and near-complete
for England, this one covers 57 LAs, and the two refresh independently.
* Latest year only. Coverage is ragged — a school may have 2021 and 2026
and nothing between — so a history array would invite a trend line drawn
through gaps that are absences of publication, not of a cut-off.
* The Admissions section now renders on either source. 3% of the schools
that render have a cut-off and no EES admissions row, and gating on
admissions alone would have hidden the figure on those pages.
Interface
* The year travels with the figure everywhere it appears; a cut-off
detached from its admissions round is not a fact about anything.
* "Not a fixed catchment — it moves every year" sits under every instance,
because that is the inference a parent will otherwise draw.
* Replaces a hardcoded "Historical distance cut-off data is not available
for this school" that appeared on every secondary page, including the
ones whose council does publish it. The absence is now stated only when
it is real, and names the authority that would hold it.
The tint costs the muted tokens their AA margin: measured on the composited
backdrop (not the computed one, which reports the untinted card), --text-muted
falls to 4.09:1 in dark theme. The tile uses --text-secondary instead — 6.50:1
dark, 6.60:1 light.
The DAG is manual, like the other annual ones: councils publish on allocation
day, each on its own timetable, so there is no date worth scheduling against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WDvkyqqHABm4bmth2kjAxE
|
||
|
|
39e5c11274 |
fix(brand): match the below-average tint to its own hue, and finish the casing
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
PR Checks / Backend Smoke (pull_request) Successful in 6s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 43s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m19s
Three review findings. --status-below-bg carried the raw --coral tuple (249,115,96) instead of the hue it belongs to (#A9481F → 169,72,31), so "below average" chips rendered a coral-pink wash behind terracotta text — the one solid/tint pair in the file whose hues disagreed. Alpha drops 0.15 → 0.12 to match --status-above-bg, so the two chips read as equal weight; left at 0.15 the darker terracotta tint takes the label to 4.45:1 on Warm White, under the 4.5 floor. At 0.12 it is 4.86:1 on cards and 4.67:1 on Warm White. Auditing the whole file for the same class of error turned up one more: --action-bg was also a --coral tint while --action is the darkened #BE3C27. It is unused today and the dark-theme value was already correct, but it is aligned so a future coral ghost button gets a matching label and ground. The five phase pairs also "mismatch" by the same test and are deliberately left alone: those pair a pastel ground (sage, sky, mustard, lavender) with a deeper text hue, which is exactly what the guideline's Chips/Tags show. The lowercase wordmark had only reached the visual chrome. Every user-visible occurrence now matches: page title and template, appleWebApp title, authors, OpenGraph and Twitter titles and siteName, the school page's siteName, the PWA manifest name and short_name, the footer copyright line, the nav's aria-label, the Web Share API strings, and the editorial paragraph. Comments in lib/*.ts and AdmissionsView still say "SchoolCompare"; they describe the codebase rather than the mark, so they are left to keep this diff to the reported problem. The comment explaining why the font classes belong on <html> still described --font-schibsted, which no longer exists. It now names --font-manrope and --font-inter, and records that the e2e typeface assertion is the only guard — this failure mode throws nothing and fails no unit test. Verified: tsc clean, 159/159 tests, build green, and the rendered title is now "School Admissions Guide | schoolcompare". Status and phase labels re-checked on their own composited tints over both light grounds — all clear AA. |
||
|
|
803e68970c |
refactor(detail): drop dead derived state from the shell
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m4s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m12s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 9m28s
Leftover from the mechanical extraction: the shell still called computeSchoolFlags() and derived isReportCard / ofstedInspectedDate / oeifAreas / oeifAllSameGrade / deprivationDesc / primaryAvg / secondaryAvg on every render, duplicating work page.tsx already does. None of those values were referenced in its JSX anymore -- that logic moved to the section composers. The chrome needs only four locally-derived values (latestResults, phase, isAllThrough, hasLocation), all one-liners over props it already owns. Removing them made seven props dead, which TypeScript caught at both call sites: absenceData, ofsted, admissions, admissionsHistory, deprivation, finance and nationalAvg now go straight to the section composers and never reach the client component. The shell's surface is down to schoolInfo, yearlyData, census, navItems and children. No behaviour change: 155 tests pass and the characterization tests remain byte-identical to the commit that introduced them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
752eb07310 |
refactor(detail): render sections on the server behind a client shell
page.tsx now composes the sections and passes them through SchoolDetailShell
as children, so ~1,300 lines of static markup stop shipping as client
JavaScript. The shell keeps what is genuinely interactive: back link, header
reveal, hero map, compare CTA, sticky nav and scroll-spy.
The scroll-spy already located sections via document.getElementById, so it
works unchanged against server-rendered children.
Charts needed a client wrapper: next/dynamic with ssr:false is illegal in a
Server Component, so components/school/charts.tsx is the boundary that keeps
Chart.js (64 KB gz) lazy and browser-only.
Measured on this build:
- school route client chunk: 8 KB gz (33 KB raw)
- total static JS across all chunks: 380.6 -> 350.7 KB gz
- section markup is absent from every client chunk ("Got their first choice",
"Ofsted reports", "Most deprived" etc. all return 0 hits); shell strings
still present, as expected
- shared baseline unchanged at 172 KB gz -- out of scope, as designed
The 14 characterization tests pass byte-identical to the commit that
introduced them. Only the render helper changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d74cc95034 |
refactor(detail): fetch national averages on the server
Both detail views fetched /api/national-averages in a useEffect, so the England-comparison deltas popped in after hydration and every section that uses them was pinned to the client. The page now fetches it in parallel with the school details (backend-cached 1h, degrades to null) and passes it down. Removes one client round-trip per detail page and unblocks the section extraction. Characterization tests pass unmodified; only the render helper changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0186227ced |
feat(detail): surface GIAS identity/contact details, drop unwired sections
PR Checks / Frontend Typecheck + Tests (pull_request) Canceled after 58s
PR Checks / Backend Smoke (pull_request) Canceled after 0s
PR Checks / Build Backend (no push) (pull_request) Canceled after 0s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 0s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s
Add seven school-identity fields to the detail header (both primary and secondary views): age range, religious character, nursery and sixth-form indicators as chips; telephone (tel: link), county and parliamentary constituency as header details. religious_denomination, age_range and has_sixth_form were already served; telephone, nursery_provision, county and parliamentary_constituency are newly wired through the marts query (with a NULL fallback for un-rebuilt marts, mirroring has_sixth_form) and the school_info API response. Remove three UI sections the backend never populated (always null): Year 1 Phonics, the SEN "types of additional needs" breakdown, and the average class-size card — along with their now-dead props, route plumbing, and the SenDetail/Phonics types + class_size_avg field. Extend the e2e detail journey to assert the Phonics section is gone and the new header fields render when the record carries them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
95081d38bd |
chore: remove the Ofsted Parent View feature end to end
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 45s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m35s
Removes the 'What Parents Say' section and all supporting elements: Frontend: - Drop the OfstedParentView type, the parent_view field, the survey section and the 'X% would recommend' callouts in the primary and secondary detail views, the Parents nav item, and the parent-view CSS. Backend: - Remove the FactParentView model, its loading in data_loader, and parent_view from the school-details API response. - Bump SCHEMA_VERSION to 6 and add an idempotent drop step (DROP TABLE IF EXISTS marts.fact_parent_view) to the CLI migration; add scripts/sql/drop_fact_parent_view.sql to apply directly to the dbt-owned marts DBs on staging and prod. Pipeline: - Delete the stg_parent_view + fact_parent_view dbt models and their source/schema entries, the tap-uk-parent-view Meltano extractor, and the monthly Parent View DAG; drop it from the Dockerfile and the staging bootstrap docs. The rest of dbt (which builds every mart the app reads) is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4de7e559e9 |
feat(admissions): surface multi-year admissions trend on school detail
Build and Push Docker Images / Build Backend (FastAPI) (pull_request) Successful in 22s
Build and Push Docker Images / Build Frontend (Next.js) (pull_request) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (pull_request) Successful in 11s
Build and Push Docker Images / Trigger Portainer Update (pull_request) Has been skipped
The school detail page only showed the latest admissions year. We store
every year, which is more decision-relevant for parents (the trend and its
consistency matter more than a single noisy year).
Backend now returns the full admissions_history (oldest first) alongside the
existing latest-year object. The primary SchoolDetailView gains a header
toggle ("This year | N-year trend") that swaps the Q&A for an SVG sparkline
of the first-choice offer rate. The toggle only appears when >=2 years carry
an offer rate; otherwise it falls back to the single-year card. Both views
share one CSS-grid cell so switching causes no layout shift.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
62eeee5f7c |
perf: cache aggressively and trim client bundle
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m1s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 2m4s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Frontend - Dynamic-import Chart.js components on detail/compare views so Chart.js no longer ships in initial JS. - Drop force-dynamic on home, compare, rankings so internal data fetches reuse Next.js's per-call revalidate cache. - Switch /school/[slug] to ISR with a 7-day revalidate window (school data updates annually). - Preconnect to analytics + postcodes.io; remove redundant defer on the Umami Script tag (afterInteractive already covers it). - Bump images.minimumCacheTTL to 1 year. - Extract HowItWorks and Editorial sections as server components passed to HomeView via slot props so their JSX stays out of the client bundle. Backend - Add GZipMiddleware (min 512 bytes). - Add CacheAndETagMiddleware: per-path Cache-Control with long s-maxage + stale-while-revalidate, ETag generation, and 304 on If-None-Match. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
6e5249aa1e |
refactor(phase): merge KS2+KS4 into fact_performance, fix all phase inconsistencies
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 50s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 1m12s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m24s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s
Root cause: the UNION ALL query in data_loader.py produced two rows per all-through school per year (one KS2, one KS4), with drop_duplicates() silently discarding the KS4 row. Fixes: - New dbt mart `fact_performance`: FULL OUTER JOIN of fact_ks2_performance and fact_ks4_performance on (urn, year). One row per school per year. All-through schools have both KS2 and KS4 columns populated. - data_loader.py: replace 175-line UNION ALL with a simple JOIN to fact_performance. No more duplicate rows or drop_duplicates needed. - sync_typesense.py: single LATERAL JOIN to fact_performance instead of two separate KS2/KS4 joins. - app.py: remove drop_duplicates (no longer needed); add PHASE_GROUPS constant so all-through/middle schools appear in primary and secondary filter results (were previously invisible to both); scope result_filters gender/admissions_policies to secondary schools only. - HomeView.tsx: isSecondaryView is now majority-based (not "any secondary") and isMixedView shows both sort option sets for mixed result sets. - school/[slug]/page.tsx: all-through schools route to SchoolDetailView (renders both SATs + GCSE sections) instead of SecondarySchoolDetailView (KS4-only). Dedicated SEO metadata for all-through schools. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
784febc162 |
feat(seo): add school name to URLs, fix sticky nav, collapse compare widget
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 32s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 31s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped
- URLs now /school/138267-school-name instead of /school/138267 - Bare URN URLs redirect to canonical slug (backward compat) - Remove overflow-x:hidden that broke sticky tab nav on secondary pages - ComparisonToast starts collapsed — user must click to open Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |