fix(compare): give every basket entry a real phase
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m13s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Canceled after 1m6s
PR Checks / Build Pipeline (no push) (pull_request) Canceled after 0s
PR Checks / AI Code Review (Claude) (pull_request) Canceled after 0s

Review of the per-phase limit found entries reaching the basket with no
phase, and a phase-less entry holds a slot in both groups:

- Nearby-school cards added without one. The API now returns each
  peer's own phase (its pool is a phase group, so an all-through school
  can sit beside a primary); the button passes it through, and an older
  API simply leaves the conservative both-groups count in place.
- Baskets saved before this change were never migrated. The compare
  page now backfills missing phases from the data it already fetches,
  and never overwrites a phase the basket has.
- "16 plus" counted against both groups; it is secondary, as the API's
  PHASE_GROUPS files it.

Also rewraps the HomeView doc comment the previous commit left awkward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5.5 committed 2026-09-30 12:04:39 +01:00
1 parent 0a4c051ee5
commit 587cfe3f0b
11 files changed
+175 -6

No files matched your search

+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
@@ -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');
});
@@ -328,6 +328,7 @@ describe('basket limits per comparison group', () => {
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', () => {
+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 =
+5 -4
View File
@@ -150,10 +150,11 @@ interface ValueProp {
*
* Every claim here must name something the product actually does. Two of the
* 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.
* 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[] = [
{
@@ -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);
};
+2
View File
@@ -17,6 +17,8 @@ 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;
/** The comparison group with no room for this school, or null. */
+17
View File
@@ -72,6 +72,22 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
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(() => {
setSelectedSchools([]);
}, []);
@@ -99,6 +115,7 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
addSchool,
removeSchool,
replaceSchools,
backfillPhases,
clearAll,
isSelected,
fullGroupFor: fullGroupForSchool,
+2 -1
View File
@@ -309,7 +309,8 @@ export const MAX_PER_GROUP = 5;
export function compareGroups(phase?: string | null): CompareGroup[] {
const p = (phase ?? '').toLowerCase();
// "Middle deemed secondary" / "Middle deemed primary" match here too.
if (p.includes('secondary')) return ['secondary'];
// "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'];
}
+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;