Compare commits

..
Author SHA1 Message Date
TudorandClaude Fable 5 cc5b6955d8 fix(school): make hero map fullscreen work on iOS Safari
iOS Safari has no Element.requestFullscreen (fullscreen is video-only),
so tapping the map band or 'View on map' silently did nothing on
iPhones. Fall back to a fixed-position CSS overlay driven by state when
the Fullscreen API is missing or its promise rejects, locking body
scroll while open. The Leaflet map already re-measures via the shared
isFullscreen flag. New e2e journey simulates the iOS condition by
deleting the API and asserts the overlay opens and closes; it fails
against the current production build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:27:41 +01:00
TudorandClaude Fable 5 35752a7d53 chore(ci): diagnostic v2 — capture headers to attribute the 500s
Staging Rankings Diagnostic / Staging rankings year diagnostic (push) Successful in 1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:49:15 +01:00
TudorandClaude Fable 5 e48469b058 chore(ci): push-triggered staging diagnostic workflow (temporary)
Staging Rankings Diagnostic / Staging rankings year diagnostic (push) Successful in 1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:47:00 +01:00
TudorandClaude Fable 5 660d84502a chore(ci): temporary staging rankings year diagnostic (do not merge)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:24:03 +01:00
tudor ff606dad71 Merge pull request 'fix(e2e): pick the latest explicit year in the rankings year test' (#8) from fix/e2e-rankings-year-pick 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 52s
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 34s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Successful in 10s
Reviewed-on: #8
2026-07-05 13:54:55 +00:00
5 changed files with 110 additions and 5 deletions
+28
View File
@@ -0,0 +1,28 @@
# TEMPORARY diagnostic workflow — delete after the rankings year= bug is closed.
name: Staging Rankings Diagnostic
on:
push:
branches:
- diag/staging-rankings-year
jobs:
staging-api-diagnostic:
name: Staging rankings year diagnostic
runs-on: ubuntu-latest
steps:
- name: Probe staging rankings year handling
env:
BASE: ${{ secrets.STAGING_BASE_URL }}
run: |
probe() {
echo "== $1 =="
curl -s --max-time 15 -D /tmp/h.txt -o /tmp/b.txt "$BASE$1"
echo "--- headers:"; cat /tmp/h.txt
echo "--- body (first 400 bytes):"; head -c 400 /tmp/b.txt; echo
}
probe "/api/data-info"
probe "/api/filters"
probe "/api/rankings?metric=rwm_expected_pct&limit=3"
probe "/api/rankings?metric=rwm_expected_pct&limit=3&year=202425"
probe "/"
+19 -1
View File
@@ -12,7 +12,25 @@ env:
PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline PIPELINE_IMAGE_NAME: ${{ gitea.repository }}-pipeline
jobs: jobs:
frontend-checks: # TEMPORARY: evidence gathering for the rankings year= empty-list bug on
# staging. Remove before merging. Prints status codes and row counts only.
staging-api-diagnostic:
name: Staging rankings year diagnostic
runs-on: ubuntu-latest
steps:
- name: Probe staging rankings year handling
env:
BASE: ${{ secrets.STAGING_BASE_URL }}
run: |
echo "== /api/filters years =="
curl -s --max-time 15 "$BASE/api/filters" -o /tmp/f.json -w "status %{http_code}\n"
python3 -c "import json; print(json.load(open('/tmp/f.json')).get('years'))" || head -c 300 /tmp/f.json
echo "== /api/rankings probes (metric=rwm_expected_pct, limit=3) =="
for Q in "" "&year=202425" "&year=201819" "&year=2024"; do
CODE=$(curl -s --max-time 15 -o /tmp/r.json -w "%{http_code}" "$BASE/api/rankings?metric=rwm_expected_pct&limit=3$Q")
echo "query [$Q] -> status $CODE"
python3 -c "import json; d=json.load(open('/tmp/r.json')); print(' year:', d.get('year'), 'total:', d.get('total'), 'rows:', len(d.get('rankings', [])))" || head -c 300 /tmp/r.json
done
name: Frontend Typecheck + Tests name: Frontend Typecheck + Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+25
View File
@@ -50,6 +50,31 @@ test('school detail page renders name and performance data', async ({ page }) =>
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
}); });
test('school hero map opens fullscreen on mobile without the Fullscreen API', async ({ page }) => {
// iOS Safari has no Element.requestFullscreen; the map must fall back to a
// CSS overlay. Simulate that by removing the API before any page script runs.
await page.setViewportSize({ width: 390, height: 844 });
await page.addInitScript(() => {
// @ts-expect-error deliberate API removal
delete Element.prototype.requestFullscreen;
});
await searchByName(page, 'primary');
const firstSchool = schoolLinks(page).first();
await expect(firstSchool).toBeVisible({ timeout: 15_000 });
await firstSchool.click();
await page.waitForURL(/\/school\//);
const openMap = page.getByRole('button', { name: 'Open full map' });
await expect(openMap).toBeVisible({ timeout: 15_000 });
await openMap.click();
const closeMap = page.getByRole('button', { name: 'Close map' });
await expect(closeMap).toBeVisible();
await closeMap.click();
await expect(openMap).toBeVisible();
});
test('comparing two schools shows both side by side', async ({ page }) => { test('comparing two schools shows both side by side', async ({ page }) => {
// Collect two school URNs from search results, then load the share URL // Collect two school URNs from search results, then load the share URL
await searchByName(page, 'primary'); await searchByName(page, 'primary');
@@ -34,6 +34,15 @@
background: #fff; background: #fff;
} }
/* Fallback fullscreen (iOS Safari — no Element.requestFullscreen): the API
can't promote the element, so pin it over the page ourselves. Above the
comparison toast (3000) and everything else except modals (9999+). */
.wrapper[data-fs-fallback] {
position: fixed;
inset: 0;
z-index: 5000;
}
.skeleton { .skeleton {
width: 100%; width: 100%;
height: 100%; height: 100%;
+29 -4
View File
@@ -29,25 +29,50 @@ interface SchoolHeroMapProps {
export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>( export const SchoolHeroMap = forwardRef<SchoolHeroMapHandle, SchoolHeroMapProps>(
function SchoolHeroMap({ lat, lng }, ref) { function SchoolHeroMap({ lat, lng }, ref) {
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [nativeFullscreen, setNativeFullscreen] = useState(false);
// iOS Safari has no Element.requestFullscreen — fall back to a
// fixed-position overlay driven by state instead of the Fullscreen API.
const [fallbackFullscreen, setFallbackFullscreen] = useState(false);
const isFullscreen = nativeFullscreen || fallbackFullscreen;
const open = useCallback(() => { const open = useCallback(() => {
wrapperRef.current?.requestFullscreen?.().catch(() => {}); const el = wrapperRef.current;
if (!el) return;
if (el.requestFullscreen) {
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
} else {
setFallbackFullscreen(true);
}
}, []); }, []);
const close = useCallback(() => { const close = useCallback(() => {
if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
setFallbackFullscreen(false);
}, []); }, []);
useImperativeHandle(ref, () => ({ open }), [open]); useImperativeHandle(ref, () => ({ open }), [open]);
useEffect(() => { useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement); const onChange = () => setNativeFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange); document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange); return () => document.removeEventListener('fullscreenchange', onChange);
}, []); }, []);
// The fallback overlay sits on top of the page rather than replacing it,
// so lock body scroll while it is up.
useEffect(() => {
if (!fallbackFullscreen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [fallbackFullscreen]);
return ( return (
<div ref={wrapperRef} className={styles.wrapper} data-fullscreen={isFullscreen || undefined}> <div
ref={wrapperRef}
className={styles.wrapper}
data-fullscreen={isFullscreen || undefined}
data-fs-fallback={fallbackFullscreen || undefined}
>
<LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} /> <LeafletHeroMap lat={lat} lng={lng} interactive={isFullscreen} />
{isFullscreen ? ( {isFullscreen ? (