Compare commits

...
Author SHA1 Message Date
TudorandClaude Fable 5 ce422e6436 fix(ci): use the Actions token, not REGISTRY_TOKEN, to read the E2E commit status
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 36s
Promotion's 'Verify the staging E2E gate' step called the Gitea
commit-status API with REGISTRY_TOKEN, which has container-registry scope
but no repository scope — so the API returned 403 and promotion failed.
Switch to the built-in GITHUB_TOKEN (repo read scope), matching how
pr-checks.yml already authenticates to the Gitea API. REGISTRY_TOKEN is
still used for the docker registry login, which is its correct scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 16:20:03 +01:00
tudor 2ac26acf91 Merge pull request 'style(compare): shorten Ofsted link text to 'Ofsted page'' (#60) from fix/ofsted-link-text into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 52s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 41s
Reviewed-on: #60
2026-07-18 14:20:09 +00:00
TudorandClaude Fable 5 3e77057567 style(compare): shorten Ofsted link text to 'Ofsted page'
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
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 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
The link repeated the full school name ('Watford Grammar School for Boys's
Ofsted page') — long on mobile, and the colour-coded school name already
leads the row. Show just 'Ofsted page →'; keep the full name in aria-label
so screen readers can still tell the per-school links apart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 15:19:41 +01:00
tudor e0d5a9969e Merge pull request 'style(compare): enlarge per-school name label in mobile section cards' (#59) from fix/mobile-cell-name-size into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 49s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 41s
Reviewed-on: #59
2026-07-18 14:09:02 +00:00
TudorandClaude Fable 5 dc21e80a5e style(compare): enlarge the per-school name label in mobile section cards
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 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 14s
The colour-coded school name that leads each row in a mobile measure card
was 0.8rem — a touch small to scan which school a value belongs to. Bump
to 0.9rem (with a matching 10px dot) so it's easier to read. Mobile only;
desktop uses the column header and hides this label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 15:06:18 +01:00
tudor 6d3a203699 Merge pull request 'fix(compare): anchor grade-5 & EBacc rows; restore secondary-button outline' (#58) from fix/secondary-anchors-button-affordance into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 0s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 40s
Reviewed-on: #58
2026-07-18 07:29:51 +00:00
TudorandClaude Fable 5 b0c5b6bb57 fix(compare): anchor grade-5 & EBacc rows; restore secondary-button outline
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 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 8s
SF1 (expert sign-off): the secondary academics 'Grade 5+ in English &
maths' and 'EBacc entry' rows rendered as bare numbers — the one place
the 'every number anchored against the England average' promise broke.
Both now show the official England anchor (already in the payload) + an
Above/Close/Below verdict chip, matching Attainment 8 (which also gains a
chip for consistency).

Button affordance: globals.css has a duplicate .btn block whose
'border: none' overrode the base '1px solid transparent', so
.btn-secondary/.btn-active's 'border-color' had no width — every outline
button (the modal's '+ Compare', search-result '+ Compare', 'Comparing')
rendered as borderless teal text. Give the outline variants the full
'border' shorthand so the outline renders regardless of the clobbered base.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 08:26:59 +01:00
tudor a1128bd801 Merge pull request 'fix(compare): keep add-school sheet above the mobile keyboard (VisualViewport)' (#57) from fix/modal-keyboard-viewport into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 55s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 42s
Reviewed-on: #57
2026-07-18 07:09:03 +00:00
TudorandClaude Fable 5 2fd997bfe6 fix(compare): keep the add-school sheet above the on-screen keyboard
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m31s
The bottom-sheet overlay was position:fixed inset:0, sized to the LAYOUT
viewport, so when the mobile keyboard opened it kept full height: the
sheet (anchored to the overlay's bottom) and the lower half of the dim
backdrop sat behind the keyboard, hiding the search input and results.

Track window.visualViewport (height + offsetTop) while the modal is open
and pin the overlay to the visible region, so backdrop and sheet stay
above the keyboard. Size the sheet against the overlay (max-height:100%,
min-height:min(55vh,100%)) instead of vh, and drop the results list's own
inner scroll on mobile so the sheet is a single scroll area.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-18 08:00:31 +01:00
tudor bdd9bef349 Merge pull request 'fix(compare): repair mobile add-school modal + re-apply stranded chart-height fix' (#56) from fix/mobile-compare-polish into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 41s
Reviewed-on: #56
2026-07-17 22:11:45 +00:00
TudorandClaude Fable 5 e5f7f4c959 fix(compare): repair the mobile add-school modal
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m6s
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 48s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 52s
The 'add school' modal passed no title to Modal, so its header held only
the close button — shoved to the far left by justify-content:space-between
in an otherwise-empty bar — while the real title was rendered separately
inside the content. Pass the title to Modal so the header reads title
(left) + close (right), and drop the duplicate in-content heading.

Also: the mobile full-width result button targeted a dead .addButton
selector (the button never had that class), so it rendered inconsistently
— give the button a real module class and full width on mobile, tighten
the stacked cards, and give the bottom sheet a stable min-height so its
empty state isn't a tiny stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 23:02:12 +01:00
TudorandClaude Fable 5 60918da483 fix(compare): give the trend chart its own definite canvas height so the gap note can't squash it
The chart note (and the 2-row mobile chip legend) lived inside the fixed
360px/420px chartBox and competed with the canvas for it, so a longer
caption — e.g. the new KS4 gap note — shrank the mobile canvas to ~178px
and tripped the e2e >220px guard. The canvas now owns a definite height
(280px mobile / 380px desktop) with chips above and the note flowing
below at natural size, so neither can eat the plot. Verified on staging:
patched canvas renders 280px vs the squashed 178px.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 22:58:35 +01:00
tudor 284215fbce Merge pull request 'fix(compare): expert should-fixes S1-S4, S6 (S5/S7 closed by verification)' (#55) from fix/expert-signoff-shouldfixes into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 45s
Reviewed-on: #55
2026-07-17 17:53:16 +00:00
TudorandClaude Fable 5 200a97d0b9 fix(compare): per-school P8 explanation; disadvantaged cohort from the same yearly row
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 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m24s
Review findings: (1) the 'no KS2 baseline (COVID)' explanation was derived
from the basket-wide max year, so a school with an unrelated data gap
borrowed it from a neighbour with 2024/25 data — now judged per school on
its own latest year. (2) The '~N disadvantaged pupils' cohort multiplied
eligible_pupils and disadvantaged_pct resolved independently across years
— now both come from the same yearly row that supplies the displayed
percentage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 17:53:10 +01:00
TudorandClaude Fable 5 f3fa12806b fix(compare): expert should-fixes S1-S4, S6 — banded chips, P8 reason, KS4 gap caption, all-through framing, cohort sizes
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m2s
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 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m8s
S1: first-choice chip banded (More than half / About 1 in 3 / Over 1 in 4
missed out) so a 44%-offered grammar isn't understated by half.
S2: Progress 8 explains its absence for 2024/25+ cohorts (no KS2 baseline,
COVID) instead of a bare 'No data'.
S3: KS4 trend charts get their own honest gap caption (2019/20-2020/21
unpublished; later years not in our dataset yet); y-axis 'Value'→'Score';
buildCompareChart exposes englandOnlyYears.
S4: all-through schools labelled in chips, rail caption says 'N schools ·
<phase> view' for mixed baskets, whole-school roll no longer judged
against the single-phase median, community section carries an all-ages
caveat.
S6 (spec §8.5): disadvantaged attainment shows the cohort behind it
('of ~50 disadvantaged pupils').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 17:41:12 +01:00
tudor 1004f08daf Merge pull request 'fix(pipeline): annual DAG never builds the official KS4 national mart' (#54) from fix/annual-dag-ks4-national-selector into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 14s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 51s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 1m10s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 45s
Reviewed-on: #54
2026-07-17 13:28:08 +00:00
TudorandClaude Fable 5 79246edc22 fix(pipeline): build stg_ees_ks4_national in the annual EES DAG
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 37s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 17s
The DAG's dbt --select list predates the official-KS4-nationals stream,
so the extract loaded raw.ees_ks4_national but the staging model and
fact_ks4_national_averages were never rebuilt — staging kept serving the
old computed means after the DAG run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-17 14:15:11 +01:00
tudor 64b63b96c8 Merge pull request 'fix(compare): expert sign-off must-fixes M1-M3' (#53) from fix/expert-signoff-mustfixes into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 19s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 43s
Reviewed-on: #53
2026-07-17 12:02:55 +00:00
22 changed files with 348 additions and 72 deletions
+4 -1
View File
@@ -67,9 +67,12 @@ jobs:
echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)" echo "Promoting $FULL_SHA (images tagged $SHORT_SHA)"
- name: Verify the staging E2E gate passed for this commit - name: Verify the staging E2E gate passed for this commit
# Use the built-in Actions token (GITHUB_TOKEN is the documented name;
# it carries repository read scope), NOT REGISTRY_TOKEN — the registry
# token has no repo scope, so the commit-status API returns 403.
run: | run: |
STATUS_JSON=$(curl -fsS \ STATUS_JSON=$(curl -fsS \
-H "Authorization: token ${{ secrets.REGISTRY_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status") "https://${REGISTRY}/api/v1/repos/${{ gitea.repository }}/commits/${{ steps.resolve.outputs.full }}/status")
echo "$STATUS_JSON" | python3 -c " echo "$STATUS_JSON" | python3 -c "
import json, sys import json, sys
@@ -0,0 +1,65 @@
/**
* Secondary academics: every headline number carries its England anchor and a
* verdict chip (expert sign-off SF1 — the grade-5 and EBacc rows previously
* rendered as bare numbers, breaking the "anchored against England" promise).
*/
import { render, screen, within } from '@testing-library/react';
import { CompareAcademics } from '@/components/compare/CompareAcademics';
import type { ComparisonData, NationalAverages, School } from '@/lib/types';
function school(urn: number, name: string): School {
return { urn, school_name: name, attainment_8_score: 58.7 } as School;
}
function data(urn: number): ComparisonData {
return {
school_info: school(urn, 'Test High'),
yearly_data: [
{
year: 202425,
attainment_8_score: 58.7,
english_maths_strong_pass_pct: 30,
ebacc_entry_pct: 10,
},
] as ComparisonData['yearly_data'],
ofsted: null,
census: null,
admissions: null,
admissions_history: [],
deprivation: null,
};
}
const NATIONAL: NationalAverages = {
year: 202425,
primary: {},
secondary: {
attainment_8_score: 46.0,
english_maths_strong_pass_pct: 45.4,
ebacc_entry_pct: 40.5,
},
by_year: [],
};
test('grade-5 and EBacc rows show the England anchor and a Below chip when under it', () => {
const s = school(137086, 'Bishop Stopford School');
render(
<CompareAcademics
schools={[s]}
data={{ '137086': data(137086) }}
nationalAverages={NATIONAL}
isSecondary
/>,
);
// The official anchors appear (45.4% and 40.5%), not just the school numbers.
expect(screen.getByText(/England average 45%/)).toBeInTheDocument();
expect(screen.getByText(/England average 41%/)).toBeInTheDocument();
// 30% grade-5 and 10% EBacc are both well below their anchors → Below chips.
// Attainment 8 (58.7 vs 46.0) is above → at least one "Above" chip too.
expect(screen.getAllByText(/Below England average/).length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText(/Above England average/).length).toBeGreaterThanOrEqual(1);
});
@@ -73,6 +73,15 @@ describe('buildCompareChart', () => {
expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7); expect(eng.data[chart.years.indexOf(202122)]).toBe(58.7);
}); });
it('lists England-only years so the component can caption dashed-only stretches', () => {
const chart = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
202122: 58.7,
});
expect(chart.englandOnlyYears).toEqual([202122]);
const none = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct');
expect(none.englandOnlyYears).toEqual([]);
});
it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => { it('flags the unpublished 2021/22 school-level year when England has data but schools do not', () => {
const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', { const withNational = buildCompareChart(THREE_SCHOOLS, SCHOOL_LIST, 'rwm_expected_pct', {
202122: 58.7, 202122: 58.7,
@@ -177,6 +177,16 @@ describe('summariseAdmissions', () => {
expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' }); expect(s.chip).toEqual({ tone: 'warn', text: 'Over 1 in 4 first choices missed out' });
}); });
it('60% → "About 1 in 3 first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 60 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'About 1 in 3 first choices missed out' });
});
it('44% (selective-scale demand) → "More than half of first choices missed out"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 43.69 }));
expect(s.chip).toEqual({ tone: 'warn', text: 'More than half of first choices missed out' });
});
it('100% → "All first choices offered"', () => { it('100% → "All first choices offered"', () => {
const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 })); const s = summariseAdmissions(admissions({ first_preference_offer_pct: 100 }));
expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' }); expect(s.chip).toEqual({ tone: 'good', text: 'All first choices offered' });
+8 -3
View File
@@ -183,10 +183,15 @@ body {
} }
/* Secondary: teal outline — supporting actions (+ Compare) */ /* Secondary: teal outline — supporting actions (+ Compare) */
/* NOTE: a duplicate `.btn` block further down this file sets `border: none`,
which wins over the base `.btn`'s `1px solid transparent`. The outline
variants below therefore declare the full `border` shorthand explicitly so
they don't depend on the base border-width — otherwise `border-color` alone
has no width and the outline never renders (buttons read as plain text). */
.btn-secondary { .btn-secondary {
background: transparent; background: transparent;
color: var(--accent-teal); color: var(--accent-teal);
border-color: var(--accent-teal); border: 1px solid var(--accent-teal);
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--accent-teal-bg); background: var(--accent-teal-bg);
@@ -196,7 +201,7 @@ body {
.btn-tertiary { .btn-tertiary {
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-secondary); color: var(--text-secondary);
border-color: var(--border-color); border: 1px solid var(--border-color);
} }
.btn-tertiary:hover:not(:disabled) { .btn-tertiary:hover:not(:disabled) {
background: var(--border-color); background: var(--border-color);
@@ -207,7 +212,7 @@ body {
.btn-active { .btn-active {
background: var(--accent-teal-bg); background: var(--accent-teal-bg);
color: var(--accent-teal); color: var(--accent-teal);
border-color: var(--accent-teal); border: 1px solid var(--accent-teal);
} }
.btn-active:hover:not(:disabled) { .btn-active:hover:not(:disabled) {
background: transparent; background: transparent;
@@ -1,16 +1,23 @@
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the /* Chart wrapper: chips (mobile) above, then the canvas, then the gap note.
parent .chartContainer, whose fixed height drives Chart.js sizing via The canvas has its OWN definite height (Chart.js needs one for
maintainAspectRatio: false. */ maintainAspectRatio: false); the chips and the note flow at their natural
size around it rather than competing with it for a fixed outer height —
so a longer note (e.g. the KS4 gap caption) or a two-row chip legend can
never squash the chart. */
.wrapper { .wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
} }
.canvasBox { .canvasBox {
position: relative; position: relative;
flex: 1 1 auto; height: 380px;
min-height: 0; }
@media (max-width: 640px) {
.canvasBox {
height: 280px;
}
} }
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps /* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
+19 -5
View File
@@ -38,13 +38,15 @@ interface ComparisonChartProps {
/** Official England figure per academic year for this metric — renders a /** Official England figure per academic year for this metric — renders a
* dashed grey reference line when provided. */ * dashed grey reference line when provided. */
nationalByYear?: Record<number, number | null | undefined>; nationalByYear?: Record<number, number | null | undefined>;
/** KS4 metrics get a different (honest) gap caption than KS2. */
isSecondary?: boolean;
} }
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so // One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// converging lines stay tellable apart without relying on hue alone. // converging lines stay tellable apart without relying on hue alone.
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star']; const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear }: ComparisonChartProps) { export function ComparisonChart({ comparisonData, schools, metric, metricLabel, nationalByYear, isSecondary = false }: ComparisonChartProps) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null); const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
@@ -168,7 +170,7 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
display: true, display: true,
title: { title: {
display: !isMobile, display: !isMobile,
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value', text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Score',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',
@@ -240,11 +242,23 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel,
<div className={styles.canvasBox}> <div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} /> <Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div> </div>
{built.showUnpublished202122Note && ( {isSecondary && built.englandOnlyYears.length > 0 ? (
// KS4's honest story differs from KS2's: 2019/202020/21 school-level
// GCSE results weren't published (COVID grading); later years WERE
// published by DfE but aren't in our dataset yet.
<p className={styles.chartNote}> <p className={styles.chartNote}>
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn&apos;t publish School-level GCSE figures for 2019/20 and 2020/21 weren&apos;t published (COVID
school-level figures for 2021/22 the England average is shown for that year. grading), and more recent years aren&apos;t in our dataset yet where lines break the
England average is shown where available.
</p> </p>
) : (
!isSecondary &&
built.showUnpublished202122Note && (
<p className={styles.chartNote}>
No national tests were held in 2019/20 and 2020/21 (COVID), and DfE didn&apos;t publish
school-level figures for 2021/22 the England average is shown for that year.
</p>
)
)} )}
</div> </div>
); );
+16 -4
View File
@@ -365,12 +365,18 @@ export function ComparisonView({
style={{ '--school-count': activeSchools.length } as CSSProperties} style={{ '--school-count': activeSchools.length } as CSSProperties}
aria-label="Schools in this comparison" aria-label="Schools in this comparison"
> >
{/* Fills the 200px label rail on desktop (hidden on mobile). */} {/* Fills the 200px label rail on desktop (hidden on mobile).
All-through schools must not be miscounted as "primary
schools"/"secondary schools" — mixed baskets get "· primary
view" phrasing instead. */}
<div className={styles.barCaption}> <div className={styles.barCaption}>
<span className={styles.barCaptionEyebrow}>Comparing</span> <span className={styles.barCaptionEyebrow}>Comparing</span>
<span className={styles.barCaptionCount}> <span className={styles.barCaptionCount}>
{activeSchools.length} {comparePhase} school {activeSchools.every((sch) =>
{activeSchools.length === 1 ? '' : 's'} sch.phase?.toLowerCase().includes(comparePhase),
)
? `${activeSchools.length} ${comparePhase} school${activeSchools.length === 1 ? '' : 's'}`
: `${activeSchools.length} schools · ${comparePhase} view`}
</span> </span>
</div> </div>
{activeSchools.map((school, index) => ( {activeSchools.map((school, index) => (
@@ -390,7 +396,13 @@ export function ComparisonView({
<span className={styles.chipNameShort}>{shortName(school.school_name)}</span> <span className={styles.chipNameShort}>{shortName(school.school_name)}</span>
</a> </a>
<span className={styles.chipMeta}> <span className={styles.chipMeta}>
{[school.local_authority, school.school_type].filter(Boolean).join(' · ')} {[
/all.?through/i.test(school.phase ?? '') ? 'All-through' : null,
school.local_authority,
school.school_type,
]
.filter(Boolean)
.join(' · ')}
</span> </span>
</span> </span>
<button <button
+7 -1
View File
@@ -123,7 +123,13 @@
.modal { .modal {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
max-height: 95vh; /* Bottom sheet sized against the overlay (which tracks the visual
viewport), NOT vh: when the keyboard is open the overlay is short, so
max-height:100% keeps the whole sheet — input and results — above the
keyboard. min-height gives a comfortable default without a tiny stub,
but is capped at 100% so it never exceeds the visible area. */
min-height: min(55vh, 100%);
max-height: 100%;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
animation: slideUp 0.3s ease; animation: slideUp 0.3s ease;
+30 -2
View File
@@ -5,7 +5,7 @@
'use client'; 'use client';
import { useEffect, useCallback } from 'react'; import { useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import styles from './Modal.module.css'; import styles from './Modal.module.css';
@@ -18,6 +18,8 @@ interface ModalProps {
} }
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) { export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const handleEscape = useCallback((e: KeyboardEvent) => { const handleEscape = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
onClose(); onClose();
@@ -39,6 +41,32 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
}; };
}, [isOpen, handleEscape]); }, [isOpen, handleEscape]);
// Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile
// the on-screen keyboard shrinks the visual viewport but not the layout one,
// so a `position: fixed; inset: 0` overlay keeps full height — leaving the
// bottom-anchored sheet (and the dim backdrop's lower half) hidden behind
// the keyboard. Tracking visualViewport.height/offsetTop keeps the whole
// overlay — backdrop and sheet — inside the visible area, above the keyboard.
useEffect(() => {
if (!isOpen) return;
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
const el = overlayRef.current;
if (!vv || !el) return;
const sync = () => {
el.style.top = `${vv.offsetTop}px`;
el.style.height = `${vv.height}px`;
el.style.bottom = 'auto';
};
sync();
vv.addEventListener('resize', sync);
vv.addEventListener('scroll', sync);
return () => {
vv.removeEventListener('resize', sync);
vv.removeEventListener('scroll', sync);
};
}, [isOpen]);
if (!isOpen || typeof window === 'undefined') return null; if (!isOpen || typeof window === 'undefined') return null;
const handleOverlayClick = (e: React.MouseEvent) => { const handleOverlayClick = (e: React.MouseEvent) => {
@@ -48,7 +76,7 @@ export function Modal({ isOpen, onClose, children, title, size = 'medium' }: Mod
}; };
return createPortal( return createPortal(
<div className={styles.overlay} onClick={handleOverlayClick}> <div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
<div className={`${styles.modal} ${styles[size]}`}> <div className={`${styles.modal} ${styles[size]}`}>
<div className={styles.header}> <div className={styles.header}>
{title && <h2 className={styles.title}>{title}</h2>} {title && <h2 className={styles.title}>{title}</h2>}
@@ -2,14 +2,6 @@
padding: 1.5rem; padding: 1.5rem;
} }
.title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #1a1612);
margin-bottom: 1.5rem;
font-family: var(--font-playfair), 'Playfair Display', serif;
}
.warning { .warning {
background: var(--accent-gold-bg); background: var(--accent-gold-bg);
border: 1px solid var(--accent-gold, #c9a227); border: 1px solid var(--accent-gold, #c9a227);
@@ -119,12 +111,16 @@
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary, #1a1612); color: var(--text-primary, #1a1612);
margin-bottom: 0.5rem; margin-bottom: 0.25rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.resultButton {
flex: 0 0 auto;
}
.schoolMeta { .schoolMeta {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
@@ -159,21 +155,30 @@
padding: 1rem; padding: 1rem;
} }
.title { /* One scroll container on mobile: the modal content itself scrolls, so the
font-size: 1.25rem; results list must not add its own inner scroll (double scrollbars, and
the input would be trapped above a short 400px window when the keyboard
shrinks the sheet). */
.results {
max-height: none;
overflow: visible;
} }
/* Compact stacked card: name + meta, then a full-width action so the tap
target is obvious and the card doesn't waste vertical space. */
.resultItem { .resultItem {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 0.625rem;
padding: 0.875rem;
} }
.addButton { .resultButton {
width: 100%; width: 100%;
} }
.schoolMeta { .schoolMeta {
flex-direction: column; flex-wrap: wrap;
gap: 0.25rem; gap: 0.25rem 1rem;
} }
} }
+3 -5
View File
@@ -81,10 +81,8 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
}; };
return ( return (
<Modal isOpen={isOpen} onClose={handleClose}> <Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison">
<div className={styles.modalContent}> <div className={styles.modalContent}>
<h2 className={styles.title}>Add School to Comparison</h2>
{!canAddMore && ( {!canAddMore && (
<div className={styles.warning}> <div className={styles.warning}>
Maximum 5 schools can be compared. Remove a school to add another. Maximum 5 schools can be compared. Remove a school to add another.
@@ -129,9 +127,9 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
<button <button
onClick={() => handleAddSchool(school)} onClick={() => handleAddSchool(school)}
disabled={alreadySelected || !canAddMore} disabled={alreadySelected || !canAddMore}
className={ className={`${styles.resultButton} ${
alreadySelected ? "btn btn-active" : "btn btn-secondary" alreadySelected ? "btn btn-active" : "btn btn-secondary"
} }`}
> >
{alreadySelected ? "✓ Comparing" : "+ Compare"} {alreadySelected ? "✓ Comparing" : "+ Compare"}
</button> </button>
@@ -148,9 +148,35 @@ export function CompareAcademics({
} }
return null; return null;
}); });
// DfE stopped publishing Progress 8 from 2024/25: those GCSE year groups
// sat no KS2 tests (COVID), so there is no baseline to measure progress
// from. A bare "No data" reads as a gap on our side — say why. Judged
// PER SCHOOL on its own latest data year: a school whose data simply
// stops earlier (an unrelated gap) must not borrow the COVID explanation
// from a neighbour that does have 2024/25 data.
const p8NotPublished = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
const y = rows.length ? Math.trunc(rows[rows.length - 1].year) : 0;
return y >= 202425;
});
const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct'); const grade5 = latestValues(data, urns, 'english_maths_strong_pass_pct');
const ebacc = latestValues(data, urns, 'ebacc_entry_pct'); const ebacc = latestValues(data, urns, 'ebacc_entry_pct');
const att8Anchor = nationalAverages?.secondary?.attainment_8_score; const att8Anchor = nationalAverages?.secondary?.attainment_8_score;
const grade5Anchor = nationalAverages?.secondary?.english_maths_strong_pass_pct;
const ebaccAnchor = nationalAverages?.secondary?.ebacc_entry_pct;
// Every headline number gets its England anchor + verdict chip, so the
// "anchored against the England average" promise holds for the grade-5
// and EBacc rows too, not just Attainment 8.
const anchorChip = (value: number | null, anchor: number | null | undefined, tol: number) => {
if (value == null || anchor == null) return null;
const v = verdict(value, anchor, tol);
return (
<Chip tone={v === 'above' ? 'good' : v === 'below' ? 'warn' : 'neutral'}>
{v === 'above' ? 'Above' : v === 'below' ? 'Below' : 'Close to'} England average
</Chip>
);
};
return ( return (
<Section <Section
@@ -163,7 +189,8 @@ export function CompareAcademics({
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
{att8[i] != null ? ( {att8[i] != null ? (
<> <>
<span className={s.big}>{(att8[i] as number).toFixed(1)}</span> <span className={s.big}>{(att8[i] as number).toFixed(1)}</span>{' '}
{anchorChip(att8[i], att8Anchor, 2)}
{att8Anchor != null && ( {att8Anchor != null && (
<span className={s.small}>England average {att8Anchor.toFixed(1)}</span> <span className={s.small}>England average {att8Anchor.toFixed(1)}</span>
)} )}
@@ -189,6 +216,11 @@ export function CompareAcademics({
> >
{banding[i]} {banding[i]}
</Chip> </Chip>
) : p8NotPublished[i] ? (
<span className={s.small}>
Not published this GCSE year group sat no KS2 tests (COVID), so DfE has no
baseline to measure progress from
</span>
) : ( ) : (
<span className={s.small}>No data</span> <span className={s.small}>No data</span>
)} )}
@@ -200,14 +232,38 @@ export function CompareAcademics({
</RowLabel> </RowLabel>
{schools.map((school, i) => ( {schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
{grade5[i] != null ? `${Math.round(grade5[i] as number)}%` : <span className={s.small}>No data</span>} {grade5[i] != null ? (
<>
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(grade5[i] as number)}%
</span>{' '}
{anchorChip(grade5[i], grade5Anchor, 3)}
{grade5Anchor != null && (
<span className={s.small}>England average {Math.round(grade5Anchor)}%</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell> </Cell>
))} ))}
<RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel> <RowLabel tip="% entering the English Baccalaureate subject combination.">EBacc entry</RowLabel>
{schools.map((school, i) => ( {schools.map((school, i) => (
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
{ebacc[i] != null ? `${Math.round(ebacc[i] as number)}%` : <span className={s.small}>No data</span>} {ebacc[i] != null ? (
<>
<span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(ebacc[i] as number)}%
</span>{' '}
{anchorChip(ebacc[i], ebaccAnchor, 3)}
{ebaccAnchor != null && (
<span className={s.small}>England average {Math.round(ebaccAnchor)}%</span>
)}
</>
) : (
<span className={s.small}>No data</span>
)}
</Cell> </Cell>
))} ))}
</SectionGrid> </SectionGrid>
@@ -218,6 +274,24 @@ export function CompareAcademics({
const national = nationalAverages?.primary; const national = nationalAverages?.primary;
const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct'); const disadvantaged = latestValues(data, urns, 'rwm_expected_disadvantaged_pct');
const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null; const disadvantagedAnchor = benchmarks?.primary?.disadvantaged_rwm_expected_pct ?? null;
// Cohort size behind the disadvantaged figure (spec §8.5): these are small
// groups where single pupils move the percentage — show roughly how many
// pupils the figure rests on. Taken from the SAME yearly row that supplies
// the displayed percentage: resolving eligible_pupils and the
// disadvantaged share independently could mix years and misstate the
// cohort behind the figure.
const cohorts = urns.map((urn) => {
const rows = data[String(urn)]?.yearly_data ?? [];
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i];
if (row.rwm_expected_disadvantaged_pct != null) {
if (row.eligible_pupils == null || row.disadvantaged_pct == null) return null;
const cohort = Math.round((row.eligible_pupils * row.disadvantaged_pct) / 100);
return cohort > 0 ? cohort : null;
}
}
return null;
});
return ( return (
<Section <Section
@@ -270,6 +344,9 @@ export function CompareAcademics({
<span className={s.big} style={{ fontSize: '1.1rem' }}> <span className={s.big} style={{ fontSize: '1.1rem' }}>
{Math.round(value)}% {Math.round(value)}%
</span>{' '} </span>{' '}
{cohorts[i] != null && (
<span className={s.small}>of ~{cohorts[i]} disadvantaged pupils</span>
)}{' '}
{disadvantagedAnchor != null && ( {disadvantagedAnchor != null && (
<Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}> <Chip tone={verdict(value, disadvantagedAnchor, 5) === 'below' ? 'warn' : 'good'}>
{verdict(value, disadvantagedAnchor, 5) === 'above' && {verdict(value, disadvantagedAnchor, 5) === 'above' &&
@@ -170,8 +170,14 @@ export function CompareAtAGlance({
{schools.map((school, i) => { {schools.map((school, i) => {
const census = data[String(school.urn)]?.census; const census = data[String(school.urn)]?.census;
const pupils = census?.total_pupils ?? school.total_pupils ?? null; const pupils = census?.total_pupils ?? school.total_pupils ?? null;
// An all-through school's roll covers every age group, so judging
// it against the single-phase median ("Much larger than average")
// is meaningless — label the roll honestly instead.
const isAllThrough = /all.?through/i.test(school.phase ?? '');
let sizeNote: string | null = null; let sizeNote: string | null = null;
if (pupils != null && medianPupils != null) { if (isAllThrough) {
sizeNote = 'Whole-school roll (all-through, all ages)';
} else if (pupils != null && medianPupils != null) {
if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average'; if (pupils >= medianPupils * 1.5) sizeNote = 'Much larger than average';
else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average'; else if (pupils >= medianPupils * 1.1) sizeNote = 'Larger than average';
else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average'; else if (pupils <= medianPupils * 0.66) sizeNote = 'Much smaller than average';
@@ -48,10 +48,25 @@ export function CompareCommunity({
); );
}; };
const anyAllThrough = schools.some((school) => /all.?through/i.test(school.phase ?? ''));
return ( return (
<Section <Section
title="Who goes there" title="Who goes there"
how="The school's community, from the latest school census. State-school averages are computed from our dataset and shown for context — there's no “right” number here." how={
<>
The school&apos;s community, from the latest school census. State-school averages are
computed from our dataset and shown for context there&apos;s no &ldquo;right&rdquo;
number here.
{anyAllThrough && (
<>
{' '}
For all-through schools these figures cover the whole school, all ages not just
the {isSecondary ? 'secondary' : 'primary'} phase.
</>
)}
</>
}
> >
<SectionGrid schools={schools}> <SectionGrid schools={schools}>
<Measure label="Pupils on roll"> <Measure label="Pupils on roll">
@@ -239,8 +239,17 @@ export function CompareOfsted({
`https://reports.ofsted.gov.uk/provider/21/${school.urn}`; `https://reports.ofsted.gov.uk/provider/21/${school.urn}`;
return ( return (
<Cell key={school.urn} school={school} index={i}> <Cell key={school.urn} school={school} index={i}>
<a className={s.link} href={url} target="_blank" rel="noopener noreferrer"> {/* Short visible label to save space on mobile (the coloured
{school.school_name}&apos;s Ofsted page school name already leads the row); the full name stays in
aria-label so screen readers can tell the links apart. */}
<a
className={s.link}
href={url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${school.school_name}'s Ofsted page`}
>
Ofsted page
</a> </a>
</Cell> </Cell>
); );
@@ -60,18 +60,10 @@
margin: 0 0 1rem; margin: 0 0 1rem;
} }
/* ComparisonChart runs Chart.js with maintainAspectRatio:false, so it fills /* ComparisonChart owns its own canvas height now (a definite px value per
its container's height — which must be *definite*. A min-height alone does breakpoint), with the mobile chip legend above and the gap note below it
not resolve the chart wrapper's height:100%, leaving Chart.js to fall back flowing at natural size. This box therefore only needs to not constrain
to its ~150px default (a squashed sliver). Give it a real height. */ that height — no fixed height, or the note would again eat the plot. */
.chartBox { .chartBox {
height: 420px; min-height: 0;
}
@media (max-width: 640px) {
/* Taller on mobile: the mobile-only school chips sit above the canvas and
wrap to two rows for 3+ schools, so the plot keeps a usable height. */
.chartBox {
height: 360px;
}
} }
@@ -119,6 +119,7 @@ export function TrendsExplorer({
metric={metric} metric={metric}
metricLabel={metricLabel} metricLabel={metricLabel}
nationalByYear={nationalByYear} nationalByYear={nationalByYear}
isSecondary={!isPrimaryPhase}
/> />
</div> </div>
</div> </div>
@@ -72,15 +72,18 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
flex-basis: 100%; flex-basis: 100%;
font-size: 0.8rem; /* Slightly larger than the values below it so the school each row belongs
to is easy to read on mobile (hidden on desktop, where the column header
names the school). */
font-size: 0.9rem;
font-weight: 600; font-weight: 600;
color: var(--sc, var(--text-secondary)); color: var(--sc, var(--text-secondary));
margin-bottom: 0.15rem; margin-bottom: 0.2rem;
} }
.cellDot { .cellDot {
width: 9px; width: 10px;
height: 9px; height: 10px;
border-radius: 50%; border-radius: 50%;
background: var(--dot, var(--text-muted)); background: var(--dot, var(--text-muted));
flex: none; flex: none;
+11 -6
View File
@@ -53,6 +53,10 @@ export interface CompareChart {
/** True when England published a 2021/22 figure but no school has one — /** True when England published a 2021/22 figure but no school has one —
* the UI shows: "DfE didn't publish school-level figures for 2021/22". */ * the UI shows: "DfE didn't publish school-level figures for 2021/22". */
showUnpublished202122Note: boolean; showUnpublished202122Note: boolean;
/** Years where the England overlay has a value but no school does — the
* chart shows a dashed-line-only stretch that needs explaining (KS2 and
* KS4 have different honest explanations, so the component owns the copy). */
englandOnlyYears: number[];
} }
export function buildCompareChart( export function buildCompareChart(
@@ -92,11 +96,12 @@ export function buildCompareChart(
} }
} }
const idx202122 = years.indexOf(202122); const englandOnlyYears = years.filter(
const showUnpublished202122Note = (year, i) =>
idx202122 >= 0 && englandDataset?.data[i] != null && schoolDatasets.every((ds) => ds.data[i] == null),
englandDataset?.data[idx202122] != null && );
schoolDatasets.every((ds) => ds.data[idx202122] == null);
return { years, schoolDatasets, englandDataset, showUnpublished202122Note }; const showUnpublished202122Note = englandOnlyYears.includes(202122);
return { years, schoolDatasets, englandDataset, showUnpublished202122Note, englandOnlyYears };
} }
+6
View File
@@ -192,6 +192,12 @@ export function summariseAdmissions(
if (pct != null) { if (pct != null) {
if (pct >= 100) { if (pct >= 100) {
chip = { tone: 'good', text: 'All first choices offered' }; chip = { tone: 'good', text: 'All first choices offered' };
} else if (pct < 50) {
// Banded, not one blanket chip: "Over 1 in 4" on a school where more
// than half missed out understated the worst cases by half.
chip = { tone: 'warn', text: 'More than half of first choices missed out' };
} else if (pct < 67) {
chip = { tone: 'warn', text: 'About 1 in 3 first choices missed out' };
} else if (pct < 75) { } else if (pct < 75) {
chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' }; chip = { tone: 'warn', text: 'Over 1 in 4 first choices missed out' };
} else { } else {
+1 -1
View File
@@ -180,7 +180,7 @@ with DAG(
dbt_build_ees = BashOperator( dbt_build_ees = BashOperator(
task_id="dbt_build", task_id="dbt_build",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+", bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+ stg_ees_ks4_national+",
) )
sync_typesense_ees = BashOperator( sync_typesense_ees = BashOperator(