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