The three at-a-glance stats were misaligned — the serif "Outstanding" tile
sat noticeably above the "70%" and "64%" numerals because the serif variant
used a smaller font. That pushed its label row ("INSPECTED NOVEMBER 2023")
up too, breaking the horizontal rhythm across the row.
- Give .heroStatNumber and .heroStatNumberSerif a shared min-height tied
to the largest clamp value, plus display: flex; align-items: flex-end.
Content bottom-aligns inside the box, so every stat's label sits at the
same Y regardless of how tall the actual glyph is.
- Bump the serif variant up slightly (1.75rem → 2.25rem clamp) so it
feels closer in weight to the numerals while still leaving room for
longer words like "Requires Improvement".
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The staging model was mapping EES column ``proportion_1stprefs_v_totaloffers``
straight onto ``first_preference_offer_pct``. That raw column is not a
percentage — it is a ratio of first-preference applications to total offers
(an oversubscription indicator, >1 means oversubscribed), so OLQH rendered
as "1%" when the true first-choice success rate is 27/42 = 64%.
The frontend display code is not at fault and is not patched here —
data-quality issues must be fixed at the source.
- stg_ees_admissions: compute ``first_preference_offer_pct`` as
``100 * number_1st_preference_offers / times_put_as_1st_preference`` —
of families who listed this school first, the % that received an offer
(0–100). Guard against divide-by-zero.
- stg_ees_admissions: expose the legitimate EES ratio as the new column
``oversubscription_ratio`` (1st-preference applications per place) for
future use, clearly named.
- fact_admissions, FactAdmissions model, data_loader: propagate the new
``oversubscription_ratio`` column.
- SchoolAdmissions type: document both columns inline.
- buildSchoolSummary: reword the oversubscription clause so it reads
sensibly across the whole 0–100 range (no more "just 64%").
- Hero chip subtitle: clearer phrasing "X% of first-choice applicants
offered a place".
Requires a dbt run of stg_ees_admissions and fact_admissions on deploy
so the new column materialises.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- heroChip: swap ``min-width: 180px; flex: 0 1 auto`` for
``flex: 0 0 240px`` so every chip in the strip is the same width
regardless of content. Title gets nowrap + ellipsis as insurance
against accidental overflow.
- heroChipTitle / Sub / Detail: align line-heights (1.3 / 1.4 / 1.4)
so an OEIF chip (title + sub) and a Report Card chip
(title + sub + detail) sit on the same vertical rhythm.
- heroSummary: drop the 64ch max-width — the sentence should read at
the natural hero width.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Drop the "Above national average" chip — the DeltaChip under the 70% /
Attainment 8 number already carries the same signal, so the chip was
duplicative and added noise.
- At-a-glance stats: switch from grid(auto-fit) to flex with a fixed
3rem column gap so the numbers cluster at the start of the row rather
than spreading across the full width of the header card.
- Add to Compare button: larger padding, bumped font, soft shadow, and
self-align centre so it sits in balance with the bigger headline
rather than floating tiny in the top-right corner.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The API returns ``framework`` as the literal string "NULL" for older OEIF
inspections (it comes from the upstream ``event_type_grouping`` column),
not real null. The original render path checks ``=== 'ReportCard'`` and
correctly treats anything else as OEIF — but buildOfstedHeroChip inverted
that and treated anything not exactly equal to ``'OEIF'`` as Report Card,
so OLQH (inspected Nov 2023, Outstanding) was being labelled as a Report
Card school in the hero strip and the at-a-glance tile.
- Invert the helper: only branch into Report Card when framework is
explicitly ``'ReportCard'``; treat OEIF / null / "NULL" / anything else
as OEIF, and require ``overall_effectiveness`` to render the grade word.
- Replace the toneClass field (which reused .ofstedGrade{N} / .rcGrade{N}
badge classes and dragged in their backgrounds) with a clean tone enum
``teal | green | gold | coral | neutral``. The serif Ofsted heroStat
picked up the badge background and rendered as a green box around
"Report Card" — gone now.
- Hero chip backgrounds use color-mix() against the tone variable so all
five tones share one rule.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Elevates the primary school detail hero from a flat report header into a
scannable editorial block. Parents can read the headline signal in seconds.
- A1: bump .schoolName to clamp(2rem, 5vw, 3.25rem) Playfair.
- A2: framework-aware signal chip strip via new buildOfstedHeroChip() helper.
Branches on ofsted.framework so Report Card schools never show a fake
overall grade — they get "Ofsted Report Card" + inspection date +
Safeguarding: Met/Not met. OEIF schools keep the grade word.
- A3: oversized Playfair stats — Reading, Writing & Maths % (primary) or
Attainment 8 (secondary) with inline DeltaChip vs national, Ofsted
verdict with tone colouring, and first-choice offer rate.
- B1: italic serif one-sentence summary via buildSchoolSummary() helper,
also framework-aware so Report Card schools are described by framework,
not a synthetic grade.
- C1: new DeltaChip component reused in the two headline KS2 metric cards
(rwm_expected_pct, rwm_high_pct).
All copy uses "Reading, Writing & Maths" in full. Secondary detail view
untouched in this slice.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The new phase inference can legitimately leave ~1100 independent schools with
null phase (no GIAS phase, no statutory ages, name gives no hint). That's a
known data quality gap, not a pipeline failure — the UI already handles null
by showing no pill. Downgrade the test to warn so it stays visible in dbt
output without blocking the DAG.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The case-insensitive "Not Applicable" fix caught schools where GIAS publishes
statutory ages, but some independent schools leave those blank too — they fall
through every branch and end up with null phase and no pill in the UI.
Add a third tier that infers phase from the school name
(Primary/Infant/Junior/Prep vs Secondary/High/Grammar/Senior/Upper) and also
normalise "Not Applicable" handling with trim() + "unknown"/"" exclusion, so
the final else branch can safely return null instead of the catch-all string.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- rankings: hide Type/Action columns on mobile so metric value stays visible;
ensure filter selects and table wrapper stay within viewport
- school detail: add min-width:0 / max-width:100% containment so internal
overflow-x wrappers actually clip rather than pushing the page wider;
explicit line-height on Ofsted grade badges to fix glyph clipping
- compare: sticky first column on the Detailed Comparison table so the Year
labels remain visible while horizontally scrolling school columns
- search: shorten placeholder to "School name or postcode" so it fits mobile
input width
- globals: overflow-x:clip safety net on .main wrapper
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Expand the abbreviation in metric names (backend schemas), the home page
sort dropdown, README/QA docs, and pipeline comments. Short_name fields
and the compact row/map-card labels remain abbreviated for space.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
GIAS provides 'Not Applicable' (capital A) but the check used 'Not applicable',
so the case-sensitive != matched true and skipped the age-range inference.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Independent schools have phase='Not applicable' in GIAS. Now infer
phase from statutory age range: <=11 → Primary, >=11 → Secondary,
spans both → All-through. Falls back to original value if no age data.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add coloured left-border and phase label pill to visually differentiate
school phases (Primary, Secondary, All-through, Post-16, Nursery) in
search result lists. Colours are accessible (WCAG AA) and don't clash
with existing Ofsted/trend semantic colours.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Apply formatAcademicYear to all year displays in ComparisonChart, ComparisonView,
PerformanceChart, and RankingsView. Remove old vanilla JS frontend and CSV data
directory — both superseded by the Next.js app and Meltano pipeline.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Old DfE CSVs encode percentages as "57%" not "57". The safe_numeric
macro rejects non-numeric strings, so strip the suffix before emitting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The file hosting uses non-deterministic URLs, so replace legacy_ks2_base_url
+ legacy_ks2_years with a single legacy_ks2_urls object mapping year codes
to download URLs. Configure the 4 pre-COVID years in meltano.yml.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add LegacyKS2Stream to tap-uk-ees: downloads old DfE england_ks2final.csv
files from a configurable base URL, maps 318-column wide format to the
same schema as stg_ees_ks2 output
- Add stg_legacy_ks2.sql staging model with safe_numeric casts
- Add legacy_ks2 source to _stg_sources.yml
- Update int_ks2_with_lineage.sql to union EES + legacy data
- Configurable via legacy_ks2_base_url and legacy_ks2_years tap settings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Older census CSVs use 'URN' (uppercase) while the stream expects 'urn'.
Normalise the column name before filtering and emitting records.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Older census (and other) files don't include a time_period column.
Derive it from the release slug (e.g. '2022-23' → '202223') and inject
it into records so the required Singer schema field is always present.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add get_all_release_ids() to paginate /publications/{slug}/releases and
iterate over every release in get_records(). Add latest_only config flag
(default false) to restore single-release behaviour for daily runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
These params were present in the URL but never passed to fetchSchools on
the server side, so the backend never applied the filters. Also include
them in hasSearchParams so filter-only searches trigger a fetch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sort was local state, lost on navigation. Now reads from
searchParams.get('sort') and pushes to URL on change.
'default' removes the param to keep URLs clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace fixed 480px height with calc(100vh - 280px) so the map
fills most of the viewport on any screen size, clamped between
520px and 800px.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Button sits top-right of the map (matching Leaflet control style),
toggles expand/compress icon, and syncs state with Escape key via
the fullscreenchange event.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove 10-mile radius option; cap backend radius max at 5 miles
- Raise backend page_size max to 500 so map can fetch all schools in one call
- HomeView: when map view is active, fetch all schools within radius
(page_size=500) instead of showing only the paginated first page;
falls back to initial SSR schools while loading
- SchoolMap/LeafletMapInner: accept referencePoint prop and render a
distinctive coral circle pin at the search postcode location
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Load-more requests read URL params (postcode, radius, etc.) but page_size
is never in the URL — it's hardcoded in page.tsx. Without it the backend
received page_size=None, hit a TypeError on (page-1)*None, returned 500,
and the silent catch left the user stuck on page 1.
In a dense area (e.g. Wimbledon SW19) 50 schools fit within ~1.8 miles,
so page 1 never shows anything beyond that regardless of selected radius.
Fix:
- Backend: give page_size a safe default of 25 instead of None
- Frontend: explicitly pass initialSchools.page_size in load-more params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase is a common filter (primary vs secondary) so it now appears
between the search form and the Advanced filters toggle rather than
being hidden inside the collapsible section.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Backend builds sitemap.xml from school data at startup (in-memory)
- POST /api/admin/regenerate-sitemap refreshes it after data updates
- New Airflow DAG (sitemap_generate) runs Sundays 05:00 and calls the endpoint
- Next.js proxies /sitemap.xml to the backend; removes the slow dynamic sitemap.ts
- docker-compose passes BACKEND_URL + ADMIN_API_KEY to Airflow env
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace tab-based show/hide with always-visible sections and anchor
link navigation, matching the primary school detail page behaviour.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 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>
The overview tab was sparse for schools without parent view data, showing
only 2 cards. Now shows:
- Individual Ofsted grades when no overall effectiveness (post-Sept 2024)
- Admissions summary card (PAN, applications, 1st choice rate)
- School context card (pupils, capacity, SEN support, EHCP)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Schools with phases like "All-through" or null phase but with GCSE data
were falling through to the primary SchoolDetailView, rendering only
partial content. Now checks yearly_data for attainment_8_score as well.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. Simpler home page: only search box on landing, no filter dropdowns
2. Advanced filters: hidden behind toggle on results page, auto-open if active
3. Per-school phase rendering: each row renders based on its own data
4. Taller 4-line rows with context line (type, age range, denomination, gender)
5. Result-scoped filters: dropdown values reflect current search results
6. Fix blank filter values: exclude empty strings and "Not applicable"
7. Rankings: Primary/Secondary phase tabs with phase-specific metrics
8. Compare: Primary/Secondary tabs with school counts and phase metrics
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>