From 3cb72d0a0f4057fbe3fdbd70bc8183ab51a0f6d2 Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 15 Jul 2026 07:29:24 +0100 Subject: [PATCH 1/2] fix(compare): remove the year-by-year data table from Explore trends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mockup's Explore trends section is the measure picker + chart only — no data table. Removes the table (and the now-unused progressBand / band chip / formatMetricValue plumbing that only fed it). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- .../compare/TrendsExplorer.module.css | 31 -------- .../components/compare/TrendsExplorer.tsx | 77 ++----------------- 2 files changed, 5 insertions(+), 103 deletions(-) diff --git a/nextjs-app/components/compare/TrendsExplorer.module.css b/nextjs-app/components/compare/TrendsExplorer.module.css index 61f17fa..7c74367 100644 --- a/nextjs-app/components/compare/TrendsExplorer.module.css +++ b/nextjs-app/components/compare/TrendsExplorer.module.css @@ -75,34 +75,3 @@ height: 360px; } } - -.tableWrapper { - overflow-x: auto; - margin-top: 1.5rem; -} - -.table { - width: 100%; - border-collapse: collapse; - font-size: 0.9rem; -} - -.table th, -.table td { - text-align: left; - padding: 0.6rem 0.75rem; - border-bottom: 1px solid var(--border-light); -} - -.table th { - background: var(--bg-secondary); - font-size: 0.8rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--text-secondary); -} - -.yearCell { - font-weight: 600; - white-space: nowrap; -} diff --git a/nextjs-app/components/compare/TrendsExplorer.tsx b/nextjs-app/components/compare/TrendsExplorer.tsx index 6cd5a6e..0a5f83e 100644 --- a/nextjs-app/components/compare/TrendsExplorer.tsx +++ b/nextjs-app/components/compare/TrendsExplorer.tsx @@ -1,19 +1,17 @@ /** * Explore trends — the full grouped metric catalogue (nothing from the old - * compare page is lost; spec §4's tier 3) driving the year-by-year chart - * with its England reference line, plus the year-by-year table. Progress - * metrics carry CI-based bands for the years DfE published them. + * compare page is lost; spec §4's tier 3) driving the year-by-year chart with + * its England reference line. Matches the mockup: a measure picker and the + * chart only (no data table). */ 'use client'; import dynamic from 'next/dynamic'; -import { progressBand } from '@/lib/compareLogic'; import type { ComparisonData, MetricDefinition, NationalAverages, School } from '@/lib/types'; -import { formatAcademicYear, formatMetricValue, metricKind } from '@/lib/utils'; import { track } from '@/lib/analytics'; -import { Chip, Section, sectionStyles as s } from './sectionShared'; +import { Section } from './sectionShared'; import styles from './TrendsExplorer.module.css'; const ComparisonChart = dynamic( @@ -40,14 +38,6 @@ const SECONDARY_OPTGROUPS: { label: string; category: string }[] = [ export const PRIMARY_CATEGORIES = PRIMARY_OPTGROUPS.map((g) => g.category); export const SECONDARY_CATEGORIES = SECONDARY_OPTGROUPS.map((g) => g.category); -const PROGRESS_CI: Record = { - reading_progress: ['reading_progress_lower_ci', 'reading_progress_upper_ci'], - writing_progress: ['writing_progress_lower_ci', 'writing_progress_upper_ci'], - maths_progress: ['maths_progress_lower_ci', 'maths_progress_upper_ci'], -}; - -const BAND_LABEL = { above: 'Above average', average: 'Average', below: 'Below average' } as const; - export function TrendsExplorer({ schools, data, @@ -78,21 +68,11 @@ export function TrendsExplorer({ nationalByYear[entry.year] = block?.[metric] ?? null; } - const years = [ - ...new Set( - schools.flatMap( - (school) => data[String(school.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [], - ), - ), - ].sort((a, b) => a - b); - const handleMetricChange = (next: string) => { track('compare_metric_changed', { metric: next, phase: isPrimaryPhase ? 'primary' : 'secondary' }); onMetricChange(next); }; - const ciKeys = PROGRESS_CI[metric]; - return (
Progress scores measure pupils' progress from KS1 to KS2. A score of 0 equals the - national average. DfE stopped publishing KS2 progress after 2022/23 (no KS1 baseline); - bands use DfE's confidence intervals, not the raw score alone. + national average. DfE stopped publishing KS2 progress after 2022/23 (no KS1 baseline).

)} @@ -142,52 +121,6 @@ export function TrendsExplorer({ nationalByYear={nationalByYear} /> - - {years.length > 0 && ( -
- - - - - {schools.map((school) => ( - - ))} - - - - {years.map((year) => ( - - - {schools.map((school) => { - const row = data[String(school.urn)]?.yearly_data.find( - (d) => Math.trunc(d.year) === year, - ) as (Record & { year: number }) | undefined; - const value = row?.[metric]; - if (typeof value !== 'number') return ; - const band = ciKeys - ? progressBand( - value, - (row?.[ciKeys[0]] as number | null) ?? null, - (row?.[ciKeys[1]] as number | null) ?? null, - ) - : null; - return ( - - ); - })} - - ))} - -
Year{school.school_name}
{formatAcademicYear(year)} - {formatMetricValue(value, metricKind(metric))}{' '} - {band && ( - - {BAND_LABEL[band]} - - )} -
-
- )}
From 66bc5523f6f1c5f6e5d5367529c0ff104b1518aa Mon Sep 17 00:00:00 2001 From: Tudor Date: Wed, 15 Jul 2026 07:50:01 +0100 Subject: [PATCH 2/2] fix(compare): mobile measure-first cards to match the mockup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid sections (At a glance, Ofsted, Getting a place, Who goes there) collapsed generically on mobile — grey label pills, full names wrapping to 3 lines, no dots — making the page ~2x the mockup's height and 'significantly different' from the mobile design. Each measure is now wrapped in a that is display:contents on desktop (so the label + cells still flow into the shared aligned grid, unchanged) and a white card on mobile with compact [dot][short name] [value] rows — matching the mobile mockup. The sticky school bar becomes scrollable short-name pills on mobile too. Adds a shortName() util. Desktop layout is unchanged (display:contents dissolves the wrapper). Validated the card mechanism and real content shapes (report-card cell, badges, %+chip rows) via static previews at both widths. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB --- .../components/CompareOfsted.test.tsx | 8 ++ nextjs-app/__tests__/lib/utils.test.ts | 15 ++++ .../components/ComparisonView.module.css | 42 ++++++++++ nextjs-app/components/ComparisonView.tsx | 5 +- .../components/compare/CompareAdmissions.tsx | 18 +++-- .../components/compare/CompareAtAGlance.tsx | 17 ++-- .../components/compare/CompareCommunity.tsx | 41 ++++++---- .../components/compare/CompareOfsted.tsx | 26 +++--- .../compare/compareSections.module.css | 81 ++++++++++++++----- .../components/compare/sectionShared.tsx | 39 ++++++++- nextjs-app/lib/utils.ts | 18 +++++ 11 files changed, 250 insertions(+), 60 deletions(-) diff --git a/nextjs-app/__tests__/components/CompareOfsted.test.tsx b/nextjs-app/__tests__/components/CompareOfsted.test.tsx index 8df5602..849d255 100644 --- a/nextjs-app/__tests__/components/CompareOfsted.test.tsx +++ b/nextjs-app/__tests__/components/CompareOfsted.test.tsx @@ -95,4 +95,12 @@ describe('CompareOfsted', () => { expect(links).toHaveLength(3); expect(links[0]).toHaveAttribute('href', 'https://reports.ofsted.gov.uk/provider/21/1'); }); + + it('renders a per-measure mobile tag with the short school name', () => { + render(); + // Each measure repeats the schools, so the short name ("Graded" from + // "Graded School") appears once per measure (4) via the cell tag. + expect(screen.getAllByText('Graded').length).toBe(4); + expect(screen.getAllByText('Card').length).toBe(4); + }); }); diff --git a/nextjs-app/__tests__/lib/utils.test.ts b/nextjs-app/__tests__/lib/utils.test.ts index a1ebbbc..9fb9fba 100644 --- a/nextjs-app/__tests__/lib/utils.test.ts +++ b/nextjs-app/__tests__/lib/utils.test.ts @@ -10,6 +10,7 @@ import { debounce, buildOfstedListBadge, metricKind, + shortName, computeYBounds, } from '@/lib/utils'; @@ -223,3 +224,17 @@ describe('isProposedToClose', () => { expect(isProposedToClose({})).toBe(false); }); }); + +describe('shortName', () => { + it('drops the trailing establishment-type words', () => { + expect(shortName('Barclay Primary School')).toBe('Barclay'); + expect(shortName('Elmhurst Primary School')).toBe('Elmhurst'); + expect(shortName("St Mary's Catholic Primary School")).toBe("St Mary's"); + expect(shortName('Riverside Community Junior School')).toBe('Riverside'); + }); + + it('keeps a name that carries no type suffix, capping very long ones', () => { + expect(shortName('Beaver Road')).toBe('Beaver Road'); + expect(shortName('A'.repeat(30), 10)).toBe('AAAAAAAAA…'); + }); +}); diff --git a/nextjs-app/components/ComparisonView.module.css b/nextjs-app/components/ComparisonView.module.css index 633ba12..8ea0615 100644 --- a/nextjs-app/components/ComparisonView.module.css +++ b/nextjs-app/components/ComparisonView.module.css @@ -132,6 +132,11 @@ color: var(--accent-coral-dark, #b04a2e); } +/* Full name on desktop, short name on the compact mobile pills. */ +.chipNameShort { + display: none; +} + .chipMeta { display: block; font-size: 0.78rem; @@ -163,3 +168,40 @@ padding-top: 1rem; max-width: 75ch; } + +/* Mobile: the sticky school bar becomes compact, horizontally-scrollable + pills with short names (matching the mobile mockup) instead of full-width + cards whose names wrap to several lines. */ +@media (max-width: 640px) { + .schoolChip { + flex: 0 0 auto; + min-width: 0; + border-top-width: 2px; + border-radius: 999px; + padding: 0.35rem 0.7rem; + box-shadow: none; + } + + .chipName { + font-size: 0.85rem; + white-space: nowrap; + } + + .chipNameFull { + display: none; + } + + .chipNameShort { + display: inline; + } + + .chipMeta { + display: none; + } + + .chipRemove { + width: 18px; + height: 18px; + font-size: 0.75rem; + } +} diff --git a/nextjs-app/components/ComparisonView.tsx b/nextjs-app/components/ComparisonView.tsx index dc013fd..003658b 100644 --- a/nextjs-app/components/ComparisonView.tsx +++ b/nextjs-app/components/ComparisonView.tsx @@ -28,7 +28,7 @@ import type { NationalAverages, School, } from '@/lib/types'; -import { CHART_COLORS, schoolUrl } from '@/lib/utils'; +import { CHART_COLORS, schoolUrl, shortName } from '@/lib/utils'; import { fetchComparison } from '@/lib/api'; import { track } from '@/lib/analytics'; import styles from './ComparisonView.module.css'; @@ -349,7 +349,8 @@ export function ComparisonView({ /> - {school.school_name} + {school.school_name} + {shortName(school.school_name)} {[school.local_authority, school.school_type].filter(Boolean).join(' · ')} diff --git a/nextjs-app/components/compare/CompareAdmissions.tsx b/nextjs-app/components/compare/CompareAdmissions.tsx index aeaa3dc..9e0b4e4 100644 --- a/nextjs-app/components/compare/CompareAdmissions.tsx +++ b/nextjs-app/components/compare/CompareAdmissions.tsx @@ -10,7 +10,7 @@ import { summariseAdmissions } from '@/lib/compareLogic'; import type { ComparisonData, School } from '@/lib/types'; import { CHART_COLORS } from '@/lib/utils'; -import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; +import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared'; export function CompareAdmissions({ schools, @@ -49,9 +49,10 @@ export function CompareAdmissions({ } > - - Interest in the school - + {schools.map((school, i) => { const a = rows[i]; return ( @@ -68,7 +69,9 @@ export function CompareAdmissions({ ); })} - First-choice families offered a place + + + {schools.map((school, i) => { const summary = summariseAdmissions(rows[i]); return ( @@ -95,7 +98,9 @@ export function CompareAdmissions({ ); })} - What this means + + + {schools.map((school, i) => { const a = rows[i]; const summary = summariseAdmissions(a); @@ -118,6 +123,7 @@ export function CompareAdmissions({ ); })} + ); diff --git a/nextjs-app/components/compare/CompareAtAGlance.tsx b/nextjs-app/components/compare/CompareAtAGlance.tsx index a2f6605..64b61ea 100644 --- a/nextjs-app/components/compare/CompareAtAGlance.tsx +++ b/nextjs-app/components/compare/CompareAtAGlance.tsx @@ -15,7 +15,7 @@ import { type ReportCardSummary, } from '@/lib/compareLogic'; import type { Benchmarks, ComparisonData, NationalAverages, School } from '@/lib/types'; -import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; +import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared'; function ReportCardChips({ summary }: { summary: ReportCardSummary }) { return ( @@ -69,7 +69,7 @@ export function CompareAtAGlance({ return (
- Latest Ofsted inspection + {schools.map((school, i) => { const display = ofstedDisplay(data[String(school.urn)]?.ofsted); return ( @@ -87,16 +87,16 @@ export function CompareAtAGlance({ ); })} + - - {isSecondary ? 'Attainment 8 score' : 'Children reaching the expected standard'} - {schools.map((school, i) => { const value = headlineValues[i]; return ( @@ -131,8 +131,9 @@ export function CompareAtAGlance({ ); })} + - Getting a place + {schools.map((school, i) => { const summary = summariseAdmissions(data[String(school.urn)]?.admissions); return ( @@ -148,8 +149,9 @@ export function CompareAtAGlance({ ); })} + - Size + {schools.map((school, i) => { const census = data[String(school.urn)]?.census; const pupils = census?.total_pupils ?? school.total_pupils ?? null; @@ -174,6 +176,7 @@ export function CompareAtAGlance({ ); })} +
); diff --git a/nextjs-app/components/compare/CompareCommunity.tsx b/nextjs-app/components/compare/CompareCommunity.tsx index 9ae9cc3..186c427 100644 --- a/nextjs-app/components/compare/CompareCommunity.tsx +++ b/nextjs-app/components/compare/CompareCommunity.tsx @@ -9,7 +9,7 @@ import { verdict } from '@/lib/compareLogic'; import type { Benchmarks, ComparisonData, School } from '@/lib/types'; -import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; +import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared'; function pctSplit(part: number | null | undefined, total: number | null | undefined): string | null { if (part == null || total == null || total === 0) return null; @@ -48,7 +48,7 @@ export function CompareCommunity({ 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." > - Pupils on roll + {schools.map((school, i) => { const info = data[String(school.urn)]?.school_info as (School & { gias_total_pupils?: number | null; capacity?: number | null }) | undefined; const census = data[String(school.urn)]?.census; @@ -74,8 +74,9 @@ export function CompareCommunity({ ); })} + - Girls / boys + {schools.map((school, i) => { const census = data[String(school.urn)]?.census; const girls = pctSplit(census?.female_pupils, census?.total_pupils); @@ -86,10 +87,12 @@ export function CompareCommunity({ ); })} + - - Free school meals - + {schools.map((school, i) => { const fsm = data[String(school.urn)]?.census?.fsm_pct ?? null; return ( @@ -104,10 +107,12 @@ export function CompareCommunity({ ); })} + - - English as an additional language - + {schools.map((school, i) => { const eal = data[String(school.urn)]?.census?.eal_pct ?? null; return ( @@ -116,10 +121,12 @@ export function CompareCommunity({ ); })} + - - Extra learning support (SEN) - + {schools.map((school, i) => { const rows = data[String(school.urn)]?.yearly_data ?? []; let sen: number | null = null; @@ -143,8 +150,9 @@ export function CompareCommunity({ ); })} + - Faith character + {schools.map((school, i) => { const info = data[String(school.urn)]?.school_info; const faith = info?.religious_denomination; @@ -155,8 +163,9 @@ export function CompareCommunity({ ); })} + - Ages + {schools.map((school, i) => { const info = data[String(school.urn)]?.school_info; return ( @@ -165,8 +174,9 @@ export function CompareCommunity({ ); })} + - Run by + {schools.map((school, i) => { const info = data[String(school.urn)]?.school_info; const trust = info?.trust_name; @@ -177,6 +187,7 @@ export function CompareCommunity({ ); })} + ); diff --git a/nextjs-app/components/compare/CompareOfsted.tsx b/nextjs-app/components/compare/CompareOfsted.tsx index e148674..805b1af 100644 --- a/nextjs-app/components/compare/CompareOfsted.tsx +++ b/nextjs-app/components/compare/CompareOfsted.tsx @@ -13,7 +13,7 @@ import { type OfstedDisplay, } from '@/lib/compareLogic'; import type { ComparisonData, OfstedInspection, School } from '@/lib/types'; -import { Cell, Chip, RowLabel, Section, SectionGrid, sectionStyles as s } from './sectionShared'; +import { Cell, Chip, Measure, Section, SectionGrid, sectionStyles as s } from './sectionShared'; const GRADE_TONE: Record = { 1: 'good', @@ -157,14 +157,15 @@ export function CompareOfsted({ } > - Result + {schools.map((school, i) => ( ))} + - Inspected + {schools.map((school, i) => { const ofsted = data[String(school.urn)]?.ofsted; const age = yearsSince(ofsted?.inspection_date ?? null); @@ -176,9 +177,12 @@ export function CompareOfsted({ ); })} - - Judgement detail - + + + {schools.map((school, i) => { const ofsted = data[String(school.urn)]?.ofsted; return ( @@ -196,9 +200,12 @@ export function CompareOfsted({ ); })} - - Ofsted page - + + + {schools.map((school, i) => { const url = data[String(school.urn)]?.ofsted?.ofsted_page_url ?? @@ -211,6 +218,7 @@ export function CompareOfsted({ ); })} + ); diff --git a/nextjs-app/components/compare/compareSections.module.css b/nextjs-app/components/compare/compareSections.module.css index 895adae..5cd0b4b 100644 --- a/nextjs-app/components/compare/compareSections.module.css +++ b/nextjs-app/components/compare/compareSections.module.css @@ -31,43 +31,71 @@ margin-top: 1.25rem; } +/* Mobile base: each measure is a card; each cell is a school row led by a + colour dot + short name. `display: contents` at ≥761px dissolves the card + back into the shared grid. */ +.measure { + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: 12px; + box-shadow: var(--shadow-soft); + padding: 0.75rem 0.85rem; + margin-bottom: 0.6rem; +} + .rowLabel { font-size: 0.85rem; font-weight: 600; - color: var(--text-secondary); + color: var(--text-primary); display: flex; align-items: center; gap: 0.35rem; - background: var(--bg-secondary); - border-radius: 6px; - padding: 0.4rem 0.6rem; - margin-top: 0.8rem; + padding: 0 0 0.1rem; } .cell { - padding: 0.4rem 0.6rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding: 0.45rem 0; + border-top: 1px solid var(--border-light); + margin-top: 0.45rem; font-size: 0.95rem; } -.cell::before { - content: attr(data-school); - display: block; - font-size: 0.72rem; +.cellTag { + display: inline-flex; + align-items: center; + gap: 0.4rem; + width: 5rem; + flex: none; + font-size: 0.8rem; font-weight: 600; - color: var(--sc, var(--text-muted)); + color: var(--sc, var(--text-secondary)); +} + +.cellDot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--dot, var(--text-muted)); + flex: none; } .big { - font-size: 1.35rem; + font-size: 1.05rem; font-weight: 700; font-variant-numeric: tabular-nums; } .small { display: block; + flex-basis: 100%; + padding-left: 5.5rem; font-size: 0.8rem; color: var(--text-muted); - margin-top: 0.1rem; + margin-top: -0.05rem; } .chip { @@ -197,20 +225,37 @@ gap: 0 0.75rem; } + /* Dissolve the per-measure card so its label + cells become grid items of + .grid, keeping columns aligned across every measure. */ + .measure { + display: contents; + } + + .cellTag { + display: none; + } + .rowLabel { - background: none; - border-radius: 0; - margin-top: 0; + color: var(--text-secondary); padding: 0.85rem 0.5rem 0.85rem 0; border-bottom: 1px solid var(--border-light); } .cell { + display: block; padding: 0.85rem 0.25rem; + border-top: none; border-bottom: 1px solid var(--border-light); + margin-top: 0; } - .cell::before { - content: none; + .big { + font-size: 1.35rem; + } + + .small { + flex-basis: auto; + padding-left: 0; + margin-top: 0.1rem; } } diff --git a/nextjs-app/components/compare/sectionShared.tsx b/nextjs-app/components/compare/sectionShared.tsx index 89b5d7a..0c27ea5 100644 --- a/nextjs-app/components/compare/sectionShared.tsx +++ b/nextjs-app/components/compare/sectionShared.tsx @@ -10,7 +10,7 @@ import type { CSSProperties, ReactNode } from 'react'; import type { School } from '@/lib/types'; -import { CHART_TEXT_COLORS } from '@/lib/utils'; +import { CHART_COLORS, CHART_TEXT_COLORS, shortName } from '@/lib/utils'; import styles from './compareSections.module.css'; export function Section({ @@ -61,6 +61,29 @@ export function RowLabel({ children, tip }: { children: ReactNode; tip?: string ); } +/** + * One measure = its row label plus a cell per school. `display: contents` on + * desktop (see CSS) makes these flow into the section grid as if this wrapper + * weren't here, keeping columns aligned across measures; on mobile the wrapper + * becomes a card so each measure reads as its own block. + */ +export function Measure({ + label, + tip, + children, +}: { + label: ReactNode; + tip?: string; + children: ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + export function Cell({ school, index, @@ -73,9 +96,19 @@ export function Cell({ return (
+ {/* Mobile-only per-school tag (dot + short name); hidden on desktop, + where the column header identifies the school. */} + + {children}
); diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index fa1edf0..1e1305d 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -59,6 +59,24 @@ export function truncate(text: string, maxLength: number): string { return text.slice(0, maxLength).trim() + '...'; } +/** + * A compact school label for tight spaces (mobile compare rows, chip bars): + * drop the trailing establishment-type words so "Barclay Primary School" → + * "Barclay", "St Mary's Catholic Primary School" → "St Mary's". Falls back to + * a length-capped truncation for names that don't carry a type suffix. + */ +export function shortName(name: string, maxLength = 20): string { + let s = name + .replace( + /\s+(primary|junior|infant|nursery|community|foundation|catholic|academy|school|college)\b.*$/i, + '', + ) + .trim(); + if (!s) s = name; + if (s.length > maxLength) s = s.slice(0, maxLength - 1).trim() + '…'; + return s; +} + /** * Format a school's age range for display, e.g. "3-11" → "Ages 3–11". * Display-only — leaves the raw `age_range` field (used for sixth-form