Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2dc4d0779 | ||
|
|
5c39131b50 | ||
|
|
95081d38bd | ||
|
|
694b6013b3 | ||
|
|
9f8dba227c | ||
|
|
18cd805c6c | ||
|
|
22769b6295 | ||
|
|
90f2a02e75 | ||
|
|
d52d384cf2 | ||
|
|
ff606dad71 | ||
|
|
acec8135e1 | ||
|
|
0a370e3b63 |
+1
-2
@@ -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, Parent View, admissions, etc.)
|
# Fetch supplementary data (Ofsted, admissions, etc.)
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
supplementary = {}
|
supplementary = {}
|
||||||
try:
|
try:
|
||||||
@@ -605,7 +605,6 @@ 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 [],
|
||||||
|
|||||||
+1
-25
@@ -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, FactParentView, FactAdmissions,
|
FactOfstedInspection, FactAdmissions,
|
||||||
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||||
)
|
)
|
||||||
from .schemas import SCHOOL_TYPE_MAP
|
from .schemas import SCHOOL_TYPE_MAP
|
||||||
@@ -446,30 +446,6 @@ 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"] = (
|
||||||
|
|||||||
@@ -433,6 +433,25 @@ 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.
|
||||||
@@ -479,6 +498,9 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -149,29 +149,6 @@ 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"
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,7 @@ WHEN TO BUMP:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Current schema version - increment when models change
|
# Current schema version - increment when models change
|
||||||
SCHEMA_VERSION = 5
|
SCHEMA_VERSION = 6
|
||||||
|
|
||||||
# Changelog for documentation
|
# Changelog for documentation
|
||||||
SCHEMA_CHANGELOG = {
|
SCHEMA_CHANGELOG = {
|
||||||
@@ -22,4 +22,5 @@ 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",
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -79,8 +79,7 @@ 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`,
|
`school_data_daily`, `school_data_monthly_ofsted`, then the manual-schedule
|
||||||
`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.
|
||||||
|
|||||||
@@ -50,6 +50,56 @@ 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('results map fullscreen falls back to an overlay on iOS', async ({ page }) => {
|
||||||
|
// Same iOS gap as the hero map: no Element.requestFullscreen, so the results
|
||||||
|
// map's fullscreen button must fall back to a CSS overlay.
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
// @ts-expect-error deliberate API removal
|
||||||
|
delete Element.prototype.requestFullscreen;
|
||||||
|
});
|
||||||
|
|
||||||
|
await searchByName(page, 'B1 1BB');
|
||||||
|
await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// Switch to the map view, then open the map fullscreen.
|
||||||
|
await page.getByRole('button', { name: 'Map', exact: true }).click();
|
||||||
|
const openFs = page.getByRole('button', { name: 'View map fullscreen' });
|
||||||
|
await expect(openFs).toBeVisible({ timeout: 15_000 });
|
||||||
|
await openFs.click();
|
||||||
|
|
||||||
|
// The button flips to its exit state once the overlay is up.
|
||||||
|
const exitFs = page.getByRole('button', { name: 'Exit fullscreen' });
|
||||||
|
await expect(exitFs).toBeVisible();
|
||||||
|
await exitFs.click();
|
||||||
|
await expect(openFs).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 +116,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();
|
||||||
@@ -81,8 +157,11 @@ test('rankings stay populated after picking a specific year', async ({ page }) =
|
|||||||
const yearSelect = page.locator('#year-select');
|
const yearSelect = page.locator('#year-select');
|
||||||
await expect(yearSelect).toBeVisible({ timeout: 15_000 });
|
await expect(yearSelect).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
// Pick the first explicit year option (index 0 is the "Latest" default).
|
// Pick the last option — the most recent explicit year. The default view
|
||||||
const yearValue = await yearSelect.locator('option').nth(1).getAttribute('value');
|
// 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();
|
expect(yearValue).toBeTruthy();
|
||||||
await yearSelect.selectOption(yearValue!);
|
await yearSelect.selectOption(yearValue!);
|
||||||
await page.waitForURL(/year=/);
|
await page.waitForURL(/year=/);
|
||||||
|
|||||||
@@ -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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default async function SchoolPage({ params }: SchoolPageProps) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { school_info, yearly_data, absence_data, ofsted, parent_view, census, admissions, admissions_history, sen_detail, phonics, deprivation, finance } = data;
|
const { school_info, yearly_data, absence_data, ofsted, 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,7 +189,6 @@ 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}
|
||||||
@@ -203,7 +202,6 @@ 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 ?? []}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -713,18 +713,6 @@
|
|||||||
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;
|
||||||
@@ -732,18 +720,6 @@
|
|||||||
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;
|
||||||
@@ -1094,49 +1070,6 @@
|
|||||||
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;
|
||||||
@@ -1269,25 +1202,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, OfstedParentView, SchoolCensus,
|
OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions, SenDetail, Phonics,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
@@ -63,7 +63,6 @@ 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[];
|
||||||
@@ -75,7 +74,7 @@ interface SchoolDetailViewProps {
|
|||||||
|
|
||||||
export function SchoolDetailView({
|
export function SchoolDetailView({
|
||||||
schoolInfo, yearlyData, absenceData,
|
schoolInfo, yearlyData, absenceData,
|
||||||
ofsted, parentView, census, admissions, admissionsHistory, senDetail, phonics, deprivation, finance,
|
ofsted, 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();
|
||||||
@@ -234,8 +233,6 @@ 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' });
|
||||||
@@ -549,11 +546,6 @@ 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 ── */
|
||||||
@@ -572,11 +564,6 @@ 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' Development and Leadership.
|
Rated <strong>{OFSTED_LABELS[ofsted.overall_effectiveness!]}</strong> across all inspected areas — Quality of Teaching, Behaviour, Pupils' Development and Leadership.
|
||||||
@@ -1129,42 +1116,6 @@ 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,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,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 ? (
|
||||||
|
|||||||
@@ -10,6 +10,15 @@
|
|||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 the bottom nav; below modals (9999+). */
|
||||||
|
.mapWrapper.fsFallback {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5000;
|
||||||
|
}
|
||||||
|
|
||||||
.fullscreenBtn {
|
.fullscreenBtn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.625rem;
|
top: 0.625rem;
|
||||||
|
|||||||
@@ -33,22 +33,52 @@ interface SchoolMapProps {
|
|||||||
|
|
||||||
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
|
||||||
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;
|
||||||
|
|
||||||
// Sync state with browser fullscreen events (e.g. Escape key)
|
// Sync state with browser fullscreen events (e.g. Escape key)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
|
const onFsChange = () => setNativeFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Lock body scroll while the fallback overlay is up.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fallbackFullscreen) return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}, [fallbackFullscreen]);
|
||||||
|
|
||||||
|
// Leaflet re-measures on window resize (trackResize). Native fullscreen fires
|
||||||
|
// one; the CSS fallback overlay changes size without a resize event, so nudge
|
||||||
|
// Leaflet after the layout settles or the map fills only part of the screen.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [isFullscreen]);
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
if (!document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
wrapperRef.current?.requestFullscreen();
|
document.exitFullscreen().catch(() => {});
|
||||||
} else {
|
return;
|
||||||
document.exitFullscreen();
|
|
||||||
}
|
}
|
||||||
}, []);
|
if (fallbackFullscreen) {
|
||||||
|
setFallbackFullscreen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const el = wrapperRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (el.requestFullscreen) {
|
||||||
|
el.requestFullscreen().catch(() => setFallbackFullscreen(true));
|
||||||
|
} else {
|
||||||
|
setFallbackFullscreen(true);
|
||||||
|
}
|
||||||
|
}, [fallbackFullscreen]);
|
||||||
|
|
||||||
// Calculate center if not provided
|
// Calculate center if not provided
|
||||||
const mapCenter: [number, number] = center || (() => {
|
const mapCenter: [number, number] = center || (() => {
|
||||||
@@ -64,7 +94,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''}`}>
|
<div ref={wrapperRef} className={`${styles.mapWrapper} ${isFullscreen ? styles.fullscreen : ''} ${fallbackFullscreen ? styles.fsFallback : ''}`}>
|
||||||
<button
|
<button
|
||||||
className={styles.fullscreenBtn}
|
className={styles.fullscreenBtn}
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
|
|||||||
@@ -383,17 +383,6 @@
|
|||||||
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);
|
||||||
@@ -664,60 +653,6 @@
|
|||||||
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;
|
||||||
@@ -1135,10 +1070,6 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parentViewLabel {
|
|
||||||
flex-basis: 10rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ofstedReportLink {
|
.ofstedReportLink {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1151,25 +1082,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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, OfstedParentView, SchoolCensus,
|
OfstedInspection, SchoolCensus,
|
||||||
SchoolAdmissions, SenDetail, Phonics,
|
SchoolAdmissions, SenDetail, Phonics,
|
||||||
SchoolDeprivation, SchoolFinance, NationalAverages,
|
SchoolDeprivation, SchoolFinance, NationalAverages,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
@@ -65,7 +65,6 @@ 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;
|
||||||
@@ -76,7 +75,7 @@ interface SecondarySchoolDetailViewProps {
|
|||||||
|
|
||||||
export function SecondarySchoolDetailView({
|
export function SecondarySchoolDetailView({
|
||||||
schoolInfo, yearlyData,
|
schoolInfo, yearlyData,
|
||||||
ofsted, parentView, census, admissions, senDetail, deprivation, finance, absenceData,
|
ofsted, 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.
|
||||||
@@ -101,7 +100,6 @@ 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;
|
||||||
@@ -159,7 +157,6 @@ 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' });
|
||||||
|
|
||||||
@@ -435,11 +432,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -775,42 +767,6 @@ 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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -99,25 +99,6 @@ 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;
|
||||||
@@ -312,7 +293,6 @@ 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. */
|
||||||
|
|||||||
@@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -156,31 +156,6 @@ 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(
|
||||||
|
|||||||
@@ -50,11 +50,6 @@ 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
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
[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"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""tap-uk-parent-view: Singer tap for Ofsted Parent View survey data."""
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
"""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,12 +105,6 @@ 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:
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
-- 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,9 +53,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
-- 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
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 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;
|
||||||
Reference in New Issue
Block a user