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
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:
commit
b34feb8e98
19 files changed
+319
-30
No files matched your search
+1
-1
@@ -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>"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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') },
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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]),
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user