Compare commits

..
Author SHA1 Message Date
tudor 1cdcd85e41 Merge pull request 'fix(search): stop the mobile sort dropdown overflowing the viewport' (#17) from fix/mobile-sort-select-overflow into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 51s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 36s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #17
2026-07-06 12:46:48 +00:00
TudorandClaude Fable 5 a00cbe9161 fix(search): stop the mobile sort dropdown overflowing the viewport
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m37s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m3s
On a location search the results header shows the view toggle and the
sort <select> side by side. The select sizes to its widest option
('Highest Reading, Writing & Maths %', ~273px), so on a phone its right
edge ran ~46px past the viewport and was clipped off-screen.

On mobile let the select flex into the remaining space with min-width:0
so its label truncates instead of overflowing, and keep the view toggle
from shrinking. Verified live at 390px: the select now sits fully within
the viewport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:23:29 +01:00
tudor 64121592fd Merge pull request 'feat(compare): lay mobile chart chips two per row' (#16) from feat/compare-chips-two-per-row into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 50s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 38s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #16
2026-07-06 12:16:26 +00:00
tudor 331ae8d89f Merge pull request 'fix(e2e): compare-chips test must compare schools in one phase' (#15) from fix/e2e-compare-chips-phase into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 12s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 53s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Successful in 38s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 9s
Reviewed-on: #15
2026-07-06 11:01:22 +00:00
TudorandClaude Fable 5 3adea73ee0 fix(e2e): compare-chips test must use schools in one phase
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 37s
The test picked the first two /school/ links from a 'primary' search and
asserted exactly two mobile chips. But a 'primary' search can return
all-through schools (e.g. 'Hessle High School and Penshurst Primary')
that classify as secondary, so the two picks can split across phases —
the active phase then holds one school and the chips are correctly gated
out (they need ≥2 in the active phase), while the canvas still shows one
line. That's a test artefact, not a bug.

Pick three schools instead: across two phases the auto-selected majority
phase always holds ≥2, so the chip legend is guaranteed. Assert ≥2 chips
(the majority may be 2 or 3). Verified against staging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:42:11 +01:00
tudor 47335fcda0 Merge pull request 'fix(frontend): proxy /api and /sitemap.xml at runtime, not via baked rewrites' (#14) from fix/runtime-api-proxy into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 48s
Deploy (staging -> E2E gate -> production) / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Deploy (staging -> E2E gate -> production) / Deploy to Staging (push) Successful in 1s
Deploy (staging -> E2E gate -> production) / E2E Journeys against Staging (push) Failing after 52s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #14
2026-07-06 10:11:39 +00:00
TudorandClaude Fable 5 95a5783da1 fix(frontend): proxy /api and /sitemap.xml at runtime, not via baked rewrites
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m38s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 50s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 3m13s
next.config.js rewrites() bakes its destination into the build
(routes-manifest.json), capturing FASTAPI_URL at build time. Because one
frontend image is promoted staging->prod, the baked backend host forced
every environment to name the backend service identically; staging names
it 'backend_stg', so the browser's /api/* calls proxied to the baked
'http://backend' and failed with getaddrinfo ENOTFOUND backend. (SSR was
unaffected because lib/api.ts reads FASTAPI_URL at runtime.)

Replace the rewrites with route handlers that read FASTAPI_URL per
request:
- app/api/[...path]/route.ts — transparent proxy for all methods, streams
  the response, strips hop-by-hop headers, and returns 502 on upstream
  failure instead of crashing.
- app/sitemap.xml/route.ts — proxies the backend sitemap (robots.ts points
  crawlers here).

The same promoted image now adapts to whatever the backend is called in
each environment. Verified: production build succeeds with /api/[...path]
and /sitemap.xml as dynamic routes and an empty rewrites manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:00:42 +01:00
6 changed files with 133 additions and 20 deletions
+9 -4
View File
@@ -100,15 +100,20 @@ test('compare chart on mobile shows school chips with tap-to-focus', async ({ pa
links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '') links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '')
); );
const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))]; const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))];
expect(urns.length).toBeGreaterThanOrEqual(2); // Compare three schools, not two: a "primary" search can return all-through
// schools that classify as secondary, and the chips only appear for the
// active phase. With three schools across two phases, the auto-selected
// majority phase always holds ≥2, so the chip legend is guaranteed to render.
expect(urns.length).toBeGreaterThanOrEqual(3);
await page.goto(`/compare?urns=${urns[0]},${urns[1]}`); await page.goto(`/compare?urns=${urns[0]},${urns[1]},${urns[2]}`);
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
// The mobile chart legend renders one chip per school inside the chart card. // The mobile chart legend renders one chip per school in the active phase.
const chipGroup = page.getByRole('group', { name: /highlight a school/i }); const chipGroup = page.getByRole('group', { name: /highlight a school/i });
const chips = chipGroup.getByRole('button'); const chips = chipGroup.getByRole('button');
await expect(chips).toHaveCount(2); await expect(chips.first()).toBeVisible({ timeout: 15_000 });
expect(await chips.count()).toBeGreaterThanOrEqual(2);
// Tapping a chip focuses that school's line; tapping again releases it. // Tapping a chip focuses that school's line; tapping again releases it.
await chips.first().click(); await chips.first().click();
+3 -1
View File
@@ -22,7 +22,9 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production ENV NODE_ENV=production
# Build argument for FastAPI URL (used by Next.js rewrites at build time) # Default backend URL for any server-side fetch during `next build`. The
# runtime /api proxy reads FASTAPI_URL per request (see app/api/[...path]),
# so the deployed container's env is what actually routes traffic.
ARG FASTAPI_URL=http://backend:80/api ARG FASTAPI_URL=http://backend:80/api
ENV FASTAPI_URL=${FASTAPI_URL} ENV FASTAPI_URL=${FASTAPI_URL}
+75
View File
@@ -0,0 +1,75 @@
/**
* Runtime proxy for /api/* → the FastAPI backend.
*
* This replaces the old next.config.js `rewrites()` proxy, whose destination
* was baked into the build (routes-manifest.json) from FASTAPI_URL at build
* time. Because one frontend image is promoted staging→prod, a baked hostname
* forced every environment to name the backend identically; a mismatch (e.g.
* a `backend_stg` service) produced `getaddrinfo ENOTFOUND backend`.
*
* A route handler reads process.env.FASTAPI_URL on each request, so the same
* image adapts to whatever the backend is called in each environment.
*/
import { type NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
// FASTAPI_URL already includes the `/api` suffix (e.g. http://backend:80/api).
function backendBase(): string {
return process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
}
// Hop-by-hop / length headers must not be copied across a proxy — undici has
// already decoded the body, so a stale content-encoding/length corrupts it.
const STRIPPED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'];
const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
const target = `${backendBase()}/${path.join('/')}${req.nextUrl.search}`;
const headers = new Headers(req.headers);
headers.delete('host');
headers.delete('connection');
const init: RequestInit & { duplex?: 'half' } = {
method: req.method,
headers,
redirect: 'manual',
cache: 'no-store',
};
if (METHODS_WITH_BODY.has(req.method)) {
init.body = req.body;
init.duplex = 'half';
}
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch (err) {
// e.g. DNS failure or connection refused — surface a clean 502 instead of
// an opaque proxy crash so callers can degrade gracefully.
return NextResponse.json({ detail: 'Upstream request failed' }, { status: 502 });
}
const responseHeaders = new Headers(upstream.headers);
for (const h of STRIPPED_RESPONSE_HEADERS) responseHeaders.delete(h);
return new NextResponse(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: responseHeaders,
});
}
export {
handler as GET,
handler as HEAD,
handler as POST,
handler as PUT,
handler as PATCH,
handler as DELETE,
handler as OPTIONS,
};
+32
View File
@@ -0,0 +1,32 @@
/**
* Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap.
*
* Like the /api/* proxy, this reads FASTAPI_URL at request time rather than
* baking the backend host into the build, so one image works in every
* environment. robots.ts points crawlers here.
*/
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
function backendOrigin(): string {
const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
return base.replace(/\/api$/, '');
}
export async function GET() {
let upstream: Response;
try {
upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' });
} catch {
return new NextResponse('Sitemap temporarily unavailable', { status: 502 });
}
const body = await upstream.text();
return new NextResponse(body, {
status: upstream.status,
headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' },
});
}
+10
View File
@@ -369,6 +369,16 @@
.viewToggle { .viewToggle {
justify-content: center; justify-content: center;
flex-shrink: 0;
}
/* The sort <select> sizes to its widest option ("Highest Reading, Writing
& Maths %"), which overflows a phone viewport — beside the view toggle it
ran off the right edge. Let it flex into the remaining space and shrink;
the selected label truncates instead of pushing past the screen. */
.sortSelect {
flex: 1;
min-width: 0;
} }
.mapViewContainer { .mapViewContainer {
+4 -15
View File
@@ -3,21 +3,10 @@ const nextConfig = {
// Enable standalone output for Docker // Enable standalone output for Docker
output: 'standalone', output: 'standalone',
// API Proxy to FastAPI backend // The /api/* and /sitemap.xml proxies to the FastAPI backend are route
async rewrites() { // handlers (app/api/[...path]/route.ts, app/sitemap.xml/route.ts) rather
const apiUrl = process.env.FASTAPI_URL || 'http://localhost:8000/api'; // than rewrites, so the backend host is read from FASTAPI_URL at runtime
const backendUrl = apiUrl.replace(/\/api$/, ''); // instead of being baked into the build.
return [
{
source: '/api/:path*',
destination: `${apiUrl}/:path*`,
},
{
source: '/sitemap.xml',
destination: `${backendUrl}/sitemap.xml`,
},
];
},
// Image optimization // Image optimization
images: { images: {