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/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 && (
-
-
-
-
- | Year |
- {schools.map((school) => (
- {school.school_name} |
- ))}
-
-
-
- {years.map((year) => (
-
- | {formatAcademicYear(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 (
-
- {formatMetricValue(value, metricKind(metric))}{' '}
- {band && (
-
- {BAND_LABEL[band]}
-
- )}
- |
- );
- })}
-
- ))}
-
-
-
- )}
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. */}
+
+
+ {shortName(school.school_name)}
+
{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