Files
school_compare/nextjs-app/context/ComparisonProvider.tsx
T
TudorandClaude Opus 5.5 587cfe3f0b
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
fix(compare): give every basket entry a real phase
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>
2026-09-30 12:04:39 +01:00

130 lines
3.8 KiB
TypeScript

/**
* ComparisonProvider
* Provides shared comparison state to all components
*/
'use client';
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';
export function ComparisonProvider({ children }: { children: React.ReactNode }) {
const [selectedSchools, setSelectedSchools] = useState<School[]>([]);
const [isInitialized, setIsInitialized] = useState(false);
// Load from localStorage on mount
useEffect(() => {
const stored = getFromLocalStorage<School[]>(STORAGE_KEY, []);
setSelectedSchools(stored);
setIsInitialized(true);
}, []);
// Save to localStorage when schools change
useEffect(() => {
if (isInitialized) {
setToLocalStorage(STORAGE_KEY, selectedSchools);
}
}, [selectedSchools, isInitialized]);
// Listen for storage changes from other tabs
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === STORAGE_KEY && e.newValue) {
try {
const parsed = JSON.parse(e.newValue);
setSelectedSchools(parsed);
} catch {
// Ignore parse errors
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
const addSchool = useCallback((school: School) => {
setSelectedSchools((prev) => {
if (prev.some((s) => s.urn === school.urn)) {
return prev;
}
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];
});
}, []);
const removeSchool = useCallback((urn: number) => {
setSelectedSchools((prev) => prev.filter((s) => s.urn !== urn));
}, []);
// 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(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([]);
}, []);
const isSelected = useCallback(
(urn: number) => selectedSchools.some((s) => s.urn === urn),
[selectedSchools]
);
const fullGroupForSchool = useCallback(
(school: Pick<School, 'phase'>) => fullGroupFor(selectedSchools, school),
[selectedSchools]
);
// Placeholder mutate - actual SWR mutate is in useComparison hook
const mutate = useCallback(() => {}, []);
return (
<ComparisonContext.Provider
value={{
selectedSchools,
comparisonData: null,
isLoading: false,
error: null,
addSchool,
removeSchool,
replaceSchools,
backfillPhases,
clearAll,
isSelected,
fullGroupFor: fullGroupForSchool,
isInitialized,
mutate,
}}
>
{children}
</ComparisonContext.Provider>
);
}