Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc99865bd4 | ||
|
|
587cfe3f0b | ||
|
|
0a4c051ee5 | ||
|
|
343b40c645 |
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:
|
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>"
|
body = f"<loc>{loc}</loc>"
|
||||||
if lastmod:
|
if lastmod:
|
||||||
body += f"<lastmod>{lastmod}</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"]),
|
"distance_miles": float(row["distance_miles"]),
|
||||||
"school_type": _native(row.get("school_type")),
|
"school_type": _native(row.get("school_type")),
|
||||||
"age_range": _native(row.get("age_range")),
|
"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),
|
"shared": _shared(subject, row, is_secondary),
|
||||||
"metric_value": _native(row.get(metric_key)),
|
"metric_value": _native(row.get(metric_key)),
|
||||||
"metric_key": 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}
|
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():
|
def test_the_cap_follows_the_phase():
|
||||||
assert radius_miles("Primary") == 2.0
|
assert radius_miles("Primary") == 2.0
|
||||||
assert radius_miles("Middle deemed 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 }) => {
|
test('the landing page states the real comparison limit', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const body = await page.locator('body').innerText();
|
const body = await page.locator('body').innerText();
|
||||||
// MAX_SCHOOLS in context/ComparisonProvider.tsx is 5. The hero used to
|
// MAX_PER_GROUP in lib/compareLogic.ts is 5 per phase. The hero once
|
||||||
// promise three while a card lower on the same page promised five.
|
// promised three while a card lower on the same page promised five.
|
||||||
expect(body).toMatch(/five schools/i);
|
expect(body).toMatch(/five primary and five secondary schools/i);
|
||||||
expect(body).not.toMatch(/three 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', () => {
|
it('no C1 page claims a school count that will drift', () => {
|
||||||
// The corpus moves with every data refresh; this repo has already shipped
|
// 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) {
|
for (const [, meta] of pages) {
|
||||||
expect(meta.description as string).not.toMatch(/\b\d{2},\d{3}\b|\b\d{2},000\b/);
|
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 {
|
import {
|
||||||
|
MAX_PER_GROUP,
|
||||||
OFSTED_LEGACY_GRADES,
|
OFSTED_LEGACY_GRADES,
|
||||||
admissionsForPhase,
|
admissionsForPhase,
|
||||||
|
compareGroups,
|
||||||
|
fitToGroupLimits,
|
||||||
|
fullGroupFor,
|
||||||
ofstedDisplay,
|
ofstedDisplay,
|
||||||
progressBand,
|
progressBand,
|
||||||
rcAreaLabel,
|
rcAreaLabel,
|
||||||
@@ -313,3 +317,48 @@ describe('latestValues', () => {
|
|||||||
expect(latestValues(data, [1], 'nope')).toEqual([null]);
|
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
|
// Deliberately not the homepage's phrase. Two pages chasing "compare
|
||||||
// schools" is how a site competes with itself; this one takes the tool
|
// schools" is how a site competes with itself; this one takes the tool
|
||||||
// phrasing instead.
|
// phrasing instead.
|
||||||
title: 'School Comparison Tool: Up to Five Schools at Once | schoolcompare',
|
title: 'School Comparison Tool: Primary and Secondary Schools | schoolcompare',
|
||||||
description:
|
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:
|
keywords:
|
||||||
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
'school comparison, compare schools, Ofsted comparison, school admissions, KS2 comparison, primary school performance',
|
||||||
alternates: { canonical: absoluteUrl('/compare') },
|
alternates: { canonical: absoluteUrl('/compare') },
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ interface ComparisonChartProps {
|
|||||||
isSecondary?: boolean;
|
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.
|
// converging lines stay tellable apart without relying on hue alone.
|
||||||
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ export function ComparisonView({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { selectedSchools, removeSchool, replaceSchools, isInitialized } = useComparison();
|
const { selectedSchools, removeSchool, replaceSchools, backfillPhases, isInitialized } =
|
||||||
|
useComparison();
|
||||||
|
|
||||||
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
const [selectedMetric, setSelectedMetric] = useState(initialMetric);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -157,6 +158,17 @@ export function ComparisonView({
|
|||||||
};
|
};
|
||||||
}, [urnKey, isInitialized]);
|
}, [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 primarySchools = selectedSchools.filter((school) => {
|
||||||
const info = comparisonData?.[school.urn]?.school_info;
|
const info = comparisonData?.[school.urn]?.school_info;
|
||||||
const hasPrimaryData =
|
const hasPrimaryData =
|
||||||
|
|||||||
@@ -149,11 +149,12 @@ interface ValueProp {
|
|||||||
* and is backed by the next-deadline bar further down this page.
|
* 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
|
* 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
|
* four previously did not: "up to three schools" contradicted the basket limit
|
||||||
* in context/ComparisonProvider.tsx (and the card further down the page, which
|
* of five (and the card further down the page, which correctly said five), and
|
||||||
* correctly said five), and "class sizes" described data the codebase has never
|
* "class sizes" described data the codebase has never held — grep for it and
|
||||||
* held — grep for it and this line was the only hit. Both are corrected below
|
* this line was the only hit. Both are corrected below against the real
|
||||||
* against the real fields, which live in components/school/InclusionSection.tsx.
|
* 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[] = [
|
const VALUE_PROPS: ValueProp[] = [
|
||||||
{
|
{
|
||||||
@@ -179,7 +180,7 @@ const VALUE_PROPS: ValueProp[] = [
|
|||||||
icon: <BarsIcon />,
|
icon: <BarsIcon />,
|
||||||
tintClass: styles.propIconCompare,
|
tintClass: styles.propIconCompare,
|
||||||
title: 'Easy to compare',
|
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 />,
|
icon: <HeartIcon />,
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function HowItWorksSection() {
|
|||||||
<span className={styles.compareRowVal}>{b}</span>
|
<span className={styles.compareRowVal}>{b}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<div className={styles.hiwCardBody}>
|
<div className={styles.hiwCardBody}>
|
||||||
@@ -152,7 +152,7 @@ export function HowItWorksSection() {
|
|||||||
{/* One verb for one feature. The site previously called this
|
{/* One verb for one feature. The site previously called this
|
||||||
"compare" in the nav, "shortlist" in the footer and "pin" here,
|
"compare" in the nav, "shortlist" in the footer and "pin" here,
|
||||||
which reads as three separate things it does not have. */}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export function RankingsView({
|
|||||||
const handleAddToCompare = (ranking: RankingEntry) => {
|
const handleAddToCompare = (ranking: RankingEntry) => {
|
||||||
addSchool({
|
addSchool({
|
||||||
...ranking,
|
...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,
|
address: null,
|
||||||
postcode: null,
|
postcode: null,
|
||||||
latitude: null,
|
latitude: null,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useComparison } from "@/hooks/useComparison";
|
|||||||
import { debounce } from "@/lib/utils";
|
import { debounce } from "@/lib/utils";
|
||||||
import { fetchSchools } from "@/lib/api";
|
import { fetchSchools } from "@/lib/api";
|
||||||
import { track } from "@/lib/analytics";
|
import { track } from "@/lib/analytics";
|
||||||
|
import { MAX_PER_GROUP } from "@/lib/compareLogic";
|
||||||
import type { School } from "@/lib/types";
|
import type { School } from "@/lib/types";
|
||||||
import styles from "./SchoolSearchModal.module.css";
|
import styles from "./SchoolSearchModal.module.css";
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ interface SchoolSearchModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
||||||
const { addSchool, selectedSchools, canAddMore } = useComparison();
|
const { addSchool, selectedSchools, fullGroupFor } = useComparison();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [results, setResults] = useState<School[]>([]);
|
const [results, setResults] = useState<School[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
@@ -73,6 +74,19 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
return selectedSchools.some((s) => s.urn === urn);
|
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 = () => {
|
const handleClose = () => {
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
setResults([]);
|
setResults([]);
|
||||||
@@ -83,10 +97,8 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison">
|
<Modal isOpen={isOpen} onClose={handleClose} title="Add School to Comparison">
|
||||||
<div className={styles.modalContent}>
|
<div className={styles.modalContent}>
|
||||||
{!canAddMore && (
|
{limitWarning && (
|
||||||
<div className={styles.warning}>
|
<div className={styles.warning}>{limitWarning}</div>
|
||||||
Maximum 5 schools can be compared. Remove a school to add another.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* Search Input */}
|
||||||
@@ -112,6 +124,7 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
|
|
||||||
{results.map((school) => {
|
{results.map((school) => {
|
||||||
const alreadySelected = isSchoolSelected(school.urn);
|
const alreadySelected = isSchoolSelected(school.urn);
|
||||||
|
const fullGroup = alreadySelected ? null : fullGroupFor(school);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={school.urn} className={styles.resultItem}>
|
<div key={school.urn} className={styles.resultItem}>
|
||||||
@@ -126,12 +139,16 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAddSchool(school)}
|
onClick={() => handleAddSchool(school)}
|
||||||
disabled={alreadySelected || !canAddMore}
|
disabled={alreadySelected || fullGroup !== null}
|
||||||
className={`${styles.resultButton} ${
|
className={`${styles.resultButton} ${
|
||||||
alreadySelected ? "btn btn-active" : "btn btn-secondary"
|
alreadySelected ? "btn btn-active" : "btn btn-secondary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{alreadySelected ? "✓ Comparing" : "+ Compare"}
|
{alreadySelected
|
||||||
|
? "✓ Comparing"
|
||||||
|
: fullGroup
|
||||||
|
? `${fullGroup === "primary" ? "Primary" : "Secondary"} full`
|
||||||
|
: "+ Compare"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export function AddToCompareButton({ school }: { school: NearbySchool }) {
|
|||||||
school_name: school.school_name,
|
school_name: school.school_name,
|
||||||
school_type: school.school_type,
|
school_type: school.school_type,
|
||||||
age_range: school.age_range,
|
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);
|
} as School);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { CompareGroup } from '@/lib/compareLogic';
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
|
|
||||||
interface ComparisonContextType {
|
interface ComparisonContextType {
|
||||||
@@ -16,9 +17,12 @@ interface ComparisonContextType {
|
|||||||
addSchool: (school: School) => void;
|
addSchool: (school: School) => void;
|
||||||
removeSchool: (urn: number) => void;
|
removeSchool: (urn: number) => void;
|
||||||
replaceSchools: (schools: School[]) => 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;
|
clearAll: () => void;
|
||||||
isSelected: (urn: number) => boolean;
|
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;
|
isInitialized: boolean;
|
||||||
mutate: () => void;
|
mutate: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { getFromLocalStorage, setToLocalStorage } from '@/lib/utils';
|
import { getFromLocalStorage, setToLocalStorage } from '@/lib/utils';
|
||||||
|
import { MAX_PER_GROUP, fitToGroupLimits, fullGroupFor } from '@/lib/compareLogic';
|
||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { ComparisonContext } from './ComparisonContext';
|
import { ComparisonContext } from './ComparisonContext';
|
||||||
|
|
||||||
const STORAGE_KEY = 'selectedSchools';
|
const STORAGE_KEY = 'selectedSchools';
|
||||||
const MAX_SCHOOLS = 5;
|
|
||||||
|
|
||||||
export function ComparisonProvider({ children }: { children: React.ReactNode }) {
|
export function ComparisonProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [selectedSchools, setSelectedSchools] = useState<School[]>([]);
|
const [selectedSchools, setSelectedSchools] = useState<School[]>([]);
|
||||||
@@ -53,8 +53,9 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
|
|||||||
if (prev.some((s) => s.urn === school.urn)) {
|
if (prev.some((s) => s.urn === school.urn)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
if (prev.length >= MAX_SCHOOLS) {
|
const fullGroup = fullGroupFor(prev, school);
|
||||||
alert(`Maximum ${MAX_SCHOOLS} schools can be compared`);
|
if (fullGroup) {
|
||||||
|
alert(`You can compare up to ${MAX_PER_GROUP} ${fullGroup} schools at once. Remove one to add another.`);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
return [...prev, school];
|
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
|
// Replace the whole selection — used when a shared /compare?urns=… link
|
||||||
// must take precedence over whatever this visitor had stored.
|
// must take precedence over whatever this visitor had stored.
|
||||||
const replaceSchools = useCallback((schools: School[]) => {
|
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(() => {
|
const clearAll = useCallback(() => {
|
||||||
@@ -80,6 +97,11 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
|
|||||||
[selectedSchools]
|
[selectedSchools]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fullGroupForSchool = useCallback(
|
||||||
|
(school: Pick<School, 'phase'>) => fullGroupFor(selectedSchools, school),
|
||||||
|
[selectedSchools]
|
||||||
|
);
|
||||||
|
|
||||||
// Placeholder mutate - actual SWR mutate is in useComparison hook
|
// Placeholder mutate - actual SWR mutate is in useComparison hook
|
||||||
const mutate = useCallback(() => {}, []);
|
const mutate = useCallback(() => {}, []);
|
||||||
|
|
||||||
@@ -93,9 +115,10 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
|
|||||||
addSchool,
|
addSchool,
|
||||||
removeSchool,
|
removeSchool,
|
||||||
replaceSchools,
|
replaceSchools,
|
||||||
|
backfillPhases,
|
||||||
clearAll,
|
clearAll,
|
||||||
isSelected,
|
isSelected,
|
||||||
canAddMore: selectedSchools.length < MAX_SCHOOLS,
|
fullGroupFor: fullGroupForSchool,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
mutate,
|
mutate,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -289,3 +289,48 @@ export function latestValues(
|
|||||||
return null;
|
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;
|
distance_miles: number;
|
||||||
school_type: string | null;
|
school_type: string | null;
|
||||||
age_range: string | null;
|
age_range: string | null;
|
||||||
|
/** Optional: a frontend can ship ahead of the API that serves it. */
|
||||||
|
phase?: string | null;
|
||||||
shared: string[];
|
shared: string[];
|
||||||
metric_value: number | null;
|
metric_value: number | null;
|
||||||
metric_key: string;
|
metric_key: string;
|
||||||
|
|||||||
Reference in new issue
Block a user