Merge pull request 'feat(compare): five per phase, not five overall' (#156) from feat/compare-limit-per-phase into main
Stage (build -> staging -> E2E gate) / prepare (push) Successful in 1s
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 21s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 1m25s
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 29s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Successful in 2m57s

Reviewed-on: #156
This commit was merged in pull request #156.
This commit is contained in:
tudor committed 2026-09-30 11:23:07 +00:00
commit b34feb8e98
19 files changed
+319 -30

No files matched your search

+1 -1
View File
@@ -109,7 +109,7 @@ def _has_publishable_data(row) -> bool:
def _url_element(loc: str, lastmod: str | None = None) -> str:
"""One <url> entry. No priority or changefreq — Google ignores both."""
"""One <url> entry. No priority or changefreq, Google ignores both."""
body = f"<loc>{loc}</loc>"
if lastmod:
body += f"<lastmod>{lastmod}</lastmod>"
+4
View File
@@ -250,6 +250,10 @@ def select_nearby(frame: pd.DataFrame, urn: int) -> list[dict]:
"distance_miles": float(row["distance_miles"]),
"school_type": _native(row.get("school_type")),
"age_range": _native(row.get("age_range")),
# Each peer's own phase, not the subject's: the pool is a phase
# group, so an all-through school can sit beside a primary. The
# compare basket counts it against both of its tabs.
"phase": _native(row.get("phase")),
"shared": _shared(subject, row, is_secondary),
"metric_value": _native(row.get(metric_key)),
"metric_key": metric_key,
+12
View File
@@ -163,6 +163,18 @@ def test_secondary_reaches_further_than_primary():
assert {s["urn"] for s in select_nearby(frame, 100001)} == {100002, 100003}
def test_each_card_carries_its_own_phase():
# The compare basket limits each phase separately, so an all-through peer
# must not inherit the subject's "Primary".
frame = _frame(
_row(100001, "Subject"),
_row(100002, "A", latitude=_at(0.5)),
_row(100003, "B", phase="All-through", age_range="4-18", latitude=_at(0.6)),
)
phases = {s["urn"]: s["phase"] for s in select_nearby(frame, 100001)}
assert phases == {100002: "Primary", 100003: "All-through"}
def test_the_cap_follows_the_phase():
assert radius_miles("Primary") == 2.0
assert radius_miles("Middle deemed primary") == 2.0
+3 -3
View File
@@ -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);
});
+1 -1
View File
@@ -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/);
}
@@ -0,0 +1,114 @@
/**
* The basket limit is five per phase group, not five overall, and the
* provider is where it is enforced for every add path.
*
* Entries without a phase count against both groups. Baskets saved before
* phases were recorded hold such entries, so the compare page backfills them
* once it has fetched each school.
*/
import { act, render, screen, fireEvent } from '@testing-library/react';
import { AddToCompareButton } from '@/components/school/AddToCompareButton';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import { useComparisonContext } from '@/context/ComparisonContext';
import type { NearbySchool, School } from '@/lib/types';
type Ctx = ReturnType<typeof useComparisonContext>;
function renderBasket(stored: Partial<School>[] = []) {
window.localStorage.setItem('selectedSchools', JSON.stringify(stored));
const ref: { current: Ctx | null } = { current: null };
function Probe() {
ref.current = useComparisonContext();
return null;
}
render(
<ComparisonProvider>
<Probe />
</ComparisonProvider>,
);
return ref as { current: Ctx };
}
const school = (urn: number, phase: string | null) =>
({ urn, school_name: `School ${urn}`, phase }) as School;
beforeEach(() => {
window.localStorage.clear();
jest.spyOn(window, 'alert').mockImplementation(() => {});
});
afterEach(() => jest.restoreAllMocks());
it('holds five primary and five secondary schools, and no more of either', () => {
const ctx = renderBasket();
act(() => {
for (let i = 0; i < 6; i++) ctx.current.addSchool(school(100000 + i, 'Primary'));
for (let i = 0; i < 6; i++) ctx.current.addSchool(school(200000 + i, 'Secondary'));
});
expect(ctx.current.selectedSchools).toHaveLength(10);
expect(window.alert).toHaveBeenCalledWith(expect.stringMatching(/5 primary schools/));
expect(window.alert).toHaveBeenCalledWith(expect.stringMatching(/5 secondary schools/));
});
it('frees the second group once a stored entry learns its phase', () => {
// Three phase-less entries from an older basket plus two primaries: the
// primary group reads as full although only two are really primary.
const ctx = renderBasket([
school(100001, null),
school(100002, null),
school(100003, null),
school(100004, 'Primary'),
school(100005, 'Primary'),
]);
expect(ctx.current.fullGroupFor({ phase: 'Primary' })).toBe('primary');
act(() => {
ctx.current.backfillPhases({ 100001: 'Secondary', 100002: 'Secondary', 100003: 'Secondary' });
});
expect(ctx.current.fullGroupFor({ phase: 'Primary' })).toBeNull();
expect(ctx.current.selectedSchools.map((s) => s.phase)).toEqual([
'Secondary',
'Secondary',
'Secondary',
'Primary',
'Primary',
]);
});
it('never overwrites a phase the basket already has', () => {
const ctx = renderBasket([school(100001, 'Primary')]);
act(() => ctx.current.backfillPhases({ 100001: 'All-through' }));
expect(ctx.current.selectedSchools[0].phase).toBe('Primary');
});
it('a nearby card adds its own phase, not an unknown one', () => {
const nearby = {
urn: 100009,
school_name: 'Nearby',
distance_miles: 0.4,
school_type: 'Academy',
age_range: '4-18',
phase: 'All-through',
shared: [],
metric_value: null,
metric_key: 'rwm_expected_pct',
metric_year: null,
} as NearbySchool;
const ref: { current: Ctx | null } = { current: null };
function Probe() {
ref.current = useComparisonContext();
return null;
}
render(
<ComparisonProvider>
<Probe />
<AddToCompareButton school={nearby} />
</ComparisonProvider>,
);
fireEvent.click(screen.getByRole('button', { name: /Add to compare/ }));
expect(ref.current?.selectedSchools[0].phase).toBe('All-through');
});
@@ -6,8 +6,12 @@
*/
import {
MAX_PER_GROUP,
OFSTED_LEGACY_GRADES,
admissionsForPhase,
compareGroups,
fitToGroupLimits,
fullGroupFor,
ofstedDisplay,
progressBand,
rcAreaLabel,
@@ -313,3 +317,48 @@ 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']);
expect(compareGroups('16 plus')).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]);
});
});
+2 -2
View File
@@ -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') },
+1 -1
View File
@@ -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'];
+13 -1
View File
@@ -53,7 +53,8 @@ export function ComparisonView({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
const { selectedSchools, removeSchool, replaceSchools, backfillPhases, isInitialized } =
useComparison();
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -157,6 +158,17 @@ export function ComparisonView({
};
}, [urnKey, isInitialized]);
useEffect(() => {
if (!comparisonData) return;
backfillPhases(
Object.fromEntries(
Object.values(comparisonData)
.filter((d) => d?.school_info)
.map((d) => [d.school_info.urn, d.school_info.phase]),
),
);
}, [comparisonData, backfillPhases]);
const primarySchools = selectedSchools.filter((school) => {
const info = comparisonData?.[school.urn]?.school_info;
const hasPrimaryData =
+7 -6
View File
@@ -149,11 +149,12 @@ 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
* 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.
* four previously did not: "up to three schools" contradicted the basket limit
* of five (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.
* The limit is now five per phase: MAX_PER_GROUP in lib/compareLogic.ts.
*/
const VALUE_PROPS: ValueProp[] = [
{
@@ -179,7 +180,7 @@ const VALUE_PROPS: ValueProp[] = [
icon: <BarsIcon />,
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: <HeartIcon />,
+2 -2
View File
@@ -143,7 +143,7 @@ export function HowItWorksSection() {
<span className={styles.compareRowVal}>{b}</span>
</div>
))}
<div className={styles.compareFoot}>+ compare up to 5 schools</div>
<div className={styles.compareFoot}>+ up to 5 primary &amp; 5 secondary</div>
</div>
</div>
<div className={styles.hiwCardBody}>
@@ -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. */}
<p className={styles.hiwDesc}>Compare up to five schools and every metric aligns in the same columns. It works for primary and secondary alike.</p>
<p className={styles.hiwDesc}>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.</p>
</div>
</div>
</div>
+3
View File
@@ -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,
+24 -7
View File
@@ -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<School[]>([]);
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 (
<Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison">
<div className={styles.modalContent}>
{!canAddMore && (
<div className={styles.warning}>
Maximum 5 schools can be compared. Remove a school to add another.
</div>
{limitWarning && (
<div className={styles.warning}>{limitWarning}</div>
)}
{/* Search Input */}
@@ -112,6 +124,7 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
{results.map((school) => {
const alreadySelected = isSchoolSelected(school.urn);
const fullGroup = alreadySelected ? null : fullGroupFor(school);
return (
<div key={school.urn} className={styles.resultItem}>
@@ -126,12 +139,16 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
</div>
<button
onClick={() => handleAddSchool(school)}
disabled={alreadySelected || !canAddMore}
disabled={alreadySelected || fullGroup !== null}
className={`${styles.resultButton} ${
alreadySelected ? "btn btn-active" : "btn btn-secondary"
}`}
>
{alreadySelected ? "✓ Comparing" : "+ Compare"}
{alreadySelected
? "✓ Comparing"
: fullGroup
? `${fullGroup === "primary" ? "Primary" : "Secondary"} full`
: "+ Compare"}
</button>
</div>
);
@@ -28,6 +28,9 @@ export function AddToCompareButton({ school }: { school: NearbySchool }) {
school_name: school.school_name,
school_type: school.school_type,
age_range: school.age_range,
// The basket limits each phase separately. Missing (an older API)
// counts against both groups, which is safe, just stricter.
phase: school.phase ?? null,
} as School);
};
+5 -1
View File
@@ -6,6 +6,7 @@
'use client';
import { createContext, useContext } from 'react';
import type { CompareGroup } from '@/lib/compareLogic';
import type { School } from '@/lib/types';
interface ComparisonContextType {
@@ -16,9 +17,12 @@ interface ComparisonContextType {
addSchool: (school: School) => void;
removeSchool: (urn: number) => void;
replaceSchools: (schools: School[]) => void;
/** Fill in phases missing from stored entries; never overwrites one. */
backfillPhases: (phases: Record<number, string | null | undefined>) => void;
clearAll: () => void;
isSelected: (urn: number) => boolean;
canAddMore: boolean;
/** The comparison group with no room for this school, or null. */
fullGroupFor: (school: Pick<School, 'phase'>) => CompareGroup | null;
isInitialized: boolean;
mutate: () => void;
}
+28 -5
View File
@@ -7,11 +7,11 @@
import { useState, useEffect, useCallback } from 'react';
import { getFromLocalStorage, setToLocalStorage } from '@/lib/utils';
import { MAX_PER_GROUP, fitToGroupLimits, fullGroupFor } from '@/lib/compareLogic';
import type { School } from '@/lib/types';
import { ComparisonContext } from './ComparisonContext';
const STORAGE_KEY = 'selectedSchools';
const MAX_SCHOOLS = 5;
export function ComparisonProvider({ children }: { children: React.ReactNode }) {
const [selectedSchools, setSelectedSchools] = useState<School[]>([]);
@@ -53,8 +53,9 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
if (prev.some((s) => s.urn === school.urn)) {
return prev;
}
if (prev.length >= MAX_SCHOOLS) {
alert(`Maximum ${MAX_SCHOOLS} schools can be compared`);
const fullGroup = fullGroupFor(prev, school);
if (fullGroup) {
alert(`You can compare up to ${MAX_PER_GROUP} ${fullGroup} schools at once. Remove one to add another.`);
return prev;
}
return [...prev, school];
@@ -68,7 +69,23 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
// Replace the whole selection — used when a shared /compare?urns=… link
// must take precedence over whatever this visitor had stored.
const replaceSchools = useCallback((schools: School[]) => {
setSelectedSchools(schools.slice(0, MAX_SCHOOLS));
setSelectedSchools(fitToGroupLimits(schools));
}, []);
// Baskets saved before phases were recorded (or added from a path that
// lacked one) count against both groups. Fill the gaps once the compare
// page has fetched each school, so they stop holding a slot they don't need.
const backfillPhases = useCallback((phases: Record<number, string | null | undefined>) => {
setSelectedSchools((prev) => {
let changed = false;
const next = prev.map((s) => {
const phase = phases[s.urn];
if (s.phase || !phase) return s;
changed = true;
return { ...s, phase };
});
return changed ? next : prev;
});
}, []);
const clearAll = useCallback(() => {
@@ -80,6 +97,11 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
[selectedSchools]
);
const fullGroupForSchool = useCallback(
(school: Pick<School, 'phase'>) => fullGroupFor(selectedSchools, school),
[selectedSchools]
);
// Placeholder mutate - actual SWR mutate is in useComparison hook
const mutate = useCallback(() => {}, []);
@@ -93,9 +115,10 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
addSchool,
removeSchool,
replaceSchools,
backfillPhases,
clearAll,
isSelected,
canAddMore: selectedSchools.length < MAX_SCHOOLS,
fullGroupFor: fullGroupForSchool,
isInitialized,
mutate,
}}
+45
View File
@@ -289,3 +289,48 @@ export function latestValues(
return null;
});
}
// ---------------------------------------------------------------------------
// Basket limits — five per comparison group, not five overall
// ---------------------------------------------------------------------------
export type CompareGroup = 'primary' | 'secondary';
export const MAX_PER_GROUP = 5;
/**
* Which phase tab(s) of the compare page a school can land in. The page
* sorts by the data a school actually has, which isn't known when it's added,
* so anything that isn't plainly one phase (all-through, special schools
* with phase "Not applicable", unknown) counts against BOTH groups. That
* keeps every tab at MAX_PER_GROUP or fewer, which the five-slot chart
* palette and point styles rely on.
*/
export function compareGroups(phase?: string | null): CompareGroup[] {
const p = (phase ?? '').toLowerCase();
// "Middle deemed secondary" / "Middle deemed primary" match here too.
// "16 plus" is secondary, as the API's PHASE_GROUPS files it.
if (p.includes('secondary') || p === '16 plus') return ['secondary'];
if (p.includes('primary')) return ['primary'];
return ['primary', 'secondary'];
}
/** The group that has no room for `school`, or null if it can be added. */
export function fullGroupFor(
basket: ReadonlyArray<{ phase?: string | null }>,
school: { phase?: string | null },
): CompareGroup | null {
for (const group of compareGroups(school.phase)) {
const used = basket.filter((s) => compareGroups(s.phase).includes(group)).length;
if (used >= MAX_PER_GROUP) return group;
}
return null;
}
/** Keep schools in order, dropping any that would overflow a group. */
export function fitToGroupLimits<T extends { phase?: string | null }>(schools: T[]): T[] {
return schools.reduce<T[]>(
(kept, school) => (fullGroupFor(kept, school) ? kept : [...kept, school]),
[],
);
}
+2
View File
@@ -360,6 +360,8 @@ export interface NearbySchool {
distance_miles: number;
school_type: string | null;
age_range: string | null;
/** Optional: a frontend can ship ahead of the API that serves it. */
phase?: string | null;
shared: string[];
metric_value: number | null;
metric_key: string;