Compare commits

..
Author SHA1 Message Date
TudorandClaude Fable 5 9f8dba227c fix(compare): keep chart data when a client refetch fails
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 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m9s
The compare view refetches /api/compare on the client after SSR; on any
failure the catch nulled comparisonData, destroying the working
SSR-provided chart. A transient error (or staging's broken external /api
proxy) should not blank a comparison the user is already viewing — keep
the existing data instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:08:22 +01:00
tudor 18cd805c6c Merge pull request 'feat(compare): readable comparison chart on mobile' (#11) from feat/compare-chart-mobile-readability 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 49s
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) Failing after 1m6s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #11
2026-07-05 21:26:01 +00:00
TudorandClaude Fable 5 22769b6295 feat(compare): readable comparison chart on mobile
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
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 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m49s
The compare chart squashed clustered schools into a thin band (y pinned
0-100) under an in-chart title + per-school legend that ate ~40% of a
300px card, leaving converging lines indistinguishable on phones.

- Auto-fit the y-axis to the data on all viewports (computeYBounds in
  lib/utils: padded + min-span for percentages, symmetric around 0 for
  progress, fitted for scores; negative pct-named trend metrics are not
  zero-clamped).
- Distinct point style per school (circle/triangle/rect/rectRot/star)
  as secondary encoding for convergence and colour-blindness.
- Mobile: drop in-chart title/legend/axis titles; add a chip row (colour
  dot + name) that doubles as tap-to-focus — highlights one school's
  line and dims the rest. Chart card 300px -> 340px, nearly all plot.
- Fix a latent colour mismatch: datasets were built from Object.entries
  whose integer-like URN keys enumerate in ascending numeric order,
  desyncing line colours from card colours; the chart now receives the
  ordered school list.
- Union years across schools instead of taking the first school's.
- Extract PerformanceChart's matchMedia pattern into hooks/useIsMobile.

Unit tests for metricKind/computeYBounds; e2e journey covers the mobile
chips and focus toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:02:26 +01:00
tudor 90f2a02e75 Merge pull request 'fix(school): make hero map fullscreen work on iOS Safari' (#10) from fix/hero-map-ios-fullscreen 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 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 8s
Reviewed-on: #10
2026-07-05 20:52:32 +00:00
TudorandClaude Fable 5 d52d384cf2 fix(school): make hero map fullscreen work on iOS Safari
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
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 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 4m15s
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:34:22 +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
TudorandClaude Fable 5 acec8135e1 fix(e2e): pick the latest explicit year in the rankings year test
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m39s
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 41s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 9s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m24s
Staging doesn't always carry the full data history, so selecting the
oldest year legitimately returns no rows and fails the promotion gate.
Select the most recent explicit year instead: the default view already
proved it has rows, so an empty table after selecting it can only mean
the year query param was rejected — the regression this test guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:29:43 +01:00
tudor 0a370e3b63 Merge pull request 'fix(api): accept academic-year codes in rankings year filter' (#7) from fix/rankings-year-validation into main
Deploy (staging -> E2E gate -> production) / Build Backend (FastAPI) (push) Successful in 21s
Deploy (staging -> E2E gate -> production) / Build Frontend (Next.js) (push) Successful in 46s
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) Failing after 1m3s
Deploy (staging -> E2E gate -> production) / Promote to Production (push) Has been skipped
Reviewed-on: #7
2026-07-05 10:46:04 +00:00
TudorandClaude Fable 5 6c872ce726 fix(api): accept academic-year codes in rankings year filter
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 20s
PR Checks / Build Frontend (no push) (pull_request) Successful in 52s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m44s
The rankings endpoint validated year with le=2100, but the database
stores academic-year codes like 201819, so any explicit year selection
returned a 422 and the rankings page rendered its empty state. Widen
the bound to cover the codes and extend the e2e journey to pick a
specific year and assert the table stays populated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:13:56 +01:00
16 changed files with 432 additions and 234 deletions
-47
View File
@@ -1,47 +0,0 @@
name: PR Comment Agent
on:
issue_comment:
types: [created]
jobs:
ai-fixup:
name: Claude Fix-up (@claude comment)
runs-on: ubuntu-latest
# Only PR comments, only from the repo owner, only when addressed to @claude.
# The owner guard matters: the job pushes code to the PR branch.
if: >-
gitea.event.issue.pull_request &&
startsWith(gitea.event.comment.body, '@claude') &&
gitea.event.comment.user.login == gitea.repository_owner
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: Apply the requested fix-up
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.issue.number }}
COMMENT_BODY: ${{ gitea.event.comment.body }}
# Claude Code runs as root inside the runner container; this flag
# acknowledges the container *is* the sandbox.
IS_SANDBOX: "1"
run: python scripts/ci/ai_fixup.py
+4 -1
View File
@@ -834,7 +834,10 @@ async def get_rankings(
request: Request, request: Request,
metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50), metric: str = Query("rwm_expected_pct", description="Metric to rank by", max_length=50),
year: Optional[int] = Query( year: Optional[int] = Query(
None, description="Specific year (defaults to most recent)", ge=2000, le=2100 None,
description="Academic year code, e.g. 201819 (defaults to most recent)",
ge=2000,
le=210100,
), ),
limit: int = Query(20, ge=1, le=100, description="Number of schools to return"), limit: int = Query(20, ge=1, le=100, description="Number of schools to return"),
local_authority: Optional[str] = Query( local_authority: Optional[str] = Query(
-18
View File
@@ -130,21 +130,3 @@ token Gitea Actions provides automatically (`secrets.GITEA_TOKEN` — no setup
needed), and fails the check only when a finding is rated needed), and fails the check only when a finding is rated
**severe** (would break prod, leak data, or corrupt data). Minor findings are **severe** (would break prod, leak data, or corrupt data). Minor findings are
informational and never block a merge. informational and never block a merge.
## Comment-triggered fix-ups (@claude)
Comment `@claude <instruction>` on any PR and `.gitea/workflows/pr-comment.yml`
runs `scripts/ci/ai_fixup.py`: it checks out the PR branch, hands the
instruction to headless Claude Code (same subscription auth as the reviewer),
commits and pushes whatever changed, and replies on the PR with a summary.
The push re-runs the PR checks automatically.
Guard rails:
- Only comments from the **repo owner** trigger it (the job pushes code).
- Only comments starting with `@claude` — the bot's own replies never re-trigger.
- Each comment is one full agentic session on the Claude subscription; batch
related asks into one comment rather than several small ones.
Caveat: if the checks don't re-run after the bot's push, Gitea is suppressing
workflows for pushes made with the run token — create a personal access token
secret and swap it in for the push, or re-run the checks manually.
+72
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');
@@ -66,6 +91,32 @@ test('comparing two schools shows both side by side', async ({ page }) => {
await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible(); await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible();
}); });
test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await searchByName(page, 'primary');
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 expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
// The mobile chart legend renders one chip per school inside the chart card.
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
const chips = chipGroup.getByRole('button');
await expect(chips).toHaveCount(2);
// Tapping a chip focuses that school's line; tapping again releases it.
await chips.first().click();
await expect(chips.first()).toHaveAttribute('aria-pressed', 'true');
await chips.first().click();
await expect(chips.first()).toHaveAttribute('aria-pressed', 'false');
});
test('rankings page loads a populated table', async ({ page }) => { test('rankings page loads a populated table', async ({ page }) => {
await page.goto('/rankings'); await page.goto('/rankings');
await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible(); await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible();
@@ -73,3 +124,24 @@ test('rankings page loads a populated table', async ({ page }) => {
await expect(rows.first()).toBeVisible({ timeout: 15_000 }); await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5); expect(await rows.count()).toBeGreaterThan(5);
}); });
test('rankings stay populated after picking a specific year', async ({ page }) => {
// Years are academic-year codes (e.g. 201819); the API must accept them
// as the `year` query param rather than rejecting with a 422.
await page.goto('/rankings');
const yearSelect = page.locator('#year-select');
await expect(yearSelect).toBeVisible({ timeout: 15_000 });
// Pick the last option — the most recent explicit year. The default view
// already proved this year has rows, so an empty table after selecting it
// can only mean the year param was rejected. (The oldest year is no good
// here: staging doesn't always carry the full data history.)
const yearValue = await yearSelect.locator('option').last().getAttribute('value');
expect(yearValue).toBeTruthy();
await yearSelect.selectOption(yearValue!);
await page.waitForURL(/year=/);
const rows = page.locator('table tbody tr');
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
expect(await rows.count()).toBeGreaterThan(5);
});
+53
View File
@@ -9,6 +9,8 @@ import {
isValidPostcode, isValidPostcode,
debounce, debounce,
buildOfstedListBadge, buildOfstedListBadge,
metricKind,
computeYBounds,
} from '@/lib/utils'; } from '@/lib/utils';
describe('formatPercentage', () => { describe('formatPercentage', () => {
@@ -159,3 +161,54 @@ describe('buildOfstedListBadge', () => {
expect(badge.cssClass).toBe('ofstedPending'); expect(badge.cssClass).toBe('ofstedPending');
}); });
}); });
describe('metricKind', () => {
it('classifies metrics by key', () => {
expect(metricKind('rwm_expected_pct')).toBe('percentage');
expect(metricKind('absence_rate')).toBe('percentage');
expect(metricKind('reading_progress')).toBe('progress');
expect(metricKind('progress_8_score')).toBe('progress');
expect(metricKind('attainment_8_score')).toBe('score');
expect(metricKind('reading_avg_score')).toBe('score');
});
});
describe('computeYBounds', () => {
it('tightens clustered percentages instead of framing 0-100', () => {
const b = computeYBounds([86, 86, 86, 80, 96], 'percentage');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.max).toBeLessThanOrEqual(100);
expect(b.min).toBeGreaterThan(50);
expect(b.max! - b.min!).toBeGreaterThanOrEqual(10);
});
it('never widens percentages beyond 0-100 for non-negative data', () => {
const b = computeYBounds([2, 5, 98], 'percentage');
expect(b.min).toBe(0);
expect(b.max).toBe(100);
});
it('does not clamp to zero when pct-named trend data is negative', () => {
const b = computeYBounds([-12, -3, 4], 'percentage');
expect(b.min).toBeLessThan(-12);
});
it('keeps progress bounds symmetric around zero', () => {
const b = computeYBounds([-1.2, 0.4, 2.1], 'progress');
expect(b.min).toBe(-b.max!);
expect(b.min).toBeLessThanOrEqual(-1.2);
expect(b.max).toBeGreaterThanOrEqual(2.1);
});
it('fits score metrics without a fixed frame', () => {
const b = computeYBounds([42.3, 48.9, 51.2], 'score');
expect(b.min).toBeGreaterThanOrEqual(0);
expect(b.min).toBeLessThanOrEqual(42.3);
expect(b.max).toBeGreaterThanOrEqual(51.2);
});
it('returns empty bounds when there is no numeric data', () => {
expect(computeYBounds([null, undefined, NaN], 'percentage')).toEqual({});
expect(computeYBounds([], 'progress')).toEqual({});
});
});
@@ -0,0 +1,63 @@
/* Chart wrapper: chips (mobile) above, canvas filling the rest of the
parent .chartContainer, whose fixed height drives Chart.js sizing via
maintainAspectRatio: false. */
.wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.canvasBox {
position: relative;
flex: 1 1 auto;
min-height: 0;
}
/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps
Chart.js's built-in legend (with per-school point shapes). */
.chips {
display: none;
}
@media (max-width: 640px) {
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding-bottom: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
max-width: 100%;
padding: 4px 10px;
border: 1px solid rgba(0, 0, 0, .12);
border-radius: 999px;
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.chip[aria-pressed="true"] {
background: rgba(0, 0, 0, .06);
border-color: rgba(0, 0, 0, .35);
}
.chipDot {
flex: 0 0 auto;
width: 10px;
height: 10px;
border-radius: 50%;
}
.chipName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 9rem;
}
}
+116 -45
View File
@@ -1,47 +1,82 @@
/** /**
* ComparisonChart Component * ComparisonChart Component
* Multi-school comparison chart using Chart.js * Multi-school comparison chart using Chart.js.
*
* Desktop: built-in legend (point-style markers double as per-school shapes).
* Mobile (≤640px): the in-chart legend and axis titles are dropped in favour
* of a chip row above the canvas; tapping a chip highlights that school's
* line and dims the rest. The y-axis auto-fits the data on all viewports so
* clustered schools stay distinguishable.
*/ */
'use client'; 'use client';
import { useEffect, useState } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { ChartOptions } from 'chart.js'; import { ChartOptions, ChartDataset, PointStyle } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
import type { ComparisonData } from '@/lib/types'; import type { ComparisonData } from '@/lib/types';
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils'; import {
CHART_COLORS,
CHART_TEXT_COLORS,
computeYBounds,
formatAcademicYear,
metricKind,
rgbToRgba,
} from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics';
import styles from './ComparisonChart.module.css';
interface ComparisonChartProps { interface ComparisonChartProps {
comparisonData: Record<string, ComparisonData>; comparisonData: Record<string, ComparisonData>;
/** Ordered as displayed in the school cards, so colours match by index. */
schools: Array<{ urn: number; school_name: string }>;
metric: string; metric: string;
metricLabel: string; metricLabel: string;
} }
export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) { // One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so
// Get all schools and their data // converging lines stay tellable apart without relying on hue alone.
const schools = Object.entries(comparisonData); const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star'];
export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) {
const isMobile = useIsMobile();
const [focusedUrn, setFocusedUrn] = useState<number | null>(null);
// A focused school that leaves the basket must not linger.
const urnKey = schools.map((s) => s.urn).join(',');
useEffect(() => {
setFocusedUrn(null);
}, [urnKey]);
if (schools.length === 0) { if (schools.length === 0) {
return <div>No data available</div>; return <div>No data available</div>;
} }
// Get years from first school (assuming all schools have same years) // Union of years across all schools — coverage differs between them.
const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b); const years = [
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
].sort((a, b) => a - b);
// Create datasets for each school const datasets: ChartDataset<'line'>[] = schools.map((school, index) => {
const datasets = schools.map(([urn, data], index) => { const data = comparisonData[String(school.urn)];
const schoolInfo = data.school_info;
const color = CHART_COLORS[index % CHART_COLORS.length]; const color = CHART_COLORS[index % CHART_COLORS.length];
const dimmed = focusedUrn !== null && focusedUrn !== school.urn;
return { return {
label: schoolInfo.school_name, label: school.school_name,
data: years.map((year) => { data: years.map((year) => {
const yearData = data.yearly_data.find((d) => d.year === year); const yearData = data?.yearly_data.find((d) => d.year === year);
if (!yearData) return null; if (!yearData) return null;
return yearData[metric as keyof typeof yearData] as number | null; return yearData[metric as keyof typeof yearData] as number | null;
}), }),
borderColor: color, borderColor: dimmed ? rgbToRgba(color, 0.2) : color,
backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'), backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1),
borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2,
pointStyle: POINT_STYLES[index % POINT_STYLES.length],
pointRadius: dimmed ? 2 : isMobile ? 3 : 4,
pointHoverRadius: isMobile ? 5 : 6,
tension: 0.3, tension: 0.3,
spanGaps: true, spanGaps: true,
}; };
@@ -52,9 +87,11 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
datasets, datasets,
}; };
// Determine if metric is a progress score or percentage const kind = metricKind(metric);
const isProgressScore = metric.includes('progress'); const yBounds = computeYBounds(
const isPercentage = metric.includes('pct') || metric.includes('rate'); datasets.flatMap((ds) => ds.data as Array<number | null>),
kind,
);
const options: ChartOptions<'line'> = { const options: ChartOptions<'line'> = {
responsive: true, responsive: true,
@@ -65,6 +102,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
}, },
plugins: { plugins: {
legend: { legend: {
display: !isMobile,
position: 'top' as const, position: 'top' as const,
labels: { labels: {
usePointStyle: true, usePointStyle: true,
@@ -74,26 +112,22 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
}, },
}, },
}, },
// No in-chart title: the section heading and metric selector above the
// chart already state the metric.
title: { title: {
display: true, display: false,
text: `${metricLabel} - Comparison`,
font: {
size: 16,
weight: 'bold',
},
padding: {
bottom: 20,
},
}, },
tooltip: { tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)', backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12, padding: isMobile ? 10 : 12,
titleFont: { titleFont: {
size: 14, size: isMobile ? 12 : 14,
}, },
bodyFont: { bodyFont: {
size: 13, size: isMobile ? 11 : 13,
}, },
usePointStyle: true,
itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity),
callbacks: { callbacks: {
label: function (context) { label: function (context) {
let label = context.dataset.label || ''; let label = context.dataset.label || '';
@@ -101,13 +135,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
label += ': '; label += ': ';
} }
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
if (isProgressScore) { label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : '');
label += context.parsed.y.toFixed(1);
} else if (isPercentage) {
label += context.parsed.y.toFixed(1) + '%';
} else {
label += context.parsed.y.toFixed(1);
}
} else { } else {
label += 'N/A'; label += 'N/A';
} }
@@ -121,17 +149,18 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
type: 'linear' as const, type: 'linear' as const,
display: true, display: true,
title: { title: {
display: true, display: !isMobile,
text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value', text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',
}, },
}, },
...(isPercentage && { ...yBounds,
min: 0, ticks: {
max: 100, font: { size: isMobile ? 10 : 12 },
}), ...(isMobile && { maxTicksLimit: 5 }),
},
grid: { grid: {
color: 'rgba(0, 0, 0, 0.05)', color: 'rgba(0, 0, 0, 0.05)',
}, },
@@ -141,16 +170,58 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari
display: false, display: false,
}, },
title: { title: {
display: true, display: !isMobile,
text: 'Year', text: 'Year',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',
}, },
}, },
ticks: {
font: { size: isMobile ? 10 : 12 },
...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }),
},
}, },
}, },
}; };
return <Line data={chartData} options={options} />; const toggleFocus = (urn: number) => {
const next = focusedUrn === urn ? null : urn;
setFocusedUrn(next);
if (next !== null) track('compare_focus_school', { urn: next });
};
return (
<div className={styles.wrapper}>
{/* Mobile legend + focus control; a single series needs no legend. */}
{schools.length > 1 && (
<div className={styles.chips} role="group" aria-label="Highlight a school on the chart">
{schools.map((school, index) => (
<button
key={school.urn}
type="button"
className={styles.chip}
aria-pressed={focusedUrn === school.urn}
onClick={() => toggleFocus(school.urn)}
>
<span
className={styles.chipDot}
style={{ background: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
<span
className={styles.chipName}
style={{ color: CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length] }}
>
{school.school_name}
</span>
</button>
))}
</div>
)}
<div className={styles.canvasBox}>
<Line data={chartData} options={options} aria-label={`${metricLabel} comparison chart`} />
</div>
</div>
);
} }
@@ -454,7 +454,10 @@
} }
.chartContainer { .chartContainer {
height: 300px; /* Taller than desktop's proportion would suggest: the chip legend row
sits inside, and the in-chart title/legend/axis titles are gone, so
nearly all of this is plot area. */
height: 340px;
} }
.comparisonTable { .comparisonTable {
+4 -1
View File
@@ -111,8 +111,10 @@ export function ComparisonView({
setComparisonData(data.comparison); setComparisonData(data.comparison);
}) })
.catch((err) => { .catch((err) => {
// Keep whatever we already have (SSR data or a previous fetch) rather
// than blanking the chart — a transient refetch failure shouldn't
// destroy a working comparison the user is looking at.
console.error('Failed to fetch comparison:', err); console.error('Failed to fetch comparison:', err);
setComparisonData(null);
}); });
} else { } else {
setComparisonData(null); setComparisonData(null);
@@ -429,6 +431,7 @@ export function ComparisonView({
<div className={styles.chartContainer}> <div className={styles.chartContainer}>
<ComparisonChart <ComparisonChart
comparisonData={activeComparisonData} comparisonData={activeComparisonData}
schools={activeSchools}
metric={selectedMetric} metric={selectedMetric}
metricLabel={metricLabel} metricLabel={metricLabel}
/> />
+3 -11
View File
@@ -10,12 +10,13 @@
'use client'; 'use client';
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Line } from 'react-chartjs-2'; import { Line } from 'react-chartjs-2';
import { ChartOptions, ChartDataset } from 'chart.js'; import { ChartOptions, ChartDataset } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
import type { SchoolResult } from '@/lib/types'; import type { SchoolResult } from '@/lib/types';
import { formatAcademicYear } from '@/lib/utils'; import { formatAcademicYear } from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { track } from '@/lib/analytics'; import { track } from '@/lib/analytics';
import styles from './PerformanceChart.module.css'; import styles from './PerformanceChart.module.css';
@@ -68,16 +69,7 @@ export function PerformanceChart({
const sortedData = [...data].sort((a, b) => a.year - b.year); const sortedData = [...data].sort((a, b) => a.year - b.year);
const years = sortedData.map(d => formatAcademicYear(d.year)); const years = sortedData.map(d => formatAcademicYear(d.year));
// ── Mobile detection ───────────────────────────────────────────────── const isMobile = useIsMobile();
// Hydration-safe: SSR renders desktop; client flips to mobile after mount.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 640px)');
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, []);
// ── Build per-year national averages ───────────────────────────────── // ── Build per-year national averages ─────────────────────────────────
const natRefRwm: (number | null)[] = sortedData.map(d => { const natRefRwm: (number | null)[] = sortedData.map(d => {
@@ -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 ? (
+23
View File
@@ -0,0 +1,23 @@
/**
* Viewport hook shared by the chart components.
* Hydration-safe: SSR and the first client render report desktop; the
* media-query subscription flips the value after mount.
*/
'use client';
import { useEffect, useState } from 'react';
export function useIsMobile(maxWidth = 640): boolean {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia(`(max-width: ${maxWidth}px)`);
const update = () => setIsMobile(mq.matches);
update();
mq.addEventListener('change', update);
return () => mq.removeEventListener('change', update);
}, [maxWidth]);
return isMobile;
}
+1
View File
@@ -29,6 +29,7 @@ export type EventName =
| 'compare_viewed' | 'compare_viewed'
| 'compare_metric_changed' | 'compare_metric_changed'
| 'compare_shared' | 'compare_shared'
| 'compare_focus_school'
// Operational // Operational
| 'api_error' | 'api_error'
| 'results_load_more'; | 'results_load_more';
+51
View File
@@ -317,6 +317,57 @@ export function getTrendColor(trend: 'up' | 'down' | 'stable'): string {
} }
} }
/**
* Broad shape of a KS2/KS4 metric, used to scale chart axes and format values.
*/
export type MetricKind = 'percentage' | 'progress' | 'score';
export function metricKind(metric: string): MetricKind {
if (metric.includes('progress')) return 'progress';
if (metric.includes('pct') || metric.includes('rate')) return 'percentage';
return 'score';
}
/**
* Fit a chart y-axis to the data instead of a fixed frame, so clustered
* series remain distinguishable. Padding keeps a minimum span so noise is
* not magnified into drama.
*
* - percentage: pad and snap to 5s; cap at 100; floor at 0 only when the
* data is non-negative (some trend metrics have `pct` in the key but hold
* negative year-over-year deltas).
* - progress: symmetric around 0 so the zero line always shows.
* - score (Attainment 8, scaled scores): pad and snap to integers; floor at
* 0 only when the data is non-negative.
*/
export function computeYBounds(
values: Array<number | null | undefined>,
kind: MetricKind,
): { min?: number; max?: number } {
const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
if (nums.length === 0) return {};
const lo = Math.min(...nums);
const hi = Math.max(...nums);
if (kind === 'progress') {
const reach = Math.max(2, Math.ceil(Math.max(Math.abs(lo), Math.abs(hi)) + 0.5));
return { min: -reach, max: reach };
}
if (kind === 'percentage') {
const pad = Math.max(5, Math.round((hi - lo) * 0.2));
const min = Math.floor((lo - pad) / 5) * 5;
const max = Math.min(100, Math.ceil((hi + pad) / 5) * 5);
return { min: lo >= 0 ? Math.max(0, min) : min, max };
}
// score
const pad = Math.max(2, (hi - lo) * 0.2);
const min = Math.floor(lo - pad);
return { min: lo >= 0 ? Math.max(0, min) : min, max: Math.ceil(hi + pad) };
}
// ============================================================================ // ============================================================================
// Local Storage Utilities // Local Storage Utilities
// ============================================================================ // ============================================================================
-106
View File
@@ -1,106 +0,0 @@
#!/usr/bin/env python3
"""Comment-triggered PR fix-ups, powered by Claude Code.
Runs when a maintainer comments `@claude <instruction>` on a pull request.
Checks out the PR head branch, hands the instruction to headless Claude Code
(subscription OAuth auth — no API billing), commits and pushes whatever it
changed (which re-runs the PR checks), and replies on the PR with a summary.
Uses only the Python standard library plus the `claude` CLI.
Required environment:
CLAUDE_CODE_OAUTH_TOKEN token from `claude setup-token`
GITEA_TOKEN run-scoped token (checkout auth handles the push)
GITEA_SERVER_URL e.g. https://privaterepo.sitaru.org
GITEA_REPOSITORY owner/repo
PR_NUMBER pull request index
COMMENT_BODY the triggering comment text
"""
import json
import os
import subprocess
import sys
import urllib.request
TRIGGER = "@claude"
CLAUDE_TIMEOUT_S = 2400 # 40 min ceiling for one fix-up session
def api(path: str, payload: dict | None = None) -> dict:
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
repo = os.environ["GITEA_REPOSITORY"]
req = urllib.request.Request(
f"{server}/api/v1/repos/{repo}{path}",
data=json.dumps(payload).encode() if payload is not None else None,
headers={
"Authorization": f"token {os.environ['GITEA_TOKEN']}",
"Content-Type": "application/json",
},
method="POST" if payload is not None else "GET",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
def run(*cmd: str, **kwargs) -> subprocess.CompletedProcess:
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kwargs)
def main() -> int:
pr_number = os.environ["PR_NUMBER"]
instruction = os.environ["COMMENT_BODY"].strip()
if instruction.lower().startswith(TRIGGER):
instruction = instruction[len(TRIGGER):].strip()
if not instruction:
print("Empty instruction after trigger word; nothing to do")
return 0
pr = api(f"/pulls/{pr_number}")
head_ref = pr["head"]["ref"]
base_ref = pr["base"]["ref"]
run("git", "fetch", "origin", head_ref, base_ref)
run("git", "checkout", head_ref)
prompt = f"""You are working on pull request #{pr_number} ("{pr['title']}")
in the SchoolCompare repository. The PR branch is checked out; its base is
{base_ref}. A maintainer left this instruction on the PR:
{instruction}
Implement exactly what was asked, following the conventions in CLAUDE.md.
Run any relevant tests or typechecks you can. Do NOT commit or push — the
harness handles that. When done, summarise in a few sentences what you
changed and how you verified it."""
proc = subprocess.run(
["claude", "-p", prompt, "--dangerously-skip-permissions", "--output-format", "json"],
capture_output=True,
text=True,
timeout=CLAUDE_TIMEOUT_S,
)
if proc.returncode != 0:
raise RuntimeError(f"claude CLI failed:\n{proc.stderr[-2000:]}")
summary = json.loads(proc.stdout)["result"].strip()
changed = run("git", "status", "--porcelain").stdout.strip()
if changed:
run("git", "config", "user.name", "Claude (CI)")
run("git", "config", "user.email", "noreply@anthropic.com")
run("git", "add", "-A")
title = instruction.splitlines()[0][:60]
run("git", "commit", "-m", f"ai: {title}\n\nRequested via PR comment; applied by Claude Code in CI.")
run("git", "push", "origin", head_ref)
sha = run("git", "rev-parse", "--short", "HEAD").stdout.strip()
reply = f"## 🤖 Claude fix-up applied (`{sha}`)\n\n{summary}\n\n_PR checks re-run automatically on the new commit._"
else:
reply = f"## 🤖 Claude fix-up — no changes made\n\n{summary}"
api(f"/issues/{pr_number}/comments", {"body": reply})
print(reply)
return 0
if __name__ == "__main__":
sys.exit(main())