diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts
index e4060de..1b85744 100644
--- a/e2e/tests/journeys.spec.ts
+++ b/e2e/tests/journeys.spec.ts
@@ -122,9 +122,9 @@ test('the coverage figure is live, not the hardcoded fallback', async ({ page })
test('the landing page states the real comparison limit', async ({ page }) => {
await page.goto('/');
const body = await page.locator('body').innerText();
- // MAX_SCHOOLS in context/ComparisonProvider.tsx is 5. The hero used to
- // promise three while a card lower on the same page promised five.
- expect(body).toMatch(/five schools/i);
+ // MAX_PER_GROUP in lib/compareLogic.ts is 5 per phase. The hero once
+ // promised three while a card lower on the same page promised five.
+ expect(body).toMatch(/five primary and five secondary schools/i);
expect(body).not.toMatch(/three schools/i);
});
diff --git a/nextjs-app/__tests__/app/metadata.test.ts b/nextjs-app/__tests__/app/metadata.test.ts
index edfbddb..534cc74 100644
--- a/nextjs-app/__tests__/app/metadata.test.ts
+++ b/nextjs-app/__tests__/app/metadata.test.ts
@@ -123,7 +123,7 @@ describe('C1 snippet copy', () => {
it('no C1 page claims a school count that will drift', () => {
// The corpus moves with every data refresh; this repo has already shipped
- // one copy bug of that kind ("three schools" against MAX_SCHOOLS = 5).
+ // one copy bug of that kind ("three schools" against a limit of five).
for (const [, meta] of pages) {
expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/);
}
diff --git a/nextjs-app/__tests__/lib/compareLogic.test.ts b/nextjs-app/__tests__/lib/compareLogic.test.ts
index 131e26a..5051b5c 100644
--- a/nextjs-app/__tests__/lib/compareLogic.test.ts
+++ b/nextjs-app/__tests__/lib/compareLogic.test.ts
@@ -6,8 +6,12 @@
*/
import {
+ MAX_PER_GROUP,
OFSTED_LEGACY_GRADES,
admissionsForPhase,
+ compareGroups,
+ fitToGroupLimits,
+ fullGroupFor,
ofstedDisplay,
progressBand,
rcAreaLabel,
@@ -313,3 +317,47 @@ describe('latestValues', () => {
expect(latestValues(data, [1], 'nope')).toEqual([null]);
});
});
+
+describe('basket limits per comparison group', () => {
+ const school = (phase: string | null, urn = 0) => ({ urn, phase });
+ const many = (phase: string | null, n: number, from = 0) =>
+ Array.from({ length: n }, (_, i) => school(phase, from + i));
+
+ it('files each phase under its compare tab', () => {
+ expect(compareGroups('Primary')).toEqual(['primary']);
+ expect(compareGroups('Middle deemed primary')).toEqual(['primary']);
+ expect(compareGroups('Secondary')).toEqual(['secondary']);
+ expect(compareGroups('Middle deemed secondary')).toEqual(['secondary']);
+ });
+
+ it('counts schools that could land in either tab against both', () => {
+ expect(compareGroups('All-through')).toEqual(['primary', 'secondary']);
+ expect(compareGroups('Not applicable')).toEqual(['primary', 'secondary']);
+ expect(compareGroups(null)).toEqual(['primary', 'secondary']);
+ });
+
+ it('a full primary group still admits secondary schools', () => {
+ const basket = many('Primary', MAX_PER_GROUP);
+ expect(fullGroupFor(basket, school('Primary'))).toBe('primary');
+ expect(fullGroupFor(basket, school('Secondary'))).toBeNull();
+ expect(fullGroupFor(basket, school('All-through'))).toBe('primary');
+ });
+
+ it('allows ten schools in total, five per group', () => {
+ const basket = [...many('Primary', 5), ...many('Secondary', 5, 5)];
+ expect(fullGroupFor(basket, school('Primary'))).toBe('primary');
+ expect(fullGroupFor(basket, school('Secondary'))).toBe('secondary');
+ });
+
+ it('an all-through school uses a slot in both groups', () => {
+ const basket = [school('All-through'), ...many('Primary', 4, 1)];
+ expect(fullGroupFor(basket, school('Primary'))).toBe('primary');
+ expect(fullGroupFor(basket, school('Secondary'))).toBeNull();
+ });
+
+ it('trims an oversized shared link per group, keeping order', () => {
+ const urls = [...many('Primary', 7), ...many('Secondary', 3, 7)];
+ const kept = fitToGroupLimits(urls).map((s) => s.urn);
+ expect(kept).toEqual([0, 1, 2, 3, 4, 7, 8, 9]);
+ });
+});
diff --git a/nextjs-app/app/(frontend)/compare/page.tsx b/nextjs-app/app/(frontend)/compare/page.tsx
index ac99ff5..ccaab1e 100644
--- a/nextjs-app/app/(frontend)/compare/page.tsx
+++ b/nextjs-app/app/(frontend)/compare/page.tsx
@@ -33,9 +33,9 @@ export async function generateMetadata(
// Deliberately not the homepage's phrase. Two pages chasing "compare
// schools" is how a site competes with itself; this one takes the tool
// phrasing instead.
- title: 'School Comparison Tool: Up to Five Schools at Once | schoolcompare',
+ title: 'School Comparison Tool: Primary and Secondary Schools | schoolcompare',
description:
- 'Put up to five English schools in one table: SATs and GCSE results against the England average, Ofsted grades, and the distance places were offered.',
+ 'Put up to five primary and five secondary schools in England in one table: SATs and GCSE results against the England average, Ofsted grades, and the distance places were offered.',
keywords:
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
alternates: { canonical: absoluteUrl('/compare') },
diff --git a/nextjs-app/components/ComparisonChart.tsx b/nextjs-app/components/ComparisonChart.tsx
index 847ee17..5edf4a7 100644
--- a/nextjs-app/components/ComparisonChart.tsx
+++ b/nextjs-app/components/ComparisonChart.tsx
@@ -43,7 +43,7 @@ interface ComparisonChartProps {
isSecondary?: boolean;
}
-// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
+// One shape per slot in a phase tab (MAX_PER_GROUP = 5) — secondary encoding so
// converging lines stay tellable apart without relying on hue alone.
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx
index d0f16c8..90ee1c8 100644
--- a/nextjs-app/components/HomeView.tsx
+++ b/nextjs-app/components/HomeView.tsx
@@ -149,8 +149,8 @@ interface ValueProp {
* and is backed by the next-deadline bar further down this page.
*
* Every claim here must name something the product actually does. Two of the
- * four previously did not: "up to three schools" contradicted MAX_SCHOOLS = 5
- * in context/ComparisonProvider.tsx (and the card further down the page, which
+ * four previously did not: "up to three schools" contradicted the basket limit
+ * (now MAX_PER_GROUP = 5 per phase, in lib/compareLogic.ts) (and the card further down the page, which
* correctly said five), and "class sizes" described data the codebase has never
* held — grep for it and this line was the only hit. Both are corrected below
* against the real fields, which live in components/school/InclusionSection.tsx.
@@ -179,7 +179,7 @@ const VALUE_PROPS: ValueProp[] = [
icon: ,
tintClass: styles.propIconCompare,
title: 'Easy to compare',
- body: 'Up to five schools side by side, on the measures that matter.',
+ body: 'Up to five primary and five secondary schools side by side, on the measures that matter.',
},
{
icon: ,
diff --git a/nextjs-app/components/HowItWorksSection.tsx b/nextjs-app/components/HowItWorksSection.tsx
index 53f0661..cd04b06 100644
--- a/nextjs-app/components/HowItWorksSection.tsx
+++ b/nextjs-app/components/HowItWorksSection.tsx
@@ -143,7 +143,7 @@ export function HowItWorksSection() {
{b}
))}
-
+ compare up to 5 schools
+
+ up to 5 primary & 5 secondary
@@ -152,7 +152,7 @@ export function HowItWorksSection() {
{/* One verb for one feature. The site previously called this
"compare" in the nav, "shortlist" in the footer and "pin" here,
which reads as three separate things it does not have. */}
-
Compare up to five schools and every metric aligns in the same columns. It works for primary and secondary alike.
+
Compare primary and secondary school performance side by side: KS2 SATs results for primary schools, GCSE Attainment 8 for secondary schools, and Ofsted ratings for both, lined up in one table so you can see where each school is stronger.
diff --git a/nextjs-app/components/RankingsView.tsx b/nextjs-app/components/RankingsView.tsx
index e6e6d9e..4e26a40 100644
--- a/nextjs-app/components/RankingsView.tsx
+++ b/nextjs-app/components/RankingsView.tsx
@@ -95,6 +95,9 @@ export function RankingsView({
const handleAddToCompare = (ranking: RankingEntry) => {
addSchool({
...ranking,
+ // Ranking rows carry no phase, but the tab they're ranked under is
+ // theirs. The compare basket's per-phase limit needs it.
+ phase: isPrimary ? 'Primary' : 'Secondary',
address: null,
postcode: null,
latitude: null,
diff --git a/nextjs-app/components/SchoolSearchModal.tsx b/nextjs-app/components/SchoolSearchModal.tsx
index e7e6d42..4886a20 100644
--- a/nextjs-app/components/SchoolSearchModal.tsx
+++ b/nextjs-app/components/SchoolSearchModal.tsx
@@ -11,6 +11,7 @@ import { useComparison } from "@/hooks/useComparison";
import { debounce } from "@/lib/utils";
import { fetchSchools } from "@/lib/api";
import { track } from "@/lib/analytics";
+import { MAX_PER_GROUP } from "@/lib/compareLogic";
import type { School } from "@/lib/types";
import styles from "./SchoolSearchModal.module.css";
@@ -20,7 +21,7 @@ interface SchoolSearchModalProps {
}
export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
- const { addSchool, selectedSchools, canAddMore } = useComparison();
+ const { addSchool, selectedSchools, fullGroupFor } = useComparison();
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
@@ -73,6 +74,19 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
return selectedSchools.some((s) => s.urn === urn);
};
+ // Limits apply per phase group, so a full primary list must not stop a
+ // parent adding secondary schools.
+ const primaryFull = fullGroupFor({ phase: "Primary" }) !== null;
+ const secondaryFull = fullGroupFor({ phase: "Secondary" }) !== null;
+ const limitWarning =
+ primaryFull && secondaryFull
+ ? `You're comparing ${MAX_PER_GROUP} primary and ${MAX_PER_GROUP} secondary schools, the most at once. Remove a school to add another.`
+ : primaryFull
+ ? `You're comparing ${MAX_PER_GROUP} primary schools, the most at once. You can still add secondary schools.`
+ : secondaryFull
+ ? `You're comparing ${MAX_PER_GROUP} secondary schools, the most at once. You can still add primary schools.`
+ : null;
+
const handleClose = () => {
setSearchTerm("");
setResults([]);
@@ -83,10 +97,8 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
return (
- {!canAddMore && (
-
- Maximum 5 schools can be compared. Remove a school to add another.
-