Compare commits

...
Author SHA1 Message Date
TudorandClaude Fable 5 06e4898c30 test(e2e): pick two same-phase schools for the compare journey
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 47s
The compare page's phase tabs put all-through schools (which carry KS4
data) on the secondary tab, so comparing an all-through school with a
pure primary splits them across tabs and only the active tab renders its
link. The test picked the first two 'primary' search hits without
guaranteeing same phase, so it flaked whenever a search returned an
all-through school first (e.g. URN 137306). Now selects two pure-Primary
URNs via the API — deterministic and data-invariant.

Verified against staging: was a 15.6s timeout, now passes in ~1.8s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 23:12:23 +01:00
tudor abc03a0dd3 Merge pull request 'fix(compare): blank page on refresh + remove dead per-page comparison fetch' (#37) from fix/compare-refresh-and-fetch into main
Stage (build -> staging -> E2E gate) / Build Backend (FastAPI) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Build Frontend (Next.js) (push) Successful in 53s
Stage (build -> staging -> E2E gate) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Stage (build -> staging -> E2E gate) / Deploy to Staging (push) Successful in 1s
Stage (build -> staging -> E2E gate) / E2E Journeys against Staging (push) Failing after 1m9s
Reviewed-on: #37
2026-07-14 21:39:46 +00:00
TudorandClaude Fable 5 43a2c4a6bc fix(compare): show SSR data on refresh; drop dead per-page comparison fetch
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m42s
PR Checks / Backend Smoke (pull_request) Successful in 7s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 49s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 9s
Refresh bug: on mount the basket is empty for a beat before it hydrates
from the URL. The fetch effect nulled comparisonData on that transient
empty urnKey, then the one-shot 'SSR covers it' skip suppressed the
refetch — leaving the page blank on reload. The effect is now gated on
isInitialized, never blanks on empty (the render already shows the empty
state when nothing is selected), and decides fetch-vs-skip by whether it
already holds each requested school's data (SSR or a prior fetch).

Perf: useComparison ran a useSWR('/api/compare') whose result nothing
consumed — dead weight that fired on every page (Navigation + Toast are
global) whenever the basket was non-empty, and duplicated ComparisonView's
own fetch on the compare page. Removed; the hook now exposes basket state
only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0146VHeLAWjDVE2B5uU67jCB
2026-07-14 22:34:20 +01:00
4 changed files with 137 additions and 71 deletions
+26 -11
View File
@@ -19,6 +19,27 @@ function schoolLinks(page: Page) {
return page.locator('a[href^="/school/"]'); return page.locator('a[href^="/school/"]');
} }
/**
* Two URNs guaranteed to be pure-primary (same phase). The compare page's
* phase tabs split all-through schools (which carry KS4 data) onto the
* secondary tab, so picking two arbitrary "primary" search hits can land
* them on different tabs where only the active one renders. Selecting via
* the API by exact phase keeps both on the same tab. Data-invariant: uses
* whatever primaries the environment holds.
*/
async function twoPrimaryUrns(page: Page): Promise<[string, string]> {
const res = await page.request.get('/api/schools?search=primary&per_page=50');
expect(res.ok()).toBeTruthy();
const body = await res.json();
const urns: string[] = (body.schools ?? [])
.filter((s: { phase?: string; rwm_expected_pct?: number | null }) =>
s.phase === 'Primary' && s.rwm_expected_pct != null,
)
.map((s: { urn: number }) => String(s.urn));
expect(urns.length).toBeGreaterThanOrEqual(2);
return [urns[0], urns[1]];
}
test('home page loads with hero search', async ({ page }) => { test('home page loads with hero search', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await expect(page.locator('h1').first()).toBeVisible(); await expect(page.locator('h1').first()).toBeVisible();
@@ -139,19 +160,13 @@ test('results map fullscreen falls back to an overlay on iOS', async ({ page })
}); });
test('comparing two schools shows the parent-first sections side by side', async ({ page }) => { test('comparing two schools shows the parent-first sections side by side', async ({ page }) => {
// Collect two school URNs from search results, then load the share URL // Two same-phase (pure primary) schools so both stay on one tab.
await searchByName(page, 'primary'); const [urn0, urn1] = await twoPrimaryUrns(page);
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
const hrefs = await schoolLinks(page).evaluateAll((links) =>
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
);
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
expect(urns.length).toBeGreaterThanOrEqual(2);
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`); await page.goto(`/compare?urns=${urn0},${urn1}`);
// Both schools' detail links should render in the comparison view // Both schools' detail links should render in the comparison view
await expect(page.locator(`a[href*="${urns[0]}"]`).first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator(`a[href*="${urn0}"]`).first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible(); await expect(page.locator(`a[href*="${urn1}"]`).first()).toBeVisible();
// The parent-first sections render in order (data-invariant: headings only) // The parent-first sections render in order (data-invariant: headings only)
for (const heading of [ for (const heading of [
@@ -0,0 +1,82 @@
/**
* Regression: on refresh, the compare page must show the SSR-rendered data.
*
* The basket hydrates from the URL a beat after mount (selectedSchools is
* empty for the first render), so the fetch effect must not blank the
* SSR payload during that window — and must not refetch data the server
* already provided.
*/
import { render, screen, waitFor } from '@testing-library/react';
import { ComparisonView } from '@/components/ComparisonView';
import { ComparisonProvider } from '@/context/ComparisonProvider';
import type { ComparisonData, School } from '@/lib/types';
const fetchComparison = jest.fn();
jest.mock('@/lib/api', () => ({
fetchComparison: (...args: unknown[]) => fetchComparison(...args),
}));
jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
function school(urn: number, name: string): School {
return {
urn,
school_name: name,
local_authority: 'Testshire',
school_type: 'Community school',
rwm_expected_pct: 80,
phase: 'Primary',
} as School;
}
function data(urn: number, name: string): ComparisonData {
return {
school_info: school(urn, name),
yearly_data: [{ year: 202425, rwm_expected_pct: 80 }] as ComparisonData['yearly_data'],
ofsted: null,
census: null,
admissions: null,
admissions_history: [],
deprivation: null,
};
}
const INITIAL_DATA = {
'100': data(100, 'Alpha Primary'),
'200': data(200, 'Beta Primary'),
};
beforeEach(() => {
fetchComparison.mockReset();
});
test('renders SSR data on refresh without wiping it or refetching', async () => {
render(
<ComparisonProvider>
<ComparisonView
initialData={INITIAL_DATA}
initialNationalAverages={{
year: 202425,
primary: { rwm_expected_pct: 62 },
secondary: {},
by_year: [],
}}
initialBenchmarks={undefined}
initialUrns={[100, 200]}
metrics={[]}
selectedMetric="rwm_expected_pct"
/>
</ComparisonProvider>,
);
// Both SSR-provided schools appear (data was not blanked during hydration)
await waitFor(() => {
expect(screen.getAllByText('Alpha Primary').length).toBeGreaterThan(0);
});
expect(screen.getAllByText('Beta Primary').length).toBeGreaterThan(0);
expect(screen.getByRole('heading', { name: 'At a glance' })).toBeInTheDocument();
// …and the client never refetched data the server already rendered.
expect(fetchComparison).not.toHaveBeenCalled();
});
+20 -19
View File
@@ -107,24 +107,26 @@ export function ComparisonView({
router.replace(newUrl, { scroll: false }); router.replace(newUrl, { scroll: false });
}, [urnKey, selectedMetric, pathname, searchParams, router]); }, [urnKey, selectedMetric, pathname, searchParams, router]);
// Fetch only when the school set changes. The very first run is skipped // Fetch when the school set changes, but only for schools we don't already
// when the SSR payload already covers the current set — no double-fetch // have data for. This skips the refetch of SSR-rendered data on load AND
// of data the server just rendered. // avoids a network call when a school is merely removed. A ref holds the
const firstFetchRef = useRef(true); // latest data so the effect can read it without re-running on every fetch.
useEffect(() => { //
if (!urnKey) { // Correctness note: we must NOT null the data on a transient empty urnKey.
setComparisonData(null); // On mount the basket is empty for a beat before it hydrates from the URL,
setNationalAverages(undefined); // and blanking here (then skipping the refetch because SSR "covers" the set)
setBenchmarks(undefined); // was leaving the page empty on refresh. The render already shows the empty
return; // state whenever `selectedSchools` is empty, so stale data for deselected
} // schools is harmless — it's simply unused.
const comparisonDataRef = useRef(comparisonData);
comparisonDataRef.current = comparisonData;
if (firstFetchRef.current) { useEffect(() => {
firstFetchRef.current = false; if (!isInitialized || !urnKey) return;
const ssrUrns = new Set(Object.keys(initialData ?? {}));
const covered = urnKey.split(',').every((urn) => ssrUrns.has(urn)); const have = comparisonDataRef.current ?? {};
if (covered && ssrUrns.size > 0) return; const covered = urnKey.split(',').every((urn) => have[urn] != null);
} if (covered) return;
fetchComparison(urnKey, { cache: 'no-store' }) fetchComparison(urnKey, { cache: 'no-store' })
.then((data) => { .then((data) => {
@@ -138,8 +140,7 @@ export function ComparisonView({
// destroy a working comparison the user is looking at. // destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err); console.error('Failed to fetch comparison:', err);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps }, [urnKey, isInitialized]);
}, [urnKey]);
// Classify schools by phase using comparison data // Classify schools by phase using comparison data
const classifySchool = (school: School): 'primary' | 'secondary' => { const classifySchool = (school: School): 'primary' | 'secondary' => {
+9 -41
View File
@@ -1,50 +1,18 @@
/** /**
* Custom hook for managing school comparison state * Custom hook for managing school comparison state.
* Uses shared context for real-time updates across components *
* This hook is mounted on every page via the global Navigation and
* ComparisonToast, so it must stay cheap — it exposes basket state only.
* The compare page fetches `/api/compare` itself (ComparisonView); nothing
* ever read the comparison payload from here, so the previous per-page SWR
* fetch (which fired on every page whenever the basket was non-empty) was
* dead weight and has been removed.
*/ */
'use client'; 'use client';
import useSWR from 'swr';
import { fetcher } from '@/lib/api';
import { useComparisonContext } from '@/context/ComparisonContext'; import { useComparisonContext } from '@/context/ComparisonContext';
import type { ComparisonResponse } from '@/lib/types';
export function useComparison() { export function useComparison() {
const { return useComparisonContext();
selectedSchools,
addSchool,
removeSchool,
replaceSchools,
clearAll,
isSelected,
canAddMore,
isInitialized,
} = useComparisonContext();
// Fetch comparison data for selected schools
const urns = selectedSchools.map((s) => s.urn).join(',');
const { data, error, isLoading, mutate } = useSWR<ComparisonResponse>(
selectedSchools.length > 0 ? `/compare?urns=${urns}` : null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 10000,
}
);
return {
selectedSchools,
comparisonData: data?.comparison,
isLoading,
error,
addSchool,
removeSchool,
replaceSchools,
clearAll,
isSelected,
canAddMore,
isInitialized,
mutate,
};
} }