Compare commits

..
Author SHA1 Message Date
TudorandClaude Fable 5 d0100cce69 feat(ci): comment-triggered PR fix-ups via @claude
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m41s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 16s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 2m30s
Commenting '@claude <instruction>' on a PR runs headless Claude Code on the
PR branch (subscription auth), pushes the resulting commit — re-running the
PR checks — and replies with a summary. Owner-only trigger; runs unsandboxed
inside the ephemeral runner container per explicit maintainer sign-off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqGhF93UrpDNvXBLMjJENL
2026-07-03 16:26:09 +01:00
37 changed files with 841 additions and 474 deletions
+47
View File
@@ -0,0 +1,47 @@
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
+3 -5
View File
@@ -572,7 +572,7 @@ async def get_school_details(request: Request, urn: int):
# Get latest info for the school # Get latest info for the school
latest = school_data.iloc[-1] latest = school_data.iloc[-1]
# Fetch supplementary data (Ofsted, admissions, etc.) # Fetch supplementary data (Ofsted, Parent View, admissions, etc.)
from .database import SessionLocal from .database import SessionLocal
supplementary = {} supplementary = {}
try: try:
@@ -605,6 +605,7 @@ async def get_school_details(request: Request, urn: int):
"yearly_data": clean_for_json(school_data), "yearly_data": clean_for_json(school_data),
# Supplementary data (null if not yet populated by Kestra) # Supplementary data (null if not yet populated by Kestra)
"ofsted": supplementary.get("ofsted"), "ofsted": supplementary.get("ofsted"),
"parent_view": supplementary.get("parent_view"),
"census": supplementary.get("census"), "census": supplementary.get("census"),
"admissions": supplementary.get("admissions"), "admissions": supplementary.get("admissions"),
"admissions_history": supplementary.get("admissions_history") or [], "admissions_history": supplementary.get("admissions_history") or [],
@@ -833,10 +834,7 @@ 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, None, description="Specific year (defaults to most recent)", ge=2000, le=2100
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(
+25 -1
View File
@@ -14,7 +14,7 @@ from .config import settings
from .database import SessionLocal, engine from .database import SessionLocal, engine
from .models import ( from .models import (
DimSchool, DimLocation, KS2Performance, DimSchool, DimLocation, KS2Performance,
FactOfstedInspection, FactAdmissions, FactOfstedInspection, FactParentView, FactAdmissions,
FactDeprivation, FactFinance, FactPupilCharacteristics, FactDeprivation, FactFinance, FactPupilCharacteristics,
) )
from .schemas import SCHOOL_TYPE_MAP from .schemas import SCHOOL_TYPE_MAP
@@ -446,6 +446,30 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
else None else None
) )
# Parent View
pv = safe_query(FactParentView, "urn")
result["parent_view"] = (
{
"survey_date": pv.survey_date.isoformat() if pv.survey_date else None,
"total_responses": pv.total_responses,
"q_happy_pct": pv.q_happy_pct,
"q_safe_pct": pv.q_safe_pct,
"q_behaviour_pct": pv.q_behaviour_pct,
"q_bullying_pct": pv.q_bullying_pct,
"q_communication_pct": pv.q_communication_pct,
"q_progress_pct": pv.q_progress_pct,
"q_teaching_pct": pv.q_teaching_pct,
"q_information_pct": pv.q_information_pct,
"q_curriculum_pct": pv.q_curriculum_pct,
"q_future_pct": pv.q_future_pct,
"q_leadership_pct": pv.q_leadership_pct,
"q_wellbeing_pct": pv.q_wellbeing_pct,
"q_recommend_pct": pv.q_recommend_pct,
}
if pv
else None
)
# Census (latest year of fact_pupil_characteristics) # Census (latest year of fact_pupil_characteristics)
pc = safe_query(FactPupilCharacteristics, "urn", "year") pc = safe_query(FactPupilCharacteristics, "urn", "year")
result["census"] = ( result["census"] = (
-22
View File
@@ -433,25 +433,6 @@ def _apply_schema_alterations():
conn.commit() conn.commit()
def _apply_schema_drops():
"""
Drop tables retired from the schema. Idempotent (DROP … IF EXISTS), so it's
safe to run on every migration. Add entries here when a model is removed.
"""
drops = [
# v6: Ofsted Parent View feature removed
"DROP TABLE IF EXISTS marts.fact_parent_view CASCADE",
]
from sqlalchemy import text as sa_text
with engine.connect() as conn:
for stmt in drops:
try:
conn.execute(sa_text(stmt))
except Exception as e:
print(f" Warning: drop skipped ({e})")
conn.commit()
def run_full_migration(geocode: bool = False) -> bool: def run_full_migration(geocode: bool = False) -> bool:
""" """
Run a complete migration: drop all tables and reimport from CSV. Run a complete migration: drop all tables and reimport from CSV.
@@ -498,9 +479,6 @@ def run_full_migration(geocode: bool = False) -> bool:
print("Applying column additions to supplementary tables...") print("Applying column additions to supplementary tables...")
_apply_schema_alterations() _apply_schema_alterations()
print("Dropping retired tables...")
_apply_schema_drops()
print("\nLoading CSV data...") print("\nLoading CSV data...")
df = load_csv_data(settings.data_dir) df = load_csv_data(settings.data_dir)
+23
View File
@@ -149,6 +149,29 @@ class FactOfstedInspection(Base):
report_url = Column(Text) report_url = Column(Text)
class FactParentView(Base):
"""Ofsted Parent View survey — latest per school."""
__tablename__ = "fact_parent_view"
__table_args__ = MARTS
urn = Column(Integer, primary_key=True)
survey_date = Column(Date)
total_responses = Column(Integer)
q_happy_pct = Column(Float)
q_safe_pct = Column(Float)
q_behaviour_pct = Column(Float)
q_bullying_pct = Column(Float)
q_communication_pct = Column(Float)
q_progress_pct = Column(Float)
q_teaching_pct = Column(Float)
q_information_pct = Column(Float)
q_curriculum_pct = Column(Float)
q_future_pct = Column(Float)
q_leadership_pct = Column(Float)
q_wellbeing_pct = Column(Float)
q_recommend_pct = Column(Float)
class FactAdmissions(Base): class FactAdmissions(Base):
"""School admissions — one row per URN per year.""" """School admissions — one row per URN per year."""
__tablename__ = "fact_admissions" __tablename__ = "fact_admissions"
+1 -2
View File
@@ -13,7 +13,7 @@ WHEN TO BUMP:
""" """
# Current schema version - increment when models change # Current schema version - increment when models change
SCHEMA_VERSION = 6 SCHEMA_VERSION = 5
# Changelog for documentation # Changelog for documentation
SCHEMA_CHANGELOG = { SCHEMA_CHANGELOG = {
@@ -22,5 +22,4 @@ SCHEMA_CHANGELOG = {
3: "Added supplementary data tables: ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance; GIAS columns on schools", 3: "Added supplementary data tables: ofsted, parent_view, census, admissions, sen_detail, phonics, deprivation, finance; GIAS columns on schools",
4: "Added Ofsted Report Card columns to ofsted_inspections (new framework from Nov 2025)", 4: "Added Ofsted Report Card columns to ofsted_inspections (new framework from Nov 2025)",
5: "Apply ALTER TABLE additions for RC columns missed by create_all on existing tables", 5: "Apply ALTER TABLE additions for RC columns missed by create_all on existing tables",
6: "Removed the Ofsted Parent View feature: dropped fact_parent_view table and model",
} }
+20 -1
View File
@@ -79,7 +79,8 @@ fail the E2E gate. That's the point: staging absorbs the risk.
5. **Bootstrap staging data via Airflow** (no prod dump — staging populates 5. **Bootstrap staging data via Airflow** (no prod dump — staging populates
itself from source, exercising the pipeline image end-to-end): itself from source, exercising the pipeline image end-to-end):
- Open the staging Airflow UI (`http://<host>:8081`) and trigger, in order: - Open the staging Airflow UI (`http://<host>:8081`) and trigger, in order:
`school_data_daily`, `school_data_monthly_ofsted`, then the manual-schedule `school_data_daily`, `school_data_monthly_ofsted`,
`school_data_monthly_parent_view`, then the manual-schedule
`school_data_annual_ees` and `school_data_annual_idaci`. `school_data_annual_ees` and `school_data_annual_idaci`.
- First runs download from government sources (GIAS, Ofsted, EES, IDACI), - First runs download from government sources (GIAS, Ofsted, EES, IDACI),
run dbt, and sync Typesense — expect the initial backfill to take a while. run dbt, and sync Typesense — expect the initial backfill to take a while.
@@ -129,3 +130,21 @@ 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.
-77
View File
@@ -50,31 +50,6 @@ 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');
@@ -91,37 +66,6 @@ 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))];
// 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]},${urns[2]}`);
await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 });
// The mobile chart legend renders one chip per school in the active phase.
const chipGroup = page.getByRole('group', { name: /highlight a school/i });
const chips = chipGroup.getByRole('button');
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.
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();
@@ -129,24 +73,3 @@ 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,8 +9,6 @@ import {
isValidPostcode, isValidPostcode,
debounce, debounce,
buildOfstedListBadge, buildOfstedListBadge,
metricKind,
computeYBounds,
} from '@/lib/utils'; } from '@/lib/utils';
describe('formatPercentage', () => { describe('formatPercentage', () => {
@@ -161,54 +159,3 @@ 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({});
});
});
+3 -1
View File
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
notFound(); notFound();
} }
const { school_info, yearly_data, absence_data, ofsted, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data; const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
// Redirect bare URN to canonical slug URL // Redirect bare URN to canonical slug URL
const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', ''); const canonicalSlug = schoolUrl(urn, school_info.school_name).replace('/school/', '');
@@ -189,6 +189,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
yearlyData={yearly_data} yearlyData={yearly_data}
absenceData={absence_data} absenceData={absence_data}
ofsted={ofsted ?? null} ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null} census={census ?? null}
admissions={admissions ?? null} admissions={admissions ?? null}
senDetail={sen_detail ?? null} senDetail={sen_detail ?? null}
@@ -202,6 +203,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
yearlyData={yearly_data} yearlyData={yearly_data}
absenceData={absence_data} absenceData={absence_data}
ofsted={ofsted ?? null} ofsted={ofsted ?? null}
parentView={parent_view ?? null}
census={census ?? null} census={census ?? null}
admissions={admissions ?? null} admissions={admissions ?? null}
admissionsHistory={admissions_history ?? []} admissionsHistory={admissions_history ?? []}
@@ -1,63 +0,0 @@
/* 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;
}
}
+45 -116
View File
@@ -1,82 +1,47 @@
/** /**
* 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, ChartDataset, PointStyle } from 'chart.js'; import { ChartOptions } from 'chart.js';
import '@/lib/chartSetup'; import '@/lib/chartSetup';
import type { ComparisonData } from '@/lib/types'; import type { ComparisonData } from '@/lib/types';
import { import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
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;
} }
// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) {
// converging lines stay tellable apart without relying on hue alone. // Get all schools and their data
const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star']; const schools = Object.entries(comparisonData);
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>;
} }
// Union of years across all schools — coverage differs between them. // Get years from first school (assuming all schools have same years)
const years = [ const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b);
...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])),
].sort((a, b) => a - b);
const datasets: ChartDataset<'line'>[] = schools.map((school, index) => { // Create datasets for each school
const data = comparisonData[String(school.urn)]; const datasets = schools.map(([urn, data], index) => {
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: school.school_name, label: schoolInfo.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: dimmed ? rgbToRgba(color, 0.2) : color, borderColor: color,
backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1), backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 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,
}; };
@@ -87,11 +52,9 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
datasets, datasets,
}; };
const kind = metricKind(metric); // Determine if metric is a progress score or percentage
const yBounds = computeYBounds( const isProgressScore = metric.includes('progress');
datasets.flatMap((ds) => ds.data as Array<number | null>), const isPercentage = metric.includes('pct') || metric.includes('rate');
kind,
);
const options: ChartOptions<'line'> = { const options: ChartOptions<'line'> = {
responsive: true, responsive: true,
@@ -102,7 +65,6 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
}, },
plugins: { plugins: {
legend: { legend: {
display: !isMobile,
position: 'top' as const, position: 'top' as const,
labels: { labels: {
usePointStyle: true, usePointStyle: true,
@@ -112,22 +74,26 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
}, },
}, },
}, },
// No in-chart title: the section heading and metric selector above the
// chart already state the metric.
title: { title: {
display: false, display: true,
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: isMobile ? 10 : 12, padding: 12,
titleFont: { titleFont: {
size: isMobile ? 12 : 14, size: 14,
}, },
bodyFont: { bodyFont: {
size: isMobile ? 11 : 13, size: 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 || '';
@@ -135,7 +101,13 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
label += ': '; label += ': ';
} }
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : ''); if (isProgressScore) {
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';
} }
@@ -149,18 +121,17 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
type: 'linear' as const, type: 'linear' as const,
display: true, display: true,
title: { title: {
display: !isMobile, display: true,
text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value', text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value',
font: { font: {
size: 12, size: 12,
weight: 'bold', weight: 'bold',
}, },
}, },
...yBounds, ...(isPercentage && {
ticks: { min: 0,
font: { size: isMobile ? 10 : 12 }, max: 100,
...(isMobile && { maxTicksLimit: 5 }), }),
},
grid: { grid: {
color: 'rgba(0, 0, 0, 0.05)', color: 'rgba(0, 0, 0, 0.05)',
}, },
@@ -170,58 +141,16 @@ export function ComparisonChart({ comparisonData, schools, metric, metricLabel }
display: false, display: false,
}, },
title: { title: {
display: !isMobile, display: true,
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 }),
},
}, },
}, },
}; };
const toggleFocus = (urn: number) => { return <Line data={chartData} options={options} />;
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,10 +454,7 @@
} }
.chartContainer { .chartContainer {
/* Taller than desktop's proportion would suggest: the chip legend row height: 300px;
sits inside, and the in-chart title/legend/axis titles are gone, so
nearly all of this is plot area. */
height: 340px;
} }
.comparisonTable { .comparisonTable {
+1 -4
View File
@@ -111,10 +111,8 @@ 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);
@@ -431,7 +429,6 @@ 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}
/> />
+11 -3
View File
@@ -10,13 +10,12 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useEffect, 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';
@@ -69,7 +68,16 @@ 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));
const isMobile = useIsMobile(); // ── Mobile detection ─────────────────────────────────────────────────
// 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 => {
@@ -713,6 +713,18 @@
margin: -0.5rem 0 1rem; margin: -0.5rem 0 1rem;
} }
/* Response count badge */
.responseBadge {
font-size: 0.75rem;
font-weight: 500;
font-family: var(--font-dm-sans), sans-serif;
color: var(--text-muted, #8a847a);
background: var(--bg-secondary, #f3ede4);
padding: 0.1rem 0.5rem;
border-radius: 999px;
margin-left: auto;
}
.subSectionTitle { .subSectionTitle {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
@@ -720,6 +732,18 @@
margin: 1.25rem 0 0.75rem; margin: 1.25rem 0 0.75rem;
} }
/* Parent recommendation line in Ofsted section */
.parentRecommendLine {
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
margin: 0.5rem 0 0;
}
.parentRecommendLine strong {
color: var(--accent-teal, #2d7d7d);
font-weight: 700;
}
/* Metrics Grid & Cards */ /* Metrics Grid & Cards */
.metricsGrid { .metricsGrid {
display: grid; display: grid;
@@ -1070,6 +1094,49 @@
text-decoration: underline; text-decoration: underline;
} }
/* Parent View */
.parentViewGrid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.parentViewRow {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.parentViewLabel {
flex: 0 0 18rem;
color: var(--text-secondary, #5c564d);
font-size: 0.8125rem;
}
.parentViewBar {
flex: 1;
height: 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 4px;
overflow: hidden;
}
.parentViewFill {
height: 100%;
background: var(--accent-teal, #2d7d7d);
border-radius: 4px;
transition: width 0.4s ease;
}
.parentViewPct {
flex: 0 0 2.75rem;
text-align: right;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
}
/* Admissions badge — uses unified status colours */ /* Admissions badge — uses unified status colours */
.admissionsBadge { .admissionsBadge {
display: inline-flex; display: inline-flex;
@@ -1202,6 +1269,25 @@
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.parentViewRow {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.parentViewLabel {
flex: none;
max-width: 100%;
}
.parentViewBar {
width: 100%;
}
.parentViewPct {
flex: none;
}
.card { .card {
padding: 1rem; padding: 1rem;
} }
+51 -2
View File
@@ -13,7 +13,7 @@ import { SchoolHeroMap, type SchoolHeroMapHandle } from './SchoolHeroMap';
import { MetricTooltip } from './MetricTooltip'; import { MetricTooltip } from './MetricTooltip';
import type { import type {
School, SchoolResult, AbsenceData, School, SchoolResult, AbsenceData,
OfstedInspection, SchoolCensus, OfstedInspection, OfstedParentView, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics, SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
@@ -63,6 +63,7 @@ interface SchoolDetailViewProps {
yearlyData: SchoolResult[]; yearlyData: SchoolResult[];
absenceData: AbsenceData | null; absenceData: AbsenceData | null;
ofsted: OfstedInspection | null; ofsted: OfstedInspection | null;
parentView: OfstedParentView | null;
census: SchoolCensus | null; census: SchoolCensus | null;
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
admissionsHistory: SchoolAdmissions[]; admissionsHistory: SchoolAdmissions[];
@@ -74,7 +75,7 @@ interface SchoolDetailViewProps {
export function SchoolDetailView({ export function SchoolDetailView({
schoolInfo, yearlyData, absenceData, schoolInfo, yearlyData, absenceData,
ofsted, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance, ofsted, parentView, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
}: SchoolDetailViewProps) { }: SchoolDetailViewProps) {
const router = useRouter(); const router = useRouter();
const { addSchool, removeSchool, isSelected } = useComparison(); const { addSchool, removeSchool, isSelected } = useComparison();
@@ -233,6 +234,8 @@ export function SchoolDetailView({
if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' }); if (hasInclusionData) navItems.push({ id: 'inclusion', label: 'Pupils' });
if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' }); if (yearlyData.length > 0) navItems.push({ id: 'history', label: 'History' });
if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' }); if (hasPhonics && isPrimary) navItems.push({ id: 'phonics', label: 'Phonics' });
if (parentView && parentView.total_responses != null && parentView.total_responses > 0)
navItems.push({ id: 'parents', label: 'Parents' });
if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' }); if (hasSchoolLife) navItems.push({ id: 'school-life', label: 'School Life' });
if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' }); if (hasDeprivation) navItems.push({ id: 'local-area', label: 'Local Area' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
@@ -546,6 +549,11 @@ export function SchoolDetailView({
) : null; ) : null;
})} })}
</div> </div>
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
</p>
)}
</> </>
) : ( ) : (
/* ── Old OEIF layout ── */ /* ── Old OEIF layout ── */
@@ -564,6 +572,11 @@ export function SchoolDetailView({
<p className={styles.ofstedDisclaimer}> <p className={styles.ofstedDisclaimer}>
From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools. From September 2024, Ofsted no longer makes an overall effectiveness judgement in inspections of state-funded schools.
</p> </p>
{parentView?.q_recommend_pct != null && parentView.total_responses != null && parentView.total_responses > 0 && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView.q_recommend_pct)}%</strong> of parents would recommend this school ({parentView.total_responses.toLocaleString()} responses)
</p>
)}
{oeifAllSameGrade ? ( {oeifAllSameGrade ? (
<p className={styles.ofstedAllSame}> <p className={styles.ofstedAllSame}>
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership. Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas Quality of Teaching, Behaviour, Pupils&apos; Development and Leadership.
@@ -1116,6 +1129,42 @@ export function SchoolDetailView({
</section> </section>
)} )}
{/* What Parents Say */}
{parentView && parentView.total_responses != null && parentView.total_responses > 0 && (
<section id="parents" className={styles.card}>
<h2 className={styles.sectionTitle}>
What Parents Say
<span className={styles.responseBadge}>
{parentView.total_responses.toLocaleString()} responses
</span>
</h2>
<p className={styles.sectionSubtitle}>
From the Ofsted Parent View survey parents share their experience of this school.
</p>
<div className={styles.parentViewGrid}>
{[
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
].filter(q => q.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.parentViewRow}>
<span className={styles.parentViewLabel}>{label}</span>
<div className={styles.parentViewBar}>
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
</div>
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
</div>
))}
</div>
</section>
)}
{/* School Life */} {/* School Life */}
{hasSchoolLife && ( {hasSchoolLife && (
<section id="school-life" className={styles.card}> <section id="school-life" className={styles.card}>
@@ -34,15 +34,6 @@
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%;
+4 -29
View File
@@ -29,50 +29,25 @@ 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 [nativeFullscreen, setNativeFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = 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(() => {
const el = wrapperRef.current; wrapperRef.current?.requestFullscreen?.().catch(() => {});
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 = () => setNativeFullscreen(!!document.fullscreenElement); const onChange = () => setIsFullscreen(!!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 <div ref={wrapperRef} className={styles.wrapper} data-fullscreen={isFullscreen || undefined}>
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 ? (
@@ -383,6 +383,17 @@
margin: 1.25rem 0 0.75rem; margin: 1.25rem 0 0.75rem;
} }
.responseBadge {
font-size: 0.75rem;
font-weight: 500;
font-family: var(--font-dm-sans), sans-serif;
color: var(--text-muted, #8a847a);
background: var(--bg-secondary, #f3ede4);
padding: 0.1rem 0.5rem;
border-radius: 999px;
margin-left: auto;
}
/* ── Progress 8 suspension banner ───────────────────── */ /* ── Progress 8 suspension banner ───────────────────── */
.p8Banner { .p8Banner {
background: rgba(180, 120, 0, 0.1); background: rgba(180, 120, 0, 0.1);
@@ -653,6 +664,60 @@
text-decoration: underline; text-decoration: underline;
} }
/* ── Parent View ─────────────────────────────────────── */
.parentRecommendLine {
font-size: 0.85rem;
color: var(--text-secondary, #5c564d);
margin: 0.5rem 0 0;
}
.parentRecommendLine strong {
color: var(--accent-teal, #2d7d7d);
font-weight: 700;
}
.parentViewGrid {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.parentViewRow {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
}
.parentViewLabel {
flex: 0 0 18rem;
color: var(--text-secondary, #5c564d);
font-size: 0.8125rem;
}
.parentViewBar {
flex: 1;
height: 0.5rem;
background: var(--bg-secondary, #f3ede4);
border-radius: 4px;
overflow: hidden;
}
.parentViewFill {
height: 100%;
background: var(--accent-teal, #2d7d7d);
border-radius: 4px;
transition: width 0.4s ease;
}
.parentViewPct {
flex: 0 0 2.75rem;
text-align: right;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1a1612);
}
/* ── Admissions ──────────────────────────────────────── */ /* ── Admissions ──────────────────────────────────────── */
.admissionsTypeBadge { .admissionsTypeBadge {
border-radius: 6px; border-radius: 6px;
@@ -1070,6 +1135,10 @@
font-size: 1rem; font-size: 1rem;
} }
.parentViewLabel {
flex-basis: 10rem;
}
.ofstedReportLink { .ofstedReportLink {
margin-left: 0; margin-left: 0;
display: block; display: block;
@@ -1082,6 +1151,25 @@
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.parentViewRow {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.parentViewLabel {
flex: none;
max-width: 100%;
}
.parentViewBar {
width: 100%;
}
.parentViewPct {
flex: none;
}
.metricsGrid { .metricsGrid {
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 0.5rem; gap: 0.5rem;
@@ -19,7 +19,7 @@ const PerformanceChart = dynamic(
); );
import type { import type {
School, SchoolResult, AbsenceData, School, SchoolResult, AbsenceData,
OfstedInspection, SchoolCensus, OfstedInspection, OfstedParentView, SchoolCensus,
SchoolAdmissions, SenDetail, Phonics, SchoolAdmissions, SenDetail, Phonics,
SchoolDeprivation, SchoolFinance, NationalAverages, SchoolDeprivation, SchoolFinance, NationalAverages,
} from '@/lib/types'; } from '@/lib/types';
@@ -65,6 +65,7 @@ interface SecondarySchoolDetailViewProps {
yearlyData: SchoolResult[]; yearlyData: SchoolResult[];
absenceData: AbsenceData | null; absenceData: AbsenceData | null;
ofsted: OfstedInspection | null; ofsted: OfstedInspection | null;
parentView: OfstedParentView | null;
census: SchoolCensus | null; census: SchoolCensus | null;
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
senDetail: SenDetail | null; senDetail: SenDetail | null;
@@ -75,7 +76,7 @@ interface SecondarySchoolDetailViewProps {
export function SecondarySchoolDetailView({ export function SecondarySchoolDetailView({
schoolInfo, yearlyData, schoolInfo, yearlyData,
ofsted, census, admissions, senDetail, deprivation, finance, absenceData, ofsted, parentView, census, admissions, senDetail, deprivation, finance, absenceData,
}: SecondarySchoolDetailViewProps) { }: SecondarySchoolDetailViewProps) {
const router = useRouter(); const router = useRouter();
// Hero map — the "View on map" link opens its fullscreen view. // Hero map — the "View on map" link opens its fullscreen view.
@@ -100,6 +101,7 @@ export function SecondarySchoolDetailView({
const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false; const hasSixthForm = schoolInfo.age_range?.includes('18') ?? false;
const hasFinance = finance != null && finance.per_pupil_spend != null; const hasFinance = finance != null && finance.per_pupil_spend != null;
const hasParents = parentView != null && parentView.total_responses != null && parentView.total_responses > 0;
const hasDeprivation = deprivation != null && deprivation.idaci_decile != null; const hasDeprivation = deprivation != null && deprivation.idaci_decile != null;
const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null; const hasLocation = schoolInfo.latitude != null && schoolInfo.longitude != null;
const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation; const hasWellbeing = (latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) || hasDeprivation;
@@ -157,6 +159,7 @@ export function SecondarySchoolDetailView({
if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' }); if (hasResults) navItems.push({ id: 'gcse', label: 'GCSEs' });
if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' }); if (admissions) navItems.push({ id: 'admissions', label: 'Admissions' });
if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' }); if (yearlyData.length > 1) navItems.push({ id: 'history', label: 'History' });
if (hasParents) navItems.push({ id: 'parents', label: 'Parents' });
if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' }); if (hasWellbeing) navItems.push({ id: 'wellbeing', label: 'Wellbeing' });
if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' }); if (hasFinance) navItems.push({ id: 'finances', label: 'Finances' });
@@ -432,6 +435,11 @@ export function SecondarySchoolDetailView({
</div> </div>
</> </>
)} )}
{hasParents && (
<p className={styles.parentRecommendLine}>
<strong>{Math.round(parentView!.q_recommend_pct!)}%</strong> of parents would recommend this school ({parentView!.total_responses!.toLocaleString()} responses)
</p>
)}
</section> </section>
)} )}
@@ -767,6 +775,42 @@ export function SecondarySchoolDetailView({
</details> </details>
</section> </section>
)} )}
{/* ── Parent View ────────────────────────────────── */}
{hasParents && parentView && (
<section id="parents" className={styles.card}>
<h2 className={styles.sectionTitle}>
What Parents Say
<span className={styles.responseBadge}>
{parentView.total_responses!.toLocaleString()} responses
</span>
</h2>
<p className={styles.sectionSubtitle}>
From the Ofsted Parent View survey parents share their experience of this school.
</p>
<div className={styles.parentViewGrid}>
{[
{ label: 'Would recommend this school', pct: parentView.q_recommend_pct },
{ label: 'My child is happy here', pct: parentView.q_happy_pct },
{ label: 'My child feels safe here', pct: parentView.q_safe_pct },
{ label: 'Teaching is good', pct: parentView.q_teaching_pct },
{ label: 'My child makes good progress', pct: parentView.q_progress_pct },
{ label: 'School looks after pupils\' wellbeing', pct: parentView.q_wellbeing_pct },
{ label: 'Behaviour is well managed', pct: parentView.q_behaviour_pct },
{ label: 'School deals well with bullying', pct: parentView.q_bullying_pct },
{ label: 'Communicates well with parents', pct: parentView.q_communication_pct },
].filter(q => q.pct != null).map(({ label, pct }) => (
<div key={label} className={styles.parentViewRow}>
<span className={styles.parentViewLabel}>{label}</span>
<div className={styles.parentViewBar}>
<div className={styles.parentViewFill} style={{ width: `${pct}%` }} />
</div>
<span className={styles.parentViewPct}>{Math.round(pct!)}%</span>
</div>
))}
</div>
</section>
)}
{/* ── Wellbeing ──────────────────────────────────── */} {/* ── Wellbeing ──────────────────────────────────── */}
{hasWellbeing && ( {hasWellbeing && (
<section id="wellbeing" className={styles.card}> <section id="wellbeing" className={styles.card}>
-23
View File
@@ -1,23 +0,0 @@
/**
* 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,7 +29,6 @@ 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';
+20
View File
@@ -99,6 +99,25 @@ export interface OfstedInspection {
rc_sixth_form: number | null; rc_sixth_form: number | null;
} }
export interface OfstedParentView {
survey_date: string | null;
total_responses: number | null;
q_happy_pct: number | null;
q_safe_pct: number | null;
q_behaviour_pct: number | null;
q_bullying_pct: number | null;
q_communication_pct: number | null;
q_progress_pct: number | null;
q_teaching_pct: number | null;
q_information_pct: number | null;
q_curriculum_pct: number | null;
q_future_pct: number | null;
q_leadership_pct: number | null;
q_wellbeing_pct: number | null;
q_recommend_pct: number | null;
q_sen_pct: number | null;
}
export interface SchoolCensus { export interface SchoolCensus {
year: number; year: number;
total_pupils: number | null; total_pupils: number | null;
@@ -293,6 +312,7 @@ export interface SchoolDetailsResponse {
absence_data: AbsenceData | null; absence_data: AbsenceData | null;
// Supplementary data (null until Kestra populates) // Supplementary data (null until Kestra populates)
ofsted: OfstedInspection | null; ofsted: OfstedInspection | null;
parent_view: OfstedParentView | null;
census: SchoolCensus | null; census: SchoolCensus | null;
admissions: SchoolAdmissions | null; admissions: SchoolAdmissions | null;
/** All available admissions years, oldest first. Drives the multi-year trend view. */ /** All available admissions years, oldest first. Drives the multi-year trend view. */
-51
View File
@@ -317,57 +317,6 @@ 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
// ============================================================================ // ============================================================================
+1
View File
@@ -19,6 +19,7 @@ RUN pip install --no-cache-dir \
./plugins/extractors/tap-uk-gias \ ./plugins/extractors/tap-uk-gias \
./plugins/extractors/tap-uk-ees \ ./plugins/extractors/tap-uk-ees \
./plugins/extractors/tap-uk-ofsted \ ./plugins/extractors/tap-uk-ofsted \
./plugins/extractors/tap-uk-parent-view \
./plugins/extractors/tap-uk-fbit \ ./plugins/extractors/tap-uk-fbit \
./plugins/extractors/tap-uk-idaci ./plugins/extractors/tap-uk-idaci
+25
View File
@@ -156,6 +156,31 @@ with DAG(
extract_ees_group >> dbt_build_ees >> sync_typesense_ees extract_ees_group >> dbt_build_ees >> sync_typesense_ees
# ── Monthly DAG (Parent View) ──────────────────────────────────────────
with DAG(
dag_id="school_data_monthly_parent_view",
default_args=default_args,
description="Monthly Ofsted Parent View extraction and transform",
schedule="0 3 1 * *",
start_date=datetime(2025, 1, 1),
catchup=False,
tags=["school-compare", "monthly"],
) as monthly_parent_view_dag:
extract_parent_view = BashOperator(
task_id="extract_parent_view",
bash_command=f"cd {PIPELINE_DIR} && {MELTANO_BIN} run tap-uk-parent-view target-postgres",
)
dbt_build_parent_view = BashOperator(
task_id="dbt_build",
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_parent_view+ fact_parent_view+",
)
extract_parent_view >> dbt_build_parent_view
# ── Annual DAG (IDACI Deprivation) ──────────────────────────────────── # ── Annual DAG (IDACI Deprivation) ────────────────────────────────────
with DAG( with DAG(
+5
View File
@@ -50,6 +50,11 @@ plugins:
kind: string kind: string
description: Ofsted Management Information download URL description: Ofsted Management Information download URL
- name: tap-uk-parent-view
namespace: uk_parent_view
pip_url: ./plugins/extractors/tap-uk-parent-view
executable: tap-uk-parent-view
- name: tap-uk-fbit - name: tap-uk-fbit
namespace: uk_fbit namespace: uk_fbit
pip_url: ./plugins/extractors/tap-uk-fbit pip_url: ./plugins/extractors/tap-uk-fbit
@@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "tap-uk-parent-view"
version = "0.1.0"
description = "Singer tap for UK Ofsted Parent View survey data"
requires-python = ">=3.10"
dependencies = [
"singer-sdk~=0.53",
"requests>=2.31",
"pandas>=2.0",
"openpyxl>=3.1",
]
[project.scripts]
tap-uk-parent-view = "tap_uk_parent_view.tap:TapUKParentView.cli"
@@ -0,0 +1 @@
"""tap-uk-parent-view: Singer tap for Ofsted Parent View survey data."""
@@ -0,0 +1,151 @@
"""Parent View Singer tap — extracts survey data from Ofsted Parent View open data portal."""
from __future__ import annotations
import io
import re
from datetime import date
import pandas as pd
import requests
from singer_sdk import Stream, Tap
from singer_sdk import typing as th
OPEN_DATA_PAGE = "https://parentview.ofsted.gov.uk/open-data"
def _positive_pct(row: pd.Series, q_col_base: str) -> float | None:
"""Sum 'Strongly agree' + 'Agree' percentages for a question."""
strongly = row.get(f"{q_col_base} - Strongly agree %") or row.get(f"{q_col_base} - Strongly Agree %")
agree = row.get(f"{q_col_base} - Agree %")
try:
total = 0.0
if pd.notna(strongly):
total += float(strongly)
if pd.notna(agree):
total += float(agree)
return round(total, 1) if total > 0 else None
except (TypeError, ValueError):
return None
class ParentViewStream(Stream):
"""Stream: Parent View survey responses per school."""
name = "parent_view"
primary_keys = ["urn"]
replication_key = None
schema = th.PropertiesList(
th.Property("urn", th.IntegerType, required=True),
th.Property("survey_date", th.StringType),
th.Property("total_responses", th.IntegerType),
th.Property("q_happy_pct", th.NumberType),
th.Property("q_safe_pct", th.NumberType),
th.Property("q_behaviour_pct", th.NumberType),
th.Property("q_bullying_pct", th.NumberType),
th.Property("q_communication_pct", th.NumberType),
th.Property("q_progress_pct", th.NumberType),
th.Property("q_teaching_pct", th.NumberType),
th.Property("q_information_pct", th.NumberType),
th.Property("q_curriculum_pct", th.NumberType),
th.Property("q_future_pct", th.NumberType),
th.Property("q_leadership_pct", th.NumberType),
th.Property("q_wellbeing_pct", th.NumberType),
th.Property("q_recommend_pct", th.NumberType),
).to_dict()
def _discover_download_url(self) -> str:
"""Scrape the open data page for the download link."""
resp = requests.get(OPEN_DATA_PAGE, timeout=30)
resp.raise_for_status()
urls = re.findall(r'href="([^"]+\.(?:xlsx|csv|zip))"', resp.text, re.IGNORECASE)
if not urls:
msg = "No download link found on Parent View open data page"
raise RuntimeError(msg)
url = urls[0]
if not url.startswith("http"):
url = "https://parentview.ofsted.gov.uk" + url
return url
def get_records(self, context):
url = self._discover_download_url()
self.logger.info("Downloading Parent View data: %s", url)
resp = requests.get(url, timeout=120)
resp.raise_for_status()
if url.endswith(".xlsx"):
df = pd.read_excel(io.BytesIO(resp.content))
else:
df = pd.read_csv(
io.BytesIO(resp.content),
encoding="latin-1",
low_memory=False,
)
# Normalise URN column
urn_col = next((c for c in df.columns if c.strip().upper() == "URN"), None)
if not urn_col:
self.logger.error("URN column not found. Columns: %s", list(df.columns)[:20])
return
df.rename(columns={urn_col: "urn"}, inplace=True)
df["urn"] = pd.to_numeric(df["urn"], errors="coerce")
df = df.dropna(subset=["urn"])
# Find total responses column
resp_col = next(
(c for c in df.columns if "total" in c.lower() and "respon" in c.lower()),
None,
)
today = date.today().isoformat()
for _, row in df.iterrows():
try:
urn = int(row["urn"])
except (ValueError, TypeError):
continue
total = None
if resp_col and pd.notna(row.get(resp_col)):
try:
total = int(row[resp_col])
except (ValueError, TypeError):
pass
yield {
"urn": urn,
"survey_date": today,
"total_responses": total,
"q_happy_pct": _positive_pct(row, "Q1"),
"q_safe_pct": _positive_pct(row, "Q2"),
"q_behaviour_pct": _positive_pct(row, "Q3"),
"q_bullying_pct": _positive_pct(row, "Q4"),
"q_communication_pct": _positive_pct(row, "Q5"),
"q_progress_pct": _positive_pct(row, "Q7"),
"q_teaching_pct": _positive_pct(row, "Q8"),
"q_information_pct": _positive_pct(row, "Q9"),
"q_curriculum_pct": _positive_pct(row, "Q10"),
"q_future_pct": _positive_pct(row, "Q11"),
"q_leadership_pct": _positive_pct(row, "Q12"),
"q_wellbeing_pct": _positive_pct(row, "Q13"),
"q_recommend_pct": _positive_pct(row, "Q14"),
}
class TapUKParentView(Tap):
"""Singer tap for UK Ofsted Parent View."""
name = "tap-uk-parent-view"
config_jsonschema = th.PropertiesList(
th.Property("download_url", th.StringType, description="Direct URL to Parent View data file"),
).to_dict()
def discover_streams(self):
return [ParentViewStream(self)]
if __name__ == "__main__":
TapUKParentView.cli()
@@ -105,6 +105,12 @@ models:
- name: year - name: year
tests: [not_null] tests: [not_null]
- name: fact_parent_view
description: Parent View survey responses
columns:
- name: urn
tests: [not_null]
- name: fact_ks2_national_averages - name: fact_ks2_national_averages
description: Official DfE KS2 national headline averages — one row per academic year description: Official DfE KS2 national headline averages — one row per academic year
columns: columns:
@@ -0,0 +1,20 @@
-- Mart: Parent View survey responses — one row per URN (latest survey)
select
urn,
survey_date,
total_responses,
q_happy_pct,
q_safe_pct,
q_behaviour_pct,
q_bullying_pct,
q_communication_pct,
q_progress_pct,
q_teaching_pct,
q_information_pct,
q_curriculum_pct,
q_future_pct,
q_leadership_pct,
q_wellbeing_pct,
q_recommend_pct
from {{ ref('stg_parent_view') }}
@@ -53,6 +53,9 @@ sources:
# Phonics: no school-level data on EES (only national/LA level) # Phonics: no school-level data on EES (only national/LA level)
- name: parent_view
description: Ofsted Parent View survey responses
- name: fbit_finance - name: fbit_finance
description: Financial benchmarking data from FBIT API description: Financial benchmarking data from FBIT API
@@ -0,0 +1,30 @@
-- Staging model: Ofsted Parent View survey responses
-- The tap computes positive percentages (Strongly agree + Agree) per question.
with source as (
select * from {{ source('raw', 'parent_view') }}
),
renamed as (
select
cast(urn as integer) as urn,
cast(survey_date as date) as survey_date,
cast(total_responses as integer) as total_responses,
cast(q_happy_pct as numeric) as q_happy_pct,
cast(q_safe_pct as numeric) as q_safe_pct,
cast(q_behaviour_pct as numeric) as q_behaviour_pct,
cast(q_bullying_pct as numeric) as q_bullying_pct,
cast(q_communication_pct as numeric) as q_communication_pct,
cast(q_progress_pct as numeric) as q_progress_pct,
cast(q_teaching_pct as numeric) as q_teaching_pct,
cast(q_information_pct as numeric) as q_information_pct,
cast(q_curriculum_pct as numeric) as q_curriculum_pct,
cast(q_future_pct as numeric) as q_future_pct,
cast(q_leadership_pct as numeric) as q_leadership_pct,
cast(q_wellbeing_pct as numeric) as q_wellbeing_pct,
cast(q_recommend_pct as numeric) as q_recommend_pct
from source
where urn is not null
)
select * from renamed
+106
View File
@@ -0,0 +1,106 @@
#!/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())
-5
View File
@@ -1,5 +0,0 @@
-- Retire the Ofsted Parent View feature (schema v6).
-- The marts schema is dbt-owned; deleting the dbt model stops the table being
-- rebuilt but does not drop the existing relation, so apply this directly
-- against the staging and production marts databases.
DROP TABLE IF EXISTS marts.fact_parent_view CASCADE;