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
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:
1 parent
0a4c051ee5
commit
587cfe3f0b
11 files changed
+175
-6
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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('Middle deemed primary')).toEqual(['primary']);
|
||||||
expect(compareGroups('Secondary')).toEqual(['secondary']);
|
expect(compareGroups('Secondary')).toEqual(['secondary']);
|
||||||
expect(compareGroups('Middle deemed 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', () => {
|
it('counts schools that could land in either tab against both', () => {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -150,10 +150,11 @@ interface ValueProp {
|
|||||||
*
|
*
|
||||||
* 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 the basket limit
|
* 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
|
* 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[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ 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;
|
||||||
/** The comparison group with no room for this school, or null. */
|
/** The comparison group with no room for this school, or null. */
|
||||||
|
|||||||
@@ -72,6 +72,22 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
|
|||||||
setSelectedSchools(fitToGroupLimits(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(() => {
|
||||||
setSelectedSchools([]);
|
setSelectedSchools([]);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -99,6 +115,7 @@ export function ComparisonProvider({ children }: { children: React.ReactNode })
|
|||||||
addSchool,
|
addSchool,
|
||||||
removeSchool,
|
removeSchool,
|
||||||
replaceSchools,
|
replaceSchools,
|
||||||
|
backfillPhases,
|
||||||
clearAll,
|
clearAll,
|
||||||
isSelected,
|
isSelected,
|
||||||
fullGroupFor: fullGroupForSchool,
|
fullGroupFor: fullGroupForSchool,
|
||||||
|
|||||||
@@ -309,7 +309,8 @@ export const MAX_PER_GROUP = 5;
|
|||||||
export function compareGroups(phase?: string | null): CompareGroup[] {
|
export function compareGroups(phase?: string | null): CompareGroup[] {
|
||||||
const p = (phase ?? '').toLowerCase();
|
const p = (phase ?? '').toLowerCase();
|
||||||
// "Middle deemed secondary" / "Middle deemed primary" match here too.
|
// "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'];
|
if (p.includes('primary')) return ['primary'];
|
||||||
return ['primary', 'secondary'];
|
return ['primary', 'secondary'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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