Compare commits
48 Commits
9c50c49e1f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 87442788d4 | |||
| 62eeee5f7c | |||
| a7ab624a01 | |||
| 7e182e88b2 | |||
| 4cfae93a0d | |||
| 99dc5e7f8b | |||
| 763aef09f8 | |||
| d569a2afda | |||
| 1ca957499a | |||
| 9133ecdcd4 | |||
| 56ab1368b1 | |||
| 59f13a74f9 | |||
| 38d033f6a9 | |||
| 6045114ca2 | |||
| e39a79bab0 | |||
| a5be07ac0f | |||
| 4acfd21883 | |||
| 2a8ff29ccd | |||
| 976ebe752b | |||
| 1fb4b3ec5e | |||
| 675601869b | |||
| b7da3054e1 | |||
| c39256b1a0 | |||
| 9e0b004d93 | |||
| 795e2bae35 | |||
| 822d2afba1 | |||
| 9d34459191 | |||
| e52467ff5d | |||
| ae33bfe04b | |||
| 785cb72063 | |||
| 7e6ded29e2 | |||
| 3401654ab9 | |||
| 8154a59014 | |||
| 2e3456b21b | |||
| f05bbba613 | |||
| f6b9d650f8 | |||
| 3327728df0 | |||
| ac2d64caaf | |||
| bfff24fa5f | |||
| 34cd8ad26e | |||
| a27b9abd9f | |||
| 045dbc65b7 | |||
| 35deedcc16 | |||
| 5abab067a1 | |||
| 6d685b7e8a | |||
| 24ba65c829 | |||
| 3bf2e8f262 | |||
| 8ce34b3ecc |
@@ -0,0 +1,71 @@
|
|||||||
|
# Mobile design baseline
|
||||||
|
|
||||||
|
Mobile (≥55% of traffic) is the primary target for this app. Any new
|
||||||
|
screen or component must be designed at the **360 px** viewport first
|
||||||
|
and verified at three reference widths before merge.
|
||||||
|
|
||||||
|
## Reference viewports
|
||||||
|
|
||||||
|
| Width | Device class | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 360 px | Low-end Android (Samsung A-series, older Pixels) | Hard floor — if it doesn't fit here it isn't shipping |
|
||||||
|
| 390 px | iPhone 14 / 15 / 16 (38% of mobile traffic) | Primary iOS target |
|
||||||
|
| 430 px | iPhone 16 Pro Max, large Android | Upper mobile bound |
|
||||||
|
|
||||||
|
## Acceptance checks for any screen change
|
||||||
|
|
||||||
|
Before raising a PR that touches user-visible UI, confirm at each
|
||||||
|
reference width:
|
||||||
|
|
||||||
|
1. **No horizontal overflow.** `document.documentElement.scrollWidth ===
|
||||||
|
window.innerWidth`. The most reliable check: in DevTools console run
|
||||||
|
```js
|
||||||
|
document.documentElement.scrollWidth - innerWidth
|
||||||
|
```
|
||||||
|
It must read `0`. Any positive number means something is bleeding
|
||||||
|
past the right edge — usually a fixed-width element, an inline-block
|
||||||
|
that didn't wrap, or a flex row missing `flex-wrap: wrap`.
|
||||||
|
2. **Tap targets ≥ 44 × 44 px** on every interactive element (iOS Human
|
||||||
|
Interface Guidelines minimum). Probe with:
|
||||||
|
```js
|
||||||
|
Array.from(document.querySelectorAll('a, button, [role=button], input, select'))
|
||||||
|
.filter(el => el.offsetParent)
|
||||||
|
.map(el => ({ t: el.innerText?.trim().slice(0,30), r: el.getBoundingClientRect() }))
|
||||||
|
.filter(o => o.r.width < 44 || o.r.height < 44)
|
||||||
|
```
|
||||||
|
3. **No text below 11 px** in any visible-by-default block. Decorative
|
||||||
|
demo content (illustrations, mocked previews) should either scale up
|
||||||
|
or be hidden under the `640 px` breakpoint — see `MOB-04` for the
|
||||||
|
pattern used on the home page's "What you'll see" section.
|
||||||
|
4. **iOS Chrome bottom-bar parity.** The fixed `Navigation` bottom tab
|
||||||
|
bar already compensates for the auto-hiding URL bar via the Visual
|
||||||
|
Viewport API (`Navigation.tsx`). New fixed-bottom elements must
|
||||||
|
either use the same offset (read `var(--mobile-bar-offset)`) or sit
|
||||||
|
inside the existing tab-bar container.
|
||||||
|
5. **Safe-area insets** on any new sticky/fixed chrome:
|
||||||
|
`padding-bottom: env(safe-area-inset-bottom)` for bottom-pinned UI,
|
||||||
|
`padding-inline: env(safe-area-inset-left/right)` for header-class
|
||||||
|
chrome that runs full bleed.
|
||||||
|
6. **`dvh`, not `vh`.** iOS Safari's collapsing toolbar makes raw `vh`
|
||||||
|
units jump. Prefer `100dvh` (with a `100vh` fallback if you support
|
||||||
|
older engines) for any height that needs to track the visible
|
||||||
|
viewport.
|
||||||
|
|
||||||
|
## Component patterns
|
||||||
|
|
||||||
|
- **Hide-on-mobile decoration:** wrap with `@media (max-width: 640px) {
|
||||||
|
.x { display: none; } }` — examples in `HomeView.module.css`
|
||||||
|
(`.hiwVisual`), `MetricTooltip.module.css` (`.wrapper`).
|
||||||
|
- **Right-edge scroll-fade for horizontal scrollers:**
|
||||||
|
`mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);`
|
||||||
|
Drop the fade when scrolled to the end with a JS-toggled class — see
|
||||||
|
`SchoolDetailView.tsx`'s `sectionNavAtEnd` state for the pattern.
|
||||||
|
|
||||||
|
## Automation (future)
|
||||||
|
|
||||||
|
A Playwright regression test that asserts `docW === vw` at the three
|
||||||
|
reference widths on `/`, `/rankings`, `/admissions`, `/compare`, and a
|
||||||
|
representative `/school/:urn` page would catch overflow regressions
|
||||||
|
immediately. Not added yet — Playwright isn't currently in the project
|
||||||
|
dependency set, and the existing Jest setup doesn't compute layout.
|
||||||
|
Worth adding if mobile overflow regressions recur.
|
||||||
+87
-36
@@ -4,6 +4,7 @@ Serves primary and secondary school performance data for comparing schools.
|
|||||||
Uses real data from UK Government Compare School Performance downloads.
|
Uses real data from UK Government Compare School Performance downloads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -12,6 +13,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
|
from fastapi import FastAPI, HTTPException, Query, Request, Depends, Header
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.responses import FileResponse, Response
|
from fastapi.responses import FileResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
@@ -24,6 +26,7 @@ from .config import settings
|
|||||||
from .data_loader import (
|
from .data_loader import (
|
||||||
clear_cache,
|
clear_cache,
|
||||||
load_school_data,
|
load_school_data,
|
||||||
|
load_latest_school_data,
|
||||||
geocode_single_postcode,
|
geocode_single_postcode,
|
||||||
get_supplementary_data,
|
get_supplementary_data,
|
||||||
search_schools_typesense,
|
search_schools_typesense,
|
||||||
@@ -164,6 +167,69 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# Per-path Cache-Control rules. Keys are matched as path prefixes (longest wins).
|
||||||
|
# Values: (max_age, s_maxage, stale_while_revalidate)
|
||||||
|
CACHE_RULES: list[tuple[str, tuple[int, int, int]]] = [
|
||||||
|
("/api/filters", (300, 86400, 604800)),
|
||||||
|
("/api/metrics", (300, 86400, 604800)),
|
||||||
|
("/api/national-averages", (300, 86400, 604800)),
|
||||||
|
("/api/la-averages", (300, 86400, 604800)),
|
||||||
|
("/api/data-info", (300, 86400, 604800)),
|
||||||
|
("/api/schools/", (300, 3600, 86400)), # /api/schools/{urn}
|
||||||
|
("/api/rankings", (60, 600, 3600)),
|
||||||
|
("/api/compare", (60, 600, 3600)),
|
||||||
|
("/api/schools", (30, 300, 1800)), # search list
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_control_for_path(path: str) -> Optional[str]:
|
||||||
|
# Longest-prefix match
|
||||||
|
best: Optional[tuple[int, tuple[int, int, int]]] = None
|
||||||
|
for prefix, vals in CACHE_RULES:
|
||||||
|
if path.startswith(prefix) and (best is None or len(prefix) > best[0]):
|
||||||
|
best = (len(prefix), vals)
|
||||||
|
if best is None:
|
||||||
|
return None
|
||||||
|
max_age, s_maxage, swr = best[1]
|
||||||
|
return f"public, max-age={max_age}, s-maxage={s_maxage}, stale-while-revalidate={swr}"
|
||||||
|
|
||||||
|
|
||||||
|
class CacheAndETagMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Set Cache-Control on cacheable API responses and serve 304s via ETag."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# Only cache GETs that succeeded.
|
||||||
|
if request.method != "GET" or response.status_code != 200:
|
||||||
|
return response
|
||||||
|
|
||||||
|
cache_header = _cache_control_for_path(request.url.path)
|
||||||
|
if cache_header is None:
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Drain body so we can hash it for ETag.
|
||||||
|
body_chunks = []
|
||||||
|
async for chunk in response.body_iterator:
|
||||||
|
body_chunks.append(chunk)
|
||||||
|
body = b"".join(body_chunks)
|
||||||
|
|
||||||
|
etag = '"' + hashlib.md5(body).hexdigest() + '"'
|
||||||
|
headers = dict(response.headers)
|
||||||
|
headers["Cache-Control"] = cache_header
|
||||||
|
headers["ETag"] = etag
|
||||||
|
headers["Vary"] = ", ".join(filter(None, [headers.get("Vary"), "Accept-Encoding"]))
|
||||||
|
|
||||||
|
inm = request.headers.get("if-none-match")
|
||||||
|
if inm and inm == etag:
|
||||||
|
# Strip content headers on 304.
|
||||||
|
for h in ("Content-Length", "content-length", "Content-Type", "content-type"):
|
||||||
|
headers.pop(h, None)
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
|
|
||||||
|
return Response(content=body, status_code=200, headers=headers, media_type=response.media_type)
|
||||||
|
|
||||||
|
|
||||||
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
|
||||||
"""Limit request body size to prevent DoS attacks."""
|
"""Limit request body size to prevent DoS attacks."""
|
||||||
|
|
||||||
@@ -223,6 +289,8 @@ async def lifespan(app: FastAPI):
|
|||||||
print("Warning: No data in marts. Run the annual EES pipeline to populate KS2 data.")
|
print("Warning: No data in marts. Run the annual EES pipeline to populate KS2 data.")
|
||||||
else:
|
else:
|
||||||
print(f"Data loaded successfully: {len(df)} records.")
|
print(f"Data loaded successfully: {len(df)} records.")
|
||||||
|
# Pre-compute the latest-year snapshot so the first search request is fast
|
||||||
|
await asyncio.to_thread(load_latest_school_data)
|
||||||
try:
|
try:
|
||||||
_sitemap_xml = build_sitemap()
|
_sitemap_xml = build_sitemap()
|
||||||
n = _sitemap_xml.count("<url>")
|
n = _sitemap_xml.count("<url>")
|
||||||
@@ -250,9 +318,12 @@ app = FastAPI(
|
|||||||
app.state.limiter = limiter
|
app.state.limiter = limiter
|
||||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
# Security middleware (order matters - these run in reverse order)
|
# Middleware (Starlette runs the last-added middleware first on the way out,
|
||||||
|
# so list outermost-last: GZip wraps everything and compresses the final body).
|
||||||
|
app.add_middleware(CacheAndETagMiddleware)
|
||||||
app.add_middleware(SecurityHeadersMiddleware)
|
app.add_middleware(SecurityHeadersMiddleware)
|
||||||
app.add_middleware(RequestSizeLimitMiddleware)
|
app.add_middleware(RequestSizeLimitMiddleware)
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=512)
|
||||||
|
|
||||||
# CORS middleware - restricted for production
|
# CORS middleware - restricted for production
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -321,44 +392,17 @@ async def get_schools(
|
|||||||
phase = sanitize_search_input(phase)
|
phase = sanitize_search_input(phase)
|
||||||
postcode = validate_postcode(postcode)
|
postcode = validate_postcode(postcode)
|
||||||
|
|
||||||
df = load_school_data()
|
# Load the pre-computed latest-year snapshot (cached after first request / startup).
|
||||||
|
# This avoids rebuilding the expensive groupby + prev-year merge on every search.
|
||||||
|
df_latest = load_latest_school_data()
|
||||||
|
|
||||||
if df.empty:
|
if df_latest.empty:
|
||||||
return {"schools": [], "total": 0, "page": page, "page_size": 0}
|
return {"schools": [], "total": 0, "page": page, "page_size": 0}
|
||||||
|
|
||||||
# Use configured default if not specified
|
# Use configured default if not specified
|
||||||
if page_size is None:
|
if page_size is None:
|
||||||
page_size = settings.default_page_size
|
page_size = settings.default_page_size
|
||||||
|
|
||||||
# Schools with no performance data (special schools, PRUs, newly opened, etc.)
|
|
||||||
# have NULL year from the LEFT JOIN — keep them but skip the groupby/trend logic.
|
|
||||||
df_no_perf = df[df["year"].isna()].drop_duplicates(subset=["urn"])
|
|
||||||
df = df[df["year"].notna()]
|
|
||||||
|
|
||||||
# Get unique schools (latest year data for each)
|
|
||||||
latest_year = df.groupby("urn")["year"].max().reset_index()
|
|
||||||
df_latest = df.merge(latest_year, on=["urn", "year"])
|
|
||||||
|
|
||||||
# Calculate trend by comparing to previous year
|
|
||||||
# Get second-latest year for each school
|
|
||||||
df_sorted = df.sort_values(["urn", "year"], ascending=[True, False])
|
|
||||||
df_prev = df_sorted.groupby("urn").nth(1).reset_index()
|
|
||||||
if not df_prev.empty and "rwm_expected_pct" in df_prev.columns:
|
|
||||||
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
|
||||||
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
|
||||||
)
|
|
||||||
if "attainment_8_score" in df_prev.columns:
|
|
||||||
prev_rwm = prev_rwm.merge(
|
|
||||||
df_prev[["urn", "attainment_8_score"]].rename(
|
|
||||||
columns={"attainment_8_score": "prev_attainment_8_score"}
|
|
||||||
),
|
|
||||||
on="urn", how="outer"
|
|
||||||
)
|
|
||||||
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
|
||||||
|
|
||||||
# Merge back schools with no performance data
|
|
||||||
df_latest = pd.concat([df_latest, df_no_perf], ignore_index=True)
|
|
||||||
|
|
||||||
# Phase filter — uses PHASE_GROUPS so all-through/middle schools appear
|
# Phase filter — uses PHASE_GROUPS so all-through/middle schools appear
|
||||||
# in the correct phase(s) rather than being invisible to both filters.
|
# in the correct phase(s) rather than being invisible to both filters.
|
||||||
if phase:
|
if phase:
|
||||||
@@ -404,7 +448,8 @@ async def get_schools(
|
|||||||
# Location-based search (uses pre-geocoded data from database)
|
# Location-based search (uses pre-geocoded data from database)
|
||||||
search_coords = None
|
search_coords = None
|
||||||
if postcode:
|
if postcode:
|
||||||
coords = geocode_single_postcode(postcode)
|
# Offload the synchronous HTTP call to a thread so the event loop stays free
|
||||||
|
coords = await asyncio.to_thread(geocode_single_postcode, postcode)
|
||||||
if coords:
|
if coords:
|
||||||
search_coords = coords
|
search_coords = coords
|
||||||
schools_df = schools_df.copy()
|
schools_df = schools_df.copy()
|
||||||
@@ -683,7 +728,7 @@ async def get_national_averages(request: Request):
|
|||||||
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
||||||
"reading_progress", "writing_progress", "maths_progress",
|
"reading_progress", "writing_progress", "maths_progress",
|
||||||
"overall_absence_pct", "persistent_absence_pct",
|
"overall_absence_pct", "persistent_absence_pct",
|
||||||
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct",
|
"disadvantaged_gap", "disadvantaged_pct", "sen_support_pct", "eal_pct",
|
||||||
]
|
]
|
||||||
ks4_metrics = [
|
ks4_metrics = [
|
||||||
"attainment_8_score", "progress_8_score",
|
"attainment_8_score", "progress_8_score",
|
||||||
@@ -841,7 +886,12 @@ async def get_rankings(
|
|||||||
|
|
||||||
# Return only relevant fields for rankings
|
# Return only relevant fields for rankings
|
||||||
available_cols = [c for c in RANKING_COLUMNS if c in df.columns]
|
available_cols = [c for c in RANKING_COLUMNS if c in df.columns]
|
||||||
df = df[available_cols]
|
df = df[available_cols].copy()
|
||||||
|
|
||||||
|
# Surface the requested metric under a stable `value` key so the
|
||||||
|
# frontend doesn't need to know each metric's column name. The raw
|
||||||
|
# metric column is also kept in the row for callers that want it.
|
||||||
|
df["value"] = df[metric]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"metric": metric,
|
"metric": metric,
|
||||||
@@ -907,7 +957,8 @@ async def reload_data(
|
|||||||
Requires X-API-Key header with valid admin API key.
|
Requires X-API-Key header with valid admin API key.
|
||||||
"""
|
"""
|
||||||
clear_cache()
|
clear_cache()
|
||||||
load_school_data()
|
await asyncio.to_thread(load_school_data)
|
||||||
|
await asyncio.to_thread(load_latest_school_data)
|
||||||
return {"status": "reloaded"}
|
return {"status": "reloaded"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+81
-8
@@ -15,7 +15,7 @@ from .database import SessionLocal, engine
|
|||||||
from .models import (
|
from .models import (
|
||||||
DimSchool, DimLocation, KS2Performance,
|
DimSchool, DimLocation, KS2Performance,
|
||||||
FactOfstedInspection, FactParentView, FactAdmissions,
|
FactOfstedInspection, FactParentView, FactAdmissions,
|
||||||
FactDeprivation, FactFinance,
|
FactDeprivation, FactFinance, FactPupilCharacteristics,
|
||||||
)
|
)
|
||||||
from .schemas import SCHOOL_TYPE_MAP
|
from .schemas import SCHOOL_TYPE_MAP
|
||||||
|
|
||||||
@@ -130,9 +130,9 @@ _MAIN_QUERY = text("""
|
|||||||
s.total_pupils AS gias_total_pupils,
|
s.total_pupils AS gias_total_pupils,
|
||||||
s.headteacher_name,
|
s.headteacher_name,
|
||||||
s.website,
|
s.website,
|
||||||
s.ofsted_grade,
|
foi.ofsted_grade,
|
||||||
s.ofsted_date,
|
foi.ofsted_date,
|
||||||
s.ofsted_framework,
|
foi.ofsted_framework,
|
||||||
l.local_authority_name AS local_authority,
|
l.local_authority_name AS local_authority,
|
||||||
l.local_authority_code,
|
l.local_authority_code,
|
||||||
l.address_line1 AS address1,
|
l.address_line1 AS address1,
|
||||||
@@ -201,6 +201,15 @@ _MAIN_QUERY = text("""
|
|||||||
FROM marts.dim_school s
|
FROM marts.dim_school s
|
||||||
JOIN marts.dim_location l ON s.urn = l.urn
|
JOIN marts.dim_location l ON s.urn = l.urn
|
||||||
LEFT JOIN marts.fact_performance p ON s.urn = p.urn
|
LEFT JOIN marts.fact_performance p ON s.urn = p.urn
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DISTINCT ON (urn)
|
||||||
|
urn,
|
||||||
|
overall_effectiveness AS ofsted_grade,
|
||||||
|
inspection_date AS ofsted_date,
|
||||||
|
framework AS ofsted_framework
|
||||||
|
FROM marts.fact_ofsted_inspection
|
||||||
|
ORDER BY urn, inspection_date DESC NULLS LAST
|
||||||
|
) foi ON s.urn = foi.urn
|
||||||
ORDER BY s.school_name, p.year
|
ORDER BY s.school_name, p.year
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -233,6 +242,8 @@ def load_school_data_as_dataframe() -> pd.DataFrame:
|
|||||||
|
|
||||||
# Cache for DataFrame
|
# Cache for DataFrame
|
||||||
_df_cache: Optional[pd.DataFrame] = None
|
_df_cache: Optional[pd.DataFrame] = None
|
||||||
|
# Pre-computed latest-year snapshot (one row per school, with prev-year trend columns)
|
||||||
|
_df_latest_cache: Optional[pd.DataFrame] = None
|
||||||
|
|
||||||
|
|
||||||
def load_school_data() -> pd.DataFrame:
|
def load_school_data() -> pd.DataFrame:
|
||||||
@@ -251,10 +262,60 @@ def load_school_data() -> pd.DataFrame:
|
|||||||
return _df_cache
|
return _df_cache
|
||||||
|
|
||||||
|
|
||||||
|
def load_latest_school_data() -> pd.DataFrame:
|
||||||
|
"""Return a cached one-row-per-school DataFrame at the latest available year.
|
||||||
|
|
||||||
|
The expensive groupby / merge / prev-year trend computation runs once at
|
||||||
|
startup (or after a cache clear) rather than on every search request.
|
||||||
|
Per-request filters (phase, gender, LA …) should be applied to the returned
|
||||||
|
DataFrame's copy; they must NOT modify the cached object.
|
||||||
|
"""
|
||||||
|
global _df_latest_cache
|
||||||
|
if _df_latest_cache is not None:
|
||||||
|
return _df_latest_cache
|
||||||
|
|
||||||
|
df = load_school_data()
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Schools that have no performance rows (PRUs, new schools, etc.)
|
||||||
|
df_no_perf = df[df["year"].isna()].drop_duplicates(subset=["urn"])
|
||||||
|
df_with_perf = df[df["year"].notna()]
|
||||||
|
|
||||||
|
# Reduce to the latest year per school
|
||||||
|
latest_year = df_with_perf.groupby("urn")["year"].max().reset_index()
|
||||||
|
df_latest = df_with_perf.merge(latest_year, on=["urn", "year"])
|
||||||
|
|
||||||
|
# Attach previous-year metrics for trend arrows (second-latest year per school)
|
||||||
|
df_sorted = df_with_perf.sort_values(["urn", "year"], ascending=[True, False])
|
||||||
|
df_prev = df_sorted.groupby("urn").nth(1).reset_index()
|
||||||
|
if not df_prev.empty and "rwm_expected_pct" in df_prev.columns:
|
||||||
|
prev_rwm = df_prev[["urn", "rwm_expected_pct"]].rename(
|
||||||
|
columns={"rwm_expected_pct": "prev_rwm_expected_pct"}
|
||||||
|
)
|
||||||
|
if "attainment_8_score" in df_prev.columns:
|
||||||
|
prev_rwm = prev_rwm.merge(
|
||||||
|
df_prev[["urn", "attainment_8_score"]].rename(
|
||||||
|
columns={"attainment_8_score": "prev_attainment_8_score"}
|
||||||
|
),
|
||||||
|
on="urn",
|
||||||
|
how="outer",
|
||||||
|
)
|
||||||
|
df_latest = df_latest.merge(prev_rwm, on="urn", how="left")
|
||||||
|
|
||||||
|
# Merge back schools with no performance data
|
||||||
|
df_latest = pd.concat([df_latest, df_no_perf], ignore_index=True)
|
||||||
|
|
||||||
|
print(f"Latest-snapshot cache built: {len(df_latest)} schools")
|
||||||
|
_df_latest_cache = df_latest
|
||||||
|
return _df_latest_cache
|
||||||
|
|
||||||
|
|
||||||
def clear_cache():
|
def clear_cache():
|
||||||
"""Clear all caches."""
|
"""Clear all caches."""
|
||||||
global _df_cache
|
global _df_cache, _df_latest_cache
|
||||||
_df_cache = None
|
_df_cache = None
|
||||||
|
_df_latest_cache = None
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -401,8 +462,20 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Census (fact_pupil_characteristics — minimal until census columns are verified)
|
# Census (latest year of fact_pupil_characteristics)
|
||||||
result["census"] = None
|
pc = safe_query(FactPupilCharacteristics, "urn", "year")
|
||||||
|
result["census"] = (
|
||||||
|
{
|
||||||
|
"year": pc.year,
|
||||||
|
"total_pupils": pc.total_pupils,
|
||||||
|
"female_pupils": pc.female_pupils,
|
||||||
|
"male_pupils": pc.male_pupils,
|
||||||
|
"fsm_pct": pc.fsm_pct,
|
||||||
|
"eal_pct": pc.eal_pct,
|
||||||
|
}
|
||||||
|
if pc
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
# Admissions (latest year)
|
# Admissions (latest year)
|
||||||
a = safe_query(FactAdmissions, "urn", "year")
|
a = safe_query(FactAdmissions, "urn", "year")
|
||||||
@@ -410,7 +483,7 @@ def get_supplementary_data(db: Session, urn: int) -> dict:
|
|||||||
{
|
{
|
||||||
"year": a.year,
|
"year": a.year,
|
||||||
"school_phase": a.school_phase,
|
"school_phase": a.school_phase,
|
||||||
"published_admission_number": a.published_admission_number,
|
"places_offered": a.places_offered,
|
||||||
"total_applications": a.total_applications,
|
"total_applications": a.total_applications,
|
||||||
"first_preference_applications": a.first_preference_applications,
|
"first_preference_applications": a.first_preference_applications,
|
||||||
"first_preference_offers": a.first_preference_offers,
|
"first_preference_offers": a.first_preference_offers,
|
||||||
|
|||||||
+2
-5
@@ -15,6 +15,7 @@ engine = create_engine(
|
|||||||
pool_size=10,
|
pool_size=10,
|
||||||
max_overflow=20,
|
max_overflow=20,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=1800, # recycle connections every 30 min to avoid stale TCP
|
||||||
echo=False,
|
echo=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,13 +35,9 @@ def get_db():
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_db_session():
|
def get_db_session():
|
||||||
"""Context manager for non-FastAPI contexts."""
|
"""Context manager for non-FastAPI contexts (read-only)."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
db.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
+19
-1
@@ -179,7 +179,7 @@ class FactAdmissions(Base):
|
|||||||
urn = Column(Integer, primary_key=True)
|
urn = Column(Integer, primary_key=True)
|
||||||
year = Column(Integer, primary_key=True)
|
year = Column(Integer, primary_key=True)
|
||||||
school_phase = Column(String(50))
|
school_phase = Column(String(50))
|
||||||
published_admission_number = Column(Integer)
|
places_offered = Column(Integer)
|
||||||
total_applications = Column(Integer)
|
total_applications = Column(Integer)
|
||||||
first_preference_applications = Column(Integer)
|
first_preference_applications = Column(Integer)
|
||||||
first_preference_offers = Column(Integer)
|
first_preference_offers = Column(Integer)
|
||||||
@@ -189,6 +189,24 @@ class FactAdmissions(Base):
|
|||||||
admissions_policy = Column(String(100))
|
admissions_policy = Column(String(100))
|
||||||
|
|
||||||
|
|
||||||
|
class FactPupilCharacteristics(Base):
|
||||||
|
"""School pupil composition from EES census — one row per URN per year."""
|
||||||
|
__tablename__ = "fact_pupil_characteristics"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_pupil_chars_urn_year", "urn", "year"),
|
||||||
|
MARTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
urn = Column(Integer, primary_key=True)
|
||||||
|
year = Column(Integer, primary_key=True)
|
||||||
|
phase_type_grouping = Column(String(50))
|
||||||
|
total_pupils = Column(Integer)
|
||||||
|
female_pupils = Column(Integer)
|
||||||
|
male_pupils = Column(Integer)
|
||||||
|
fsm_pct = Column(Float)
|
||||||
|
eal_pct = Column(Float)
|
||||||
|
|
||||||
|
|
||||||
class FactDeprivation(Base):
|
class FactDeprivation(Base):
|
||||||
"""IDACI deprivation index — one row per URN."""
|
"""IDACI deprivation index — one row per URN."""
|
||||||
__tablename__ = "fact_deprivation"
|
__tablename__ = "fact_deprivation"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { AdmissionsView } from '@/components/AdmissionsView';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'School Admissions Guide',
|
||||||
|
description:
|
||||||
|
'Understand the Primary and Secondary school admissions process in England, with live countdowns to every key deadline and National Offer Day.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdmissionsPage() {
|
||||||
|
return <AdmissionsView />;
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ export const metadata: Metadata = {
|
|||||||
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
|
keywords: 'school comparison, compare schools, KS2 comparison, primary school performance',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Force dynamic rendering
|
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||||
export const dynamic = 'force-dynamic';
|
// can still use Next.js's per-call revalidate cache.
|
||||||
|
|
||||||
export default async function ComparePage({ searchParams }: ComparePageProps) {
|
export default async function ComparePage({ searchParams }: ComparePageProps) {
|
||||||
const { urns: urnsParam, metric: metricParam } = await searchParams;
|
const { urns: urnsParam, metric: metricParam } = await searchParams;
|
||||||
|
|||||||
@@ -92,7 +92,31 @@ body {
|
|||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
/* dvh (dynamic viewport) accounts for iOS Safari's collapsing toolbar;
|
||||||
|
fall back to vh on older engines that don't recognise dvh. */
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
/* Suppress the iOS Safari grey tap flash — explicit :active states
|
||||||
|
below carry the press feedback instead. */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Provide a baseline press feedback for the most common interactive
|
||||||
|
elements — replaces the suppressed default tap highlight. Buttons and
|
||||||
|
.btn-* classes carry their own :active styles already; this handles
|
||||||
|
plain anchors used as inline links and bare button elements. */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
a:active,
|
||||||
|
button:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reserve space for the fixed mobile bottom tab bar (56px + safe-area inset). */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skip link — visible only on focus for keyboard users */
|
/* Skip link — visible only on focus for keyboard users */
|
||||||
@@ -1759,7 +1783,7 @@ body {
|
|||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
margin: 1rem;
|
margin: 1rem;
|
||||||
max-height: calc(100vh - 2rem);
|
max-height: calc(100dvh - 2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -1829,7 +1853,7 @@ body {
|
|||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.modal-content {
|
.modal-content {
|
||||||
margin: 0.5rem;
|
margin: 0.5rem;
|
||||||
max-height: calc(100vh - 1rem);
|
max-height: calc(100dvh - 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { DM_Sans, Playfair_Display } from 'next/font/google';
|
import { DM_Sans, Playfair_Display } from 'next/font/google';
|
||||||
import Script from 'next/script';
|
import Script from 'next/script';
|
||||||
import { Navigation } from '@/components/Navigation';
|
import { Navigation } from '@/components/Navigation';
|
||||||
@@ -21,7 +21,24 @@ const playfairDisplay = Playfair_Display({
|
|||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
// viewport-fit=cover lets us paint behind the notch / Dynamic Island so
|
||||||
|
// env(safe-area-inset-*) values resolve to real numbers on iPhone.
|
||||||
|
viewportFit: 'cover',
|
||||||
|
themeColor: [
|
||||||
|
{ media: '(prefers-color-scheme: light)', color: '#faf7f2' },
|
||||||
|
{ media: '(prefers-color-scheme: dark)', color: '#1a1612' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
title: 'SchoolCompare',
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
default: 'SchoolCompare | Compare School Performance',
|
default: 'SchoolCompare | Compare School Performance',
|
||||||
template: '%s | SchoolCompare',
|
template: '%s | SchoolCompare',
|
||||||
@@ -57,10 +74,12 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<link rel="preconnect" href="https://analytics.schoolcompare.co.uk" />
|
||||||
|
<link rel="preconnect" href="https://api.postcodes.io" />
|
||||||
<Script
|
<Script
|
||||||
defer
|
|
||||||
src="https://analytics.schoolcompare.co.uk/script.js"
|
src="https://analytics.schoolcompare.co.uk/script.js"
|
||||||
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||||
|
data-performance="true"
|
||||||
strategy="afterInteractive"
|
strategy="afterInteractive"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+15
-6
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
import { fetchSchools, fetchFilters, fetchDataInfo } from '@/lib/api';
|
||||||
import { HomeView } from '@/components/HomeView';
|
import { HomeView } from '@/components/HomeView';
|
||||||
|
import { HowItWorksSection } from '@/components/HowItWorksSection';
|
||||||
|
import { EditorialSection } from '@/components/EditorialSection';
|
||||||
|
|
||||||
interface HomePageProps {
|
interface HomePageProps {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
@@ -27,8 +29,9 @@ export const metadata = {
|
|||||||
description: 'Search and compare school performance across England',
|
description: 'Search and compare school performance across England',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Force dynamic rendering (no static generation at build time)
|
// The page reads searchParams, which makes rendering dynamic by default.
|
||||||
export const dynamic = 'force-dynamic';
|
// We don't use `force-dynamic` here so the internal filter/data-info fetches
|
||||||
|
// can still hit Next.js's data cache (configured per-call in lib/api.ts).
|
||||||
|
|
||||||
export default async function HomePage({ searchParams }: HomePageProps) {
|
export default async function HomePage({ searchParams }: HomePageProps) {
|
||||||
// Await search params (Next.js 15 requirement)
|
// Await search params (Next.js 15 requirement)
|
||||||
@@ -75,22 +78,28 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
schoolsData = { schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 };
|
schoolsData = { schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedFilters = filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||||
|
const total = dataInfo?.total_schools ?? null;
|
||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={schoolsData}
|
initialSchools={schoolsData}
|
||||||
filters={filtersData || { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
filters={resolvedFilters}
|
||||||
totalSchools={dataInfo?.total_schools ?? null}
|
totalSchools={total}
|
||||||
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||||
|
editorial={hasSearchParams ? null : <EditorialSection totalSchools={total} localAuthorityCount={resolvedFilters.local_authorities.length} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data for home page:', error);
|
console.error('Error fetching data for home page:', error);
|
||||||
|
|
||||||
// Return error state with empty data
|
const emptyFilters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
|
||||||
return (
|
return (
|
||||||
<HomeView
|
<HomeView
|
||||||
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
initialSchools={{ schools: [], page: 1, page_size: 50, total: 0, total_pages: 0 }}
|
||||||
filters={{ local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] }}
|
filters={emptyFilters}
|
||||||
totalSchools={null}
|
totalSchools={null}
|
||||||
|
howItWorks={hasSearchParams ? null : <HowItWorksSection />}
|
||||||
|
editorial={hasSearchParams ? null : <EditorialSection totalSchools={null} localAuthorityCount={0} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ export const metadata: Metadata = {
|
|||||||
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
keywords: 'school rankings, top schools, best schools, KS2 rankings, KS4 rankings, school league tables',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Force dynamic rendering
|
// Dynamic via searchParams; remove force-dynamic so internal data fetches
|
||||||
export const dynamic = 'force-dynamic';
|
// can still use Next.js's per-call revalidate cache.
|
||||||
|
|
||||||
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
|
export default async function RankingsPage({ searchParams }: RankingsPageProps) {
|
||||||
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
|
const { metric: metricParam, local_authority, year: yearParam, phase: phaseParam } = await searchParams;
|
||||||
|
|
||||||
const phase = phaseParam || 'primary';
|
const phase = phaseParam || 'primary';
|
||||||
const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_high_pct');
|
||||||
const year = yearParam ? parseInt(yearParam) : undefined;
|
const year = yearParam ? parseInt(yearParam) : undefined;
|
||||||
|
|
||||||
// Fetch rankings data with error handling
|
// Fetch rankings data with error handling
|
||||||
|
|||||||
@@ -4,13 +4,48 @@
|
|||||||
* URL format: /school/138267-school-name-here
|
* URL format: /school/138267-school-name-here
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchSchoolDetails } from '@/lib/api';
|
import { fetchSchoolDetails, fetchSchools } from '@/lib/api';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
import { SchoolDetailView } from '@/components/SchoolDetailView';
|
||||||
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
import { SecondarySchoolDetailView } from '@/components/SecondarySchoolDetailView';
|
||||||
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
import { parseSchoolSlug, schoolUrl } from '@/lib/utils';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate every school for static generation at build time.
|
||||||
|
*
|
||||||
|
* Set PRERENDER_SCHOOLS=1 in the build environment to enable. When disabled
|
||||||
|
* (or when the API can't be reached), we return an empty list and the route
|
||||||
|
* falls back to ISR on first request — `dynamicParams = true` covers it.
|
||||||
|
*/
|
||||||
|
export async function generateStaticParams(): Promise<Array<{ slug: string }>> {
|
||||||
|
if (process.env.PRERENDER_SCHOOLS !== '1') return [];
|
||||||
|
|
||||||
|
const params: Array<{ slug: string }> = [];
|
||||||
|
const PAGE_SIZE = 500;
|
||||||
|
let page = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const res = await fetchSchools({ page, page_size: PAGE_SIZE });
|
||||||
|
for (const s of res.schools) {
|
||||||
|
const path = schoolUrl(s.urn, s.school_name);
|
||||||
|
const slug = path.replace('/school/', '');
|
||||||
|
params.push({ slug });
|
||||||
|
}
|
||||||
|
totalPages = res.total_pages || 1;
|
||||||
|
page += 1;
|
||||||
|
} while (page <= totalPages);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('generateStaticParams: API unreachable, falling back to on-demand ISR.', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`generateStaticParams: prebuilding ${params.length} school pages.`);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
interface SchoolPageProps {
|
interface SchoolPageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
}
|
}
|
||||||
@@ -75,8 +110,10 @@ export async function generateMetadata({ params }: SchoolPageProps): Promise<Met
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force dynamic rendering
|
// ISR: regenerate at most once a week per slug. School data updates annually,
|
||||||
export const dynamic = 'force-dynamic';
|
// so a 7-day cache is plenty and gives sub-100ms TTFB on cache hits.
|
||||||
|
export const revalidate = 604800;
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
export default async function SchoolPage({ params }: SchoolPageProps) {
|
export default async function SchoolPage({ params }: SchoolPageProps) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|||||||
@@ -0,0 +1,556 @@
|
|||||||
|
.shell {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.25rem 4rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px minmax(0, 1fr);
|
||||||
|
gap: 2.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 900px;
|
||||||
|
width: 100%;
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── In-page nav rail ────────────────────────────────── */
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 1.5rem;
|
||||||
|
padding-top: 3.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLabel {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
padding-left: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
border-left: 2px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
margin-left: -2px;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink:hover {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-weight: 700;
|
||||||
|
border-left-color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navDot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive .navDot {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Hero ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
background: rgba(45, 125, 125, 0.1);
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrowDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroTitle {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 2.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroSub {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Countdown strip ────────────────────────────────── */
|
||||||
|
|
||||||
|
.countdownSection {
|
||||||
|
padding: 0 0 2.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripHeader {
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripLabel {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownRail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.1rem 0.9rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDeadline::before { background: var(--accent-coral, #e07256); }
|
||||||
|
.chipOffer::before { background: var(--accent-teal, #2d7d7d); }
|
||||||
|
|
||||||
|
.chipUrgent {
|
||||||
|
border-color: rgba(224, 114, 86, 0.4);
|
||||||
|
background: rgba(224, 114, 86, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrackDeadline { color: var(--accent-coral, #e07256); }
|
||||||
|
.chipTrackOffer { color: var(--accent-teal, #2d7d7d); }
|
||||||
|
|
||||||
|
.chipTrackDot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDays {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDeadline .chipDays,
|
||||||
|
.chipUrgent .chipDays {
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipOffer .chipDays {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDaysUnit {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
vertical-align: bottom;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipMilestone {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDate {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Track (Secondary / Primary) ────────────────────── */
|
||||||
|
|
||||||
|
.track {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.75rem 2rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackHeaderLeft {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackKicker {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackTitle {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackSub {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackDates {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackDateRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackDateLabel {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackDateVal {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Timeline ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
list-style: none;
|
||||||
|
padding: 1.5rem 2rem 1.75rem;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDotCol {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDot {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 2px solid var(--border-color, #e5dfd5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDeadline .stepDot {
|
||||||
|
background: rgba(224, 114, 86, 0.15);
|
||||||
|
border-color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepOffer .stepDot {
|
||||||
|
background: rgba(45, 125, 125, 0.15);
|
||||||
|
border-color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepLine {
|
||||||
|
width: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 24px;
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepContent {
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:last-child .stepContent {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDate {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDeadline .stepDate { color: var(--accent-coral, #e07256); }
|
||||||
|
.stepOffer .stepDate { color: var(--accent-teal, #2d7d7d); }
|
||||||
|
|
||||||
|
.stepTitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepDeadline .stepTitle { color: var(--accent-coral-dark, #c45a3f); }
|
||||||
|
.stepOffer .stepTitle { color: var(--accent-teal, #2d7d7d); }
|
||||||
|
|
||||||
|
.stepBody {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tips ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipsHeading {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipCard {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipNumber {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--border-color, #e5dfd5);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipHeading {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipBody {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
background: var(--bg-primary, #faf8f3);
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
margin: 0 -1.25rem 1rem;
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLabel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-left: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navList::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
margin-left: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive {
|
||||||
|
background: var(--text-primary, #1a1612);
|
||||||
|
color: #fff;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLinkActive .navDot {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.heroTitle {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownRail {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.25rem 1.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackDates {
|
||||||
|
text-align: left;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tipsGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import styles from './AdmissionsView.module.css';
|
||||||
|
|
||||||
|
/* ─── Date helpers ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
function daysUntil(month: number, day: number): number {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const y = today.getFullYear();
|
||||||
|
let target = new Date(y, month - 1, day);
|
||||||
|
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||||
|
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextDate(month: number, day: number): Date {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const y = today.getFullYear();
|
||||||
|
const target = new Date(y, month - 1, day);
|
||||||
|
if (target <= today) return new Date(y + 1, month - 1, day);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(month: number, day: number): string {
|
||||||
|
return nextDate(month, day).toLocaleDateString('en-GB', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Data ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface Chip {
|
||||||
|
type: 'deadline' | 'offer';
|
||||||
|
track: string;
|
||||||
|
milestone: string;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHIPS: Chip[] = [
|
||||||
|
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
||||||
|
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||||||
|
{ type: 'deadline', track: 'Primary · Deadline', milestone: 'Primary applications close', month: 1, day: 15 },
|
||||||
|
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
date?: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
highlight?: 'deadline' | 'offer';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECONDARY_STEPS: Step[] = [
|
||||||
|
{
|
||||||
|
title: 'Check entry criteria',
|
||||||
|
body: 'Look at each school\'s admissions policy — catchment areas, faith criteria, sibling priority, and aptitude tests vary widely. Use school detail pages on SchoolCompare for admissions history.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: 'September',
|
||||||
|
title: 'Portal opens',
|
||||||
|
body: 'Your local council opens its online admissions portal. Register early to avoid last-minute technical issues. You apply through your home council even if you prefer schools in neighbouring boroughs.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '31 October',
|
||||||
|
title: 'Application deadline',
|
||||||
|
body: 'Submit your ranked list of up to six schools. Councils treat all preferences equally — list schools in the genuine order you want them, not strategically.',
|
||||||
|
highlight: 'deadline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '1 March',
|
||||||
|
title: 'National Offer Day',
|
||||||
|
body: 'Results are published online, usually from 12:01 am. You\'ll receive an email or letter with your allocated school.',
|
||||||
|
highlight: 'offer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '~15 March',
|
||||||
|
title: 'Accept or decline',
|
||||||
|
body: 'Respond by the deadline your council gives — typically around 15 March. Accepting does not prevent you from keeping a place on a waiting list for a preferred school.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Appeals',
|
||||||
|
body: 'If unsuccessful, you can appeal within 20 school days of the refusal letter. Secondary appeals consider whether prejudice to the school outweighs your case — success rates vary.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRIMARY_STEPS: Step[] = [
|
||||||
|
{
|
||||||
|
title: 'Research entry criteria',
|
||||||
|
body: 'Faith schools, language units, and distance-based catchments differ by school. Start by reading each school\'s admissions policy on their website or the council\'s website.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: 'September',
|
||||||
|
title: 'Portal opens',
|
||||||
|
body: 'Apply through your home council\'s portal, even if your preferred school is in another borough. Most councils accept applications from September.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '15 January',
|
||||||
|
title: 'Application deadline',
|
||||||
|
body: 'List up to 3–6 schools (the number varies by council) in genuine preference order. The equal preference rule means all preferences are considered before any offers are made.',
|
||||||
|
highlight: 'deadline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '16 April',
|
||||||
|
title: 'National Offer Day',
|
||||||
|
body: 'Results are published online. Reception offers are sent on 16 April (or the next working day if that falls on a weekend or bank holiday).',
|
||||||
|
highlight: 'offer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '~1 May',
|
||||||
|
title: 'Accept or decline',
|
||||||
|
body: 'Respond by your council\'s deadline, typically around 1 May. Accepting secures the place while you wait to see if a preferred school\'s waiting list moves.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Appeals',
|
||||||
|
body: 'Infant class-size appeals (Reception to Year 2) have a very narrow legal test and a low success rate. For Year 3+, appeals follow the same process as secondary.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Tip {
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIPS: Tip[] = [
|
||||||
|
{
|
||||||
|
heading: 'Equal preference rule',
|
||||||
|
body: 'Councils rank offers by your eligibility for each school, not by the order you listed them. You cannot game the system — put schools in the order you actually want them.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Late applications go to the back',
|
||||||
|
body: 'Submit before the deadline even if your child does not turn the required age until later in the year. Late applicants are only considered after all on-time applications.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Waiting lists',
|
||||||
|
body: 'You can go on waiting lists for multiple schools simultaneously. Lists are ordered by admissions criteria, not when you joined. They can move significantly over the summer.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ─── Component ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ id: 'primary', label: 'Primary' },
|
||||||
|
{ id: 'secondary', label: 'Secondary' },
|
||||||
|
{ id: 'tips', label: 'Tips' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type NavId = typeof NAV_ITEMS[number]['id'];
|
||||||
|
|
||||||
|
export function AdmissionsView() {
|
||||||
|
const [chipDays, setChipDays] = useState<(number | null)[]>(CHIPS.map(() => null));
|
||||||
|
const [activeId, setActiveId] = useState<NavId>('primary');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setChipDays(CHIPS.map(c => daysUntil(c.month, c.day)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sections = NAV_ITEMS
|
||||||
|
.map(item => document.getElementById(item.id))
|
||||||
|
.filter((el): el is HTMLElement => el !== null);
|
||||||
|
|
||||||
|
if (sections.length === 0) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter(e => e.isIntersecting)
|
||||||
|
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
||||||
|
if (visible[0]) setActiveId(visible[0].target.id as NavId);
|
||||||
|
},
|
||||||
|
{ rootMargin: '-30% 0px -55% 0px', threshold: [0, 0.25, 0.5, 0.75, 1] },
|
||||||
|
);
|
||||||
|
|
||||||
|
sections.forEach(s => observer.observe(s));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNavClick = (e: React.MouseEvent<HTMLAnchorElement>, id: NavId) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
const top = el.getBoundingClientRect().top + window.scrollY - 16;
|
||||||
|
window.scrollTo({ top, behavior: 'smooth' });
|
||||||
|
setActiveId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.shell}>
|
||||||
|
|
||||||
|
{/* In-page nav — sticky left rail on desktop, sticky top pills on mobile */}
|
||||||
|
<aside className={styles.nav} aria-label="On this page">
|
||||||
|
<div className={styles.navLabel}>On this page</div>
|
||||||
|
<ul className={styles.navList}>
|
||||||
|
{NAV_ITEMS.map(item => {
|
||||||
|
const isActive = activeId === item.id;
|
||||||
|
return (
|
||||||
|
<li key={item.id}>
|
||||||
|
<a
|
||||||
|
href={`#${item.id}`}
|
||||||
|
onClick={(e) => handleNavClick(e, item.id)}
|
||||||
|
className={[styles.navLink, isActive ? styles.navLinkActive : ''].filter(Boolean).join(' ')}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
<span className={styles.navDot} aria-hidden="true" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className={styles.page}>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className={styles.hero}>
|
||||||
|
<span className={styles.eyebrow}>
|
||||||
|
<span className={styles.eyebrowDot} aria-hidden="true" />
|
||||||
|
England · Primary & Secondary
|
||||||
|
</span>
|
||||||
|
<h1 className={styles.heroTitle}>School Admissions Guide</h1>
|
||||||
|
<p className={styles.heroSub}>
|
||||||
|
Everything parents need to know about applying for a school place in England — from opening dates to National Offer Day, with live countdowns to every key milestone.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Countdown strip */}
|
||||||
|
<section className={styles.countdownSection}>
|
||||||
|
<div className={styles.stripHeader}>
|
||||||
|
<span className={styles.stripLabel}>Days until next milestone</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.countdownRail}>
|
||||||
|
{CHIPS.map((chip, i) => {
|
||||||
|
const days = chipDays[i];
|
||||||
|
const isUrgent = days !== null && days <= 14;
|
||||||
|
const chipClass = [
|
||||||
|
styles.chip,
|
||||||
|
chip.type === 'deadline' ? styles.chipDeadline : styles.chipOffer,
|
||||||
|
isUrgent ? styles.chipUrgent : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
return (
|
||||||
|
<div key={chip.milestone} className={chipClass}>
|
||||||
|
<span className={[styles.chipTrack, chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer].join(' ')}>
|
||||||
|
<span className={styles.chipTrackDot} aria-hidden="true" />
|
||||||
|
{chip.track}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||||||
|
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||||||
|
<div className={styles.chipDate}>{days !== null ? fmtDate(chip.month, chip.day) : ''}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Primary track */}
|
||||||
|
<section id="primary" className={styles.track} style={{ scrollMarginTop: '1rem' }}>
|
||||||
|
<div className={styles.trackHeader}>
|
||||||
|
<div className={styles.trackHeaderLeft}>
|
||||||
|
<span className={styles.trackKicker}>Reception entry</span>
|
||||||
|
<h2 className={styles.trackTitle}>Primary school admissions</h2>
|
||||||
|
<p className={styles.trackSub}>For children starting Reception (Year R) in September. Applications are submitted in the autumn of the year before entry.</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.trackDates}>
|
||||||
|
<div className={styles.trackDateRow}>
|
||||||
|
<span className={styles.trackDateLabel}>Deadline</span>
|
||||||
|
<span className={styles.trackDateVal}>{fmtDate(1, 15)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.trackDateRow}>
|
||||||
|
<span className={styles.trackDateLabel}>Offer Day</span>
|
||||||
|
<span className={styles.trackDateVal}>{fmtDate(4, 16)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className={styles.timeline}>
|
||||||
|
{PRIMARY_STEPS.map((step, i) => (
|
||||||
|
<li key={i} className={[styles.step, step.highlight === 'deadline' ? styles.stepDeadline : step.highlight === 'offer' ? styles.stepOffer : ''].filter(Boolean).join(' ')}>
|
||||||
|
<div className={styles.stepDotCol}>
|
||||||
|
<div className={styles.stepDot} />
|
||||||
|
{i < PRIMARY_STEPS.length - 1 && <div className={styles.stepLine} />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.stepContent}>
|
||||||
|
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||||
|
<div className={styles.stepTitle}>{step.title}</div>
|
||||||
|
<p className={styles.stepBody}>{step.body}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Secondary track */}
|
||||||
|
<section id="secondary" className={styles.track} style={{ scrollMarginTop: '1rem' }}>
|
||||||
|
<div className={styles.trackHeader}>
|
||||||
|
<div className={styles.trackHeaderLeft}>
|
||||||
|
<span className={styles.trackKicker}>Year 7 entry</span>
|
||||||
|
<h2 className={styles.trackTitle}>Secondary school admissions</h2>
|
||||||
|
<p className={styles.trackSub}>For children starting secondary school (Year 7) in September. Applications are submitted in the autumn of Year 6.</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.trackDates}>
|
||||||
|
<div className={styles.trackDateRow}>
|
||||||
|
<span className={styles.trackDateLabel}>Deadline</span>
|
||||||
|
<span className={styles.trackDateVal}>{fmtDate(10, 31)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.trackDateRow}>
|
||||||
|
<span className={styles.trackDateLabel}>Offer Day</span>
|
||||||
|
<span className={styles.trackDateVal}>{fmtDate(3, 1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className={styles.timeline}>
|
||||||
|
{SECONDARY_STEPS.map((step, i) => (
|
||||||
|
<li key={i} className={[styles.step, step.highlight === 'deadline' ? styles.stepDeadline : step.highlight === 'offer' ? styles.stepOffer : ''].filter(Boolean).join(' ')}>
|
||||||
|
<div className={styles.stepDotCol}>
|
||||||
|
<div className={styles.stepDot} />
|
||||||
|
{i < SECONDARY_STEPS.length - 1 && <div className={styles.stepLine} />}
|
||||||
|
</div>
|
||||||
|
<div className={styles.stepContent}>
|
||||||
|
{step.date && <div className={styles.stepDate}>{step.date}</div>}
|
||||||
|
<div className={styles.stepTitle}>{step.title}</div>
|
||||||
|
<p className={styles.stepBody}>{step.body}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<section id="tips" className={styles.tips} style={{ scrollMarginTop: '1rem' }}>
|
||||||
|
<h2 className={styles.tipsHeading}>Three things most parents get wrong</h2>
|
||||||
|
<div className={styles.tipsGrid}>
|
||||||
|
{TIPS.map((tip, i) => (
|
||||||
|
<div key={i} className={styles.tipCard}>
|
||||||
|
<div className={styles.tipNumber}>{String(i + 1).padStart(2, '0')}</div>
|
||||||
|
<h3 className={styles.tipHeading}>{tip.heading}</h3>
|
||||||
|
<p className={styles.tipBody}>{tip.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,31 +6,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import {
|
import { ChartOptions } from 'chart.js';
|
||||||
Chart as ChartJS,
|
import '@/lib/chartSetup';
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ChartOptions,
|
|
||||||
} from 'chart.js';
|
|
||||||
import type { ComparisonData } from '@/lib/types';
|
import type { ComparisonData } from '@/lib/types';
|
||||||
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
|
import { CHART_COLORS, formatAcademicYear } from '@/lib/utils';
|
||||||
|
|
||||||
// Register Chart.js components
|
|
||||||
ChartJS.register(
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend
|
|
||||||
);
|
|
||||||
|
|
||||||
interface ComparisonChartProps {
|
interface ComparisonChartProps {
|
||||||
comparisonData: Record<string, ComparisonData>;
|
comparisonData: Record<string, ComparisonData>;
|
||||||
metric: string;
|
metric: string;
|
||||||
|
|||||||
@@ -167,20 +167,12 @@
|
|||||||
background: var(--accent-coral-dark, #c9614a);
|
background: var(--accent-coral-dark, #c9614a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hidden on phones — the bottom tab bar's Compare badge already
|
||||||
|
communicates count + destination, so the toast becomes redundant
|
||||||
|
chrome that costs ~70px of permanent vertical space. Per-school
|
||||||
|
removal still lives on the /compare page itself. */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.toastContainer {
|
.toastContainer {
|
||||||
bottom: 1.5rem;
|
display: none;
|
||||||
width: calc(100% - 3rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toastContent {
|
|
||||||
gap: 0;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toastActions {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,6 +306,15 @@
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Right-edge fade so phone users see the comparison table scrolls.
|
||||||
|
Otherwise the wider-than-viewport table silently clips. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.tableWrapper {
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.comparisonTable {
|
.comparisonTable {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
|
|||||||
@@ -5,16 +5,22 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
import { ComparisonChart } from './ComparisonChart';
|
|
||||||
|
const ComparisonChart = dynamic(
|
||||||
|
() => import('./ComparisonChart').then((m) => m.ComparisonChart),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
import { SchoolSearchModal } from './SchoolSearchModal';
|
import { SchoolSearchModal } from './SchoolSearchModal';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import { LoadingSkeleton } from './LoadingSkeleton';
|
import { LoadingSkeleton } from './LoadingSkeleton';
|
||||||
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
||||||
import { fetchComparison } from '@/lib/api';
|
import { fetchComparison } from '@/lib/api';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './ComparisonView.module.css';
|
import styles from './ComparisonView.module.css';
|
||||||
|
|
||||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||||||
@@ -59,6 +65,9 @@ export function ComparisonView({
|
|||||||
const [comparisonData, setComparisonData] = useState(initialData);
|
const [comparisonData, setComparisonData] = useState(initialData);
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
const [shareConfirm, setShareConfirm] = useState(false);
|
||||||
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
const [comparePhase, setComparePhase] = useState<'primary' | 'secondary'>('primary');
|
||||||
|
// Tracks whether the user has explicitly clicked a phase tab.
|
||||||
|
// While true, auto-phase detection is suppressed so manual selections aren't overridden.
|
||||||
|
const phaseLockedByUser = useRef(false);
|
||||||
|
|
||||||
// Seed context from initialData when component mounts and localStorage is empty
|
// Seed context from initialData when component mounts and localStorage is empty
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -118,34 +127,83 @@ export function ComparisonView({
|
|||||||
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
|
const primarySchools = selectedSchools.filter(s => classifySchool(s) === 'primary');
|
||||||
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
|
const secondarySchools = selectedSchools.filter(s => classifySchool(s) === 'secondary');
|
||||||
|
|
||||||
// Auto-select tab with more schools
|
// Auto-select tab with more schools and sync the metric to match the detected phase.
|
||||||
|
// This fixes the case where the URL carries a primary metric (e.g. rwm_expected_pct)
|
||||||
|
// but the shortlisted schools are secondary — the phase tab switches but the metric
|
||||||
|
// needs to follow, otherwise all secondary cards show "–" for a primary-only field.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (comparisonData && selectedSchools.length > 0) {
|
if (!comparisonData || selectedSchools.length === 0) return;
|
||||||
if (secondarySchools.length > primarySchools.length) {
|
if (phaseLockedByUser.current) return;
|
||||||
setComparePhase('secondary');
|
const newPhase = secondarySchools.length > primarySchools.length ? 'secondary' : 'primary';
|
||||||
} else {
|
setComparePhase(newPhase);
|
||||||
setComparePhase('primary');
|
// Only reset the metric when it doesn't belong to the newly detected phase.
|
||||||
}
|
// This preserves a correct metric that came from the URL (e.g. metric=attainment_8_score).
|
||||||
|
const phaseCategories = newPhase === 'secondary' ? SECONDARY_CATEGORIES : PRIMARY_CATEGORIES;
|
||||||
|
const metricFitsPhase = metrics.some(
|
||||||
|
(m) => m.key === selectedMetric && phaseCategories.includes(m.category)
|
||||||
|
);
|
||||||
|
if (!metricFitsPhase) {
|
||||||
|
setSelectedMetric(newPhase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct');
|
||||||
}
|
}
|
||||||
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [comparisonData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
const handlePhaseChange = (phase: 'primary' | 'secondary') => {
|
||||||
|
phaseLockedByUser.current = true;
|
||||||
setComparePhase(phase);
|
setComparePhase(phase);
|
||||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
||||||
setSelectedMetric(defaultMetric);
|
setSelectedMetric(defaultMetric);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// compare_viewed: fire once after the page has its first selection.
|
||||||
|
// We watch `selectedSchools.length` going from 0 → ≥1 so the event is
|
||||||
|
// sent only when there's actual content to view, not for empty arrivals.
|
||||||
|
const compareViewedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (compareViewedRef.current) return;
|
||||||
|
if (selectedSchools.length === 0) return;
|
||||||
|
compareViewedRef.current = true;
|
||||||
|
const primaryCount = selectedSchools.filter(s => s.phase?.toLowerCase().includes('primary')).length;
|
||||||
|
const secondaryCount = selectedSchools.length - primaryCount;
|
||||||
|
const phaseMix = primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
|
||||||
|
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
|
||||||
|
}, [selectedSchools]);
|
||||||
|
|
||||||
const handleMetricChange = (metric: string) => {
|
const handleMetricChange = (metric: string) => {
|
||||||
|
track('compare_metric_changed', { metric, phase: comparePhase });
|
||||||
setSelectedMetric(metric);
|
setSelectedMetric(metric);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveSchool = (urn: number) => {
|
const handleRemoveSchool = (urn: number) => {
|
||||||
removeSchool(urn);
|
removeSchool(urn);
|
||||||
|
track('compare_school_removed', { urn, from: 'compare' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
|
const url = window.location.href;
|
||||||
|
const count = selectedSchools.length;
|
||||||
|
const shareData = {
|
||||||
|
title: 'School comparison · SchoolCompare',
|
||||||
|
text: count > 0
|
||||||
|
? `Comparing ${count} school${count === 1 ? '' : 's'} on SchoolCompare`
|
||||||
|
: 'SchoolCompare',
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
// Prefer the native share sheet on platforms that support it (iOS / Android).
|
||||||
|
// canShare is feature-detected because Safari iOS exposes share() but
|
||||||
|
// some configurations refuse the payload.
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(window.location.href);
|
await navigator.share(shareData);
|
||||||
|
track('compare_shared', { method: 'native', school_count: count });
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
// User cancelled — bail silently. Any other error falls through to clipboard.
|
||||||
|
if ((err as DOMException)?.name === 'AbortError') return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
track('compare_shared', { method: 'clipboard', school_count: count });
|
||||||
setShareConfirm(true);
|
setShareConfirm(true);
|
||||||
setTimeout(() => setShareConfirm(false), 2000);
|
setTimeout(() => setShareConfirm(false), 2000);
|
||||||
} catch { /* fallback: do nothing */ }
|
} catch { /* fallback: do nothing */ }
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Server component: pure markup, no client state.
|
||||||
|
|
||||||
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
|
interface EditorialSectionProps {
|
||||||
|
totalSchools: number | null;
|
||||||
|
localAuthorityCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorialSection({ totalSchools, localAuthorityCount }: EditorialSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className={styles.editorial}>
|
||||||
|
<div className={styles.editorialGrid}>
|
||||||
|
<div className={styles.editorialText}>
|
||||||
|
<div className={styles.editorialKicker}>About school data</div>
|
||||||
|
<h2 className={styles.editorialHeading}>Making UK school performance data actually readable</h2>
|
||||||
|
<p>
|
||||||
|
School performance data in England is rich but fragmented. The Department for Education publishes
|
||||||
|
Key Stage 2 SATs, GCSE attainment, Ofsted outcomes, progress scores, admissions figures and
|
||||||
|
demographics — each in its own table, each with its own jargon.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
SchoolCompare brings it all into one place. Every school page shows performance against the national
|
||||||
|
average, explains what the numbers mean, and lets you shortlist schools side by side. Built for
|
||||||
|
parents, governors, journalists, and anyone who wants to understand a school without reading a
|
||||||
|
40-page inspection report.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factbox}>
|
||||||
|
<h3 className={styles.factboxHeading}>Coverage at a glance</h3>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Schools covered</span>
|
||||||
|
<span className={styles.factVal}>{totalSchools ? `${totalSchools.toLocaleString()}` : '24,000+'}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Local authorities</span>
|
||||||
|
<span className={styles.factVal}>{localAuthorityCount > 0 ? localAuthorityCount : 152}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Phases</span>
|
||||||
|
<span className={styles.factVal}>Primary & Secondary</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Latest results year</span>
|
||||||
|
<span className={styles.factVal}>2024/25</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Historical data</span>
|
||||||
|
<span className={styles.factVal}>2016–2025</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.factRow}>
|
||||||
|
<span className={styles.factKey}>Metrics per school</span>
|
||||||
|
<span className={styles.factVal}>40+</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -261,6 +261,21 @@
|
|||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* When filters are applied, promote the toggle to a coral pill so users
|
||||||
|
can see at a glance that the result list is being narrowed. */
|
||||||
|
.advancedToggleActive {
|
||||||
|
border-color: var(--accent-coral, #e07256);
|
||||||
|
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.12));
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advancedToggleActive:hover {
|
||||||
|
border-color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
background: var(--accent-coral-bg, rgba(224, 114, 86, 0.18));
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
}
|
||||||
|
|
||||||
.chevronDown,
|
.chevronDown,
|
||||||
.chevronUp {
|
.chevronUp {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
import { isValidPostcode } from "@/lib/utils";
|
import { isValidPostcode } from "@/lib/utils";
|
||||||
|
import { track } from "@/lib/analytics";
|
||||||
import type { Filters, ResultFilters } from "@/lib/types";
|
import type { Filters, ResultFilters } from "@/lib/types";
|
||||||
import styles from "./FilterBar.module.css";
|
import styles from "./FilterBar.module.css";
|
||||||
|
|
||||||
@@ -93,14 +94,36 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValidPostcode(omniValue)) {
|
const isPostcode = isValidPostcode(omniValue);
|
||||||
|
const cleaned = omniValue.trim();
|
||||||
|
|
||||||
|
// Build a comma-separated active-filter list so a single search event
|
||||||
|
// captures the whole intent (vs firing N events as filters are picked).
|
||||||
|
const filters_active = [
|
||||||
|
currentPhase && `phase=${currentPhase}`,
|
||||||
|
currentLA && `la=${currentLA}`,
|
||||||
|
currentType && `type=${currentType}`,
|
||||||
|
currentGender && `gender=${currentGender}`,
|
||||||
|
currentAdmissionsPolicy && `admissions=${currentAdmissionsPolicy}`,
|
||||||
|
currentHasSixthForm && `sixth_form=${currentHasSixthForm}`,
|
||||||
|
].filter(Boolean).join(',');
|
||||||
|
|
||||||
|
track('search_submitted', {
|
||||||
|
query: isPostcode ? cleaned.toUpperCase() : cleaned.toLowerCase(),
|
||||||
|
via: 'input',
|
||||||
|
has_postcode: isPostcode,
|
||||||
|
filters_active,
|
||||||
|
filters_count: filters_active ? filters_active.split(',').length : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isPostcode) {
|
||||||
updateURL({
|
updateURL({
|
||||||
postcode: omniValue.trim().toUpperCase(),
|
postcode: cleaned.toUpperCase(),
|
||||||
radius: currentRadius || "1",
|
radius: currentRadius || "1",
|
||||||
search: "",
|
search: "",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateURL({ search: omniValue.trim(), postcode: "", radius: "" });
|
updateURL({ search: cleaned, postcode: "", radius: "" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -199,10 +222,11 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.advancedToggle}
|
className={`${styles.advancedToggle}${hasActiveDropdownFilters ? ` ${styles.advancedToggleActive}` : ''}`}
|
||||||
onClick={() => setFiltersOpen((v) => !v)}
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
|
aria-expanded={filtersOpen}
|
||||||
>
|
>
|
||||||
Advanced
|
{hasActiveDropdownFilters ? 'Filters' : 'Advanced'}
|
||||||
{hasActiveDropdownFilters
|
{hasActiveDropdownFilters
|
||||||
? ` (${activeDropdownFilters.length})`
|
? ` (${activeDropdownFilters.length})`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2fr 1fr;
|
grid-template-columns: 1.5fr 1fr 1fr;
|
||||||
gap: 3rem;
|
gap: 2rem;
|
||||||
margin-bottom: 3rem;
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,33 +15,47 @@ export function Footer() {
|
|||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h3 className={styles.title}>SchoolCompare</h3>
|
<h3 className={styles.title}>SchoolCompare</h3>
|
||||||
<p className={styles.description}>
|
<p className={styles.description}>
|
||||||
Compare primary and secondary schools across England.
|
Compare primary and secondary schools across England. Free, independent, built on public data.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h4 className={styles.sectionTitle}>Product</h4>
|
||||||
|
<ul className={styles.links}>
|
||||||
|
<li><a href="/" className={styles.link}>Search schools</a></li>
|
||||||
|
<li><a href="/rankings" className={styles.link}>Rankings</a></li>
|
||||||
|
<li><a href="/compare" className={styles.link}>Compare shortlist</a></li>
|
||||||
|
<li><a href="/admissions" className={styles.link}>Admissions guide</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<h4 className={styles.sectionTitle}>Resources</h4>
|
<h4 className={styles.sectionTitle}>Resources</h4>
|
||||||
<ul className={styles.links}>
|
<ul className={styles.links}>
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://www.gov.uk/government/organisations/department-for-education"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={styles.link}
|
|
||||||
>
|
|
||||||
Department for Education
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="https://www.gov.uk/school-performance-tables"
|
href="https://www.gov.uk/school-performance-tables"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="dfe"
|
||||||
>
|
>
|
||||||
School Performance Tables
|
School Performance Tables
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://reports.ofsted.gov.uk/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.link}
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="ofsted"
|
||||||
|
>
|
||||||
|
Ofsted reports
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,32 +4,90 @@
|
|||||||
|
|
||||||
.heroSection {
|
.heroSection {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 1.5rem;
|
||||||
padding-top: 1rem;
|
padding-top: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroEyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
background: rgba(45, 125, 125, 0.1);
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroEyebrowDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroTitle {
|
.heroTitle {
|
||||||
font-size: 2.5rem;
|
font-size: 3rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.85rem;
|
||||||
line-height: 1.2;
|
line-height: 1.08;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
max-width: 840px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroEmph {
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.heroDescription {
|
.heroDescription {
|
||||||
font-size: 1.1rem;
|
font-size: 1.05rem;
|
||||||
color: var(--text-secondary, #5c564d);
|
color: var(--text-secondary, #5c564d);
|
||||||
margin: 0 auto;
|
margin: 0 auto 0.5rem;
|
||||||
max-width: 600px;
|
max-width: 680px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroDescription strong {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.heroSection {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
.heroTitle {
|
.heroTitle {
|
||||||
font-size: 1.75rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
.heroDescription {
|
.heroDescription {
|
||||||
font-size: 1rem;
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Above the fold on phones, every line costs. Drop the eyebrow tag and the
|
||||||
|
long descriptive paragraph — the h1 already names the product, and the
|
||||||
|
search input is the primary action users came to perform. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.heroSection {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.heroEyebrow,
|
||||||
|
.heroDescription {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.heroTitle {
|
||||||
|
font-size: 1.65rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +142,7 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 340px;
|
grid-template-columns: 1fr 340px;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
height: calc(100vh - 280px);
|
height: calc(100dvh - 280px);
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
max-height: 800px;
|
max-height: 800px;
|
||||||
}
|
}
|
||||||
@@ -409,7 +467,7 @@
|
|||||||
.mapViewContainer {
|
.mapViewContainer {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
height: calc(100vh - 280px);
|
height: calc(100dvh - 280px);
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,26 +481,67 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.discoverySection {
|
.discoverySection {
|
||||||
padding: 2rem var(--page-padding, 2rem);
|
padding: 0.5rem 0 0.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discoveryCount {
|
.nearMeRow {
|
||||||
font-size: 1.1rem;
|
display: flex;
|
||||||
color: var(--text-secondary);
|
flex-direction: column;
|
||||||
margin-bottom: 0.5rem;
|
align-items: center;
|
||||||
}
|
gap: 0.5rem;
|
||||||
|
|
||||||
.discoveryCount strong {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discoveryHints {
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nearMeBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.375rem;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, transform 0.15s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtn:hover:not(:disabled) {
|
||||||
|
background: #235f5f;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nearMeBtnSpinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: nearMeSpin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nearMeSpin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.geoError {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 340px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.quickSearches {
|
.quickSearches {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -473,6 +572,568 @@
|
|||||||
border-color: var(--accent-coral);
|
border-color: var(--accent-coral);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.exploringRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploringLabel {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploringChips {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploringChip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.95rem;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exploringChip:hover {
|
||||||
|
border-color: var(--accent-coral, #e07256);
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.55;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── How it works section ─────────────────────────────── */
|
||||||
|
|
||||||
|
.howItWorks {
|
||||||
|
padding: 3rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwHeading {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwSub {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwCard {
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwVisual {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
min-height: 180px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwPhaseBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwPhaseLabel {
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwPhaseLabel strong {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwCardBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwStep {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwTitle {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiwDesc {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.45;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mini cascade (performance card) */
|
||||||
|
.miniCascade {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniCascadeCol {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniSubj {
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniRowHead {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.48rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniRowHead strong {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniTrack {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(45, 125, 125, 0.08);
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniBarExp {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent-teal-light, #3a9e9e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniBarExc {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniNatPill {
|
||||||
|
position: absolute;
|
||||||
|
top: -9px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.05rem 0.2rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
z-index: 2;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Attainment 8 mini bar */
|
||||||
|
.att8Row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8BarWrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8BarHead {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8Track {
|
||||||
|
height: 7px;
|
||||||
|
background: rgba(45, 125, 125, 0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8Fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8NatLine {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 1.5px;
|
||||||
|
background: rgba(224, 114, 86, 0.6);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8Score {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8Value {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8Delta {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ofsted preview */
|
||||||
|
.ofstedPreview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedHead {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 1.5px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedBullet {
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
height: 1em;
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedTitle {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedBadge {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(45, 125, 125, 0.12);
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedVerdict {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedVerdict em {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ofstedMeta {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compare preview */
|
||||||
|
.comparePreview {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareHead {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr repeat(2, 1fr);
|
||||||
|
background: rgba(45, 125, 125, 0.1);
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareHeadCell {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareHeadLabel {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.48rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr repeat(2, 1fr);
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareRowLabel {
|
||||||
|
font-size: 0.55rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareRowVal {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareRowValHi {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compareFoot {
|
||||||
|
font-size: 0.52rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hiwGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.hiwHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.hiwHeading {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On phones, hide the scaled-down preview visuals (text becomes illegible
|
||||||
|
at this size) and let the explanatory text carry each card. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.hiwVisual {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.hiwCard {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
.hiwTitle {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.hiwDesc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Editorial section ───────────────────────────────── */
|
||||||
|
|
||||||
|
.editorial {
|
||||||
|
padding: 2rem 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorialGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1.75rem;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorialText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorialKicker {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorialHeading {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorialText p {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factbox {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factboxHeading {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factRow:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.factKey {
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.factVal {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.editorialGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 1.25rem;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.resultsHeader {
|
.resultsHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -547,3 +1208,188 @@
|
|||||||
.loadMoreButton {
|
.loadMoreButton {
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
Admissions Countdown Strip
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
.admissionsStrip {
|
||||||
|
padding: 1.5rem 0 2rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripLabel {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripCta {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripCta:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownRail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChip {
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.1rem 0.9rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(26, 22, 18, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChip::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChipDeadline::before {
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChipOffer::before {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChipUrgent {
|
||||||
|
border-color: rgba(224, 114, 86, 0.4);
|
||||||
|
background: rgba(224, 114, 86, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrackDeadline {
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrackOffer {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipTrackDot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDays {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
|
font-size: 2.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChipDeadline .chipDays,
|
||||||
|
.countdownChipUrgent .chipDays {
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdownChipOffer .chipDays {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDaysUnit {
|
||||||
|
font-family: 'DM Sans', sans-serif;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-left: 0.2rem;
|
||||||
|
vertical-align: bottom;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipMilestone {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipDate {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.countdownRail {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On phones the 2×2 grid cramped each chip so badly the "Secondary ·
|
||||||
|
Deadline" track label dropped to 9.6px. Switch to a horizontal
|
||||||
|
snap-scroller — each card is full-width-ish and stays readable,
|
||||||
|
and the rightmost card peeks past the edge to signal there's more. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.countdownRail {
|
||||||
|
display: flex;
|
||||||
|
grid-template-columns: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scrollbar-width: none;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-right: 1.25rem;
|
||||||
|
margin-inline: -1rem;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||||
|
}
|
||||||
|
.countdownRail::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.countdownChip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 78%;
|
||||||
|
min-width: 220px;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
.chipTrack {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||||
import { FilterBar } from './FilterBar';
|
import { FilterBar } from './FilterBar';
|
||||||
import { SchoolRow } from './SchoolRow';
|
import { SchoolRow } from './SchoolRow';
|
||||||
@@ -16,15 +16,54 @@ import { useComparisonContext } from '@/context/ComparisonContext';
|
|||||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||||
import { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
|
import { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './HomeView.module.css';
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
interface HomeViewProps {
|
interface HomeViewProps {
|
||||||
initialSchools: SchoolsResponse;
|
initialSchools: SchoolsResponse;
|
||||||
filters: Filters;
|
filters: Filters;
|
||||||
totalSchools?: number | null;
|
totalSchools?: number | null;
|
||||||
|
// Slot props for static markup the server pre-renders so it stays out of
|
||||||
|
// the client bundle. Server passes null when the landing sections shouldn't
|
||||||
|
// show (e.g. an active search).
|
||||||
|
howItWorks?: React.ReactNode;
|
||||||
|
editorial?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProps) {
|
function daysUntil(month: number, day: number): number {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const y = today.getFullYear();
|
||||||
|
let target = new Date(y, month - 1, day);
|
||||||
|
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||||
|
return Math.round((target.getTime() - today.getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdownDate(month: number, day: number): string {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const y = today.getFullYear();
|
||||||
|
let target = new Date(y, month - 1, day);
|
||||||
|
if (target < today) target = new Date(y + 1, month - 1, day);
|
||||||
|
return target.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountdownChipData {
|
||||||
|
type: 'deadline' | 'offer';
|
||||||
|
track: string;
|
||||||
|
milestone: string;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMISSIONS_CHIPS: CountdownChipData[] = [
|
||||||
|
{ type: 'offer', track: 'Primary · Offer Day', milestone: 'Primary National Offer Day', month: 4, day: 16 },
|
||||||
|
{ type: 'deadline', track: 'Secondary · Deadline', milestone: 'Secondary applications close', month: 10, day: 31 },
|
||||||
|
{ type: 'deadline', track: 'Primary · Deadline', milestone: 'Primary applications close', month: 1, day: 15 },
|
||||||
|
{ type: 'offer', track: 'Secondary · Offer Day', milestone: 'Secondary National Offer Day', month: 3, day: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function HomeView({ initialSchools, filters, totalSchools, howItWorks, editorial }: HomeViewProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -41,6 +80,12 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
const [mapSchools, setMapSchools] = useState<School[]>([]);
|
||||||
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
const [isLoadingMap, setIsLoadingMap] = useState(false);
|
||||||
const prevSearchParamsRef = useRef(searchParams.toString());
|
const prevSearchParamsRef = useRef(searchParams.toString());
|
||||||
|
const mapParamsRef = useRef<string>('');
|
||||||
|
const [geoState, setGeoState] = useState<'idle' | 'requesting' | 'error'>('idle');
|
||||||
|
const [geoError, setGeoError] = useState<string | null>(null);
|
||||||
|
const [sortedChips, setSortedChips] = useState<Array<{ chip: CountdownChipData; days: number | null }>>(
|
||||||
|
ADMISSIONS_CHIPS.map(c => ({ chip: c, days: null }))
|
||||||
|
);
|
||||||
|
|
||||||
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
const hasSearch = searchParams.get('search') || searchParams.get('postcode');
|
||||||
const isLocationSearch = !!searchParams.get('postcode');
|
const isLocationSearch = !!searchParams.get('postcode');
|
||||||
@@ -52,11 +97,12 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
|| (!currentPhase && secondaryCount > primaryCount);
|
|| (!currentPhase && secondaryCount > primaryCount);
|
||||||
const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase;
|
const isMixedView = primaryCount > 0 && secondaryCount > 0 && !currentPhase;
|
||||||
|
|
||||||
// Reset pagination state when search params change
|
// Reset pagination and map cache when search params change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newParamsStr = searchParams.toString();
|
const newParamsStr = searchParams.toString();
|
||||||
if (newParamsStr !== prevSearchParamsRef.current) {
|
if (newParamsStr !== prevSearchParamsRef.current) {
|
||||||
prevSearchParamsRef.current = newParamsStr;
|
prevSearchParamsRef.current = newParamsStr;
|
||||||
|
mapParamsRef.current = ''; // allow map to re-fetch for new search
|
||||||
setAllSchools(initialSchools.schools);
|
setAllSchools(initialSchools.schools);
|
||||||
setCurrentPage(initialSchools.page);
|
setCurrentPage(initialSchools.page);
|
||||||
setHasMore(initialSchools.total_pages > 1);
|
setHasMore(initialSchools.total_pages > 1);
|
||||||
@@ -69,9 +115,13 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
setSelectedMapSchool(null);
|
setSelectedMapSchool(null);
|
||||||
}, [resultsView, searchParams]);
|
}, [resultsView, searchParams]);
|
||||||
|
|
||||||
// Fetch all schools within radius when map view is active
|
// Fetch all schools within radius when map view is active.
|
||||||
|
// Guard with a ref so toggling back to map never re-fetches the same params.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resultsView !== 'map' || !isLocationSearch) return;
|
if (resultsView !== 'map' || !isLocationSearch) return;
|
||||||
|
const paramsKey = searchParams.toString();
|
||||||
|
if (paramsKey === mapParamsRef.current) return;
|
||||||
|
mapParamsRef.current = paramsKey;
|
||||||
setIsLoadingMap(true);
|
setIsLoadingMap(true);
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, any> = {};
|
||||||
searchParams.forEach((value, key) => { params[key] = value; });
|
searchParams.forEach((value, key) => { params[key] = value; });
|
||||||
@@ -98,8 +148,16 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Compute admissions countdown days client-side and sort soonest-first to avoid SSR mismatch
|
||||||
|
useEffect(() => {
|
||||||
|
const withDays = ADMISSIONS_CHIPS.map(c => ({ chip: c, days: daysUntil(c.month, c.day) }));
|
||||||
|
withDays.sort((a, b) => (a.days ?? Infinity) - (b.days ?? Infinity));
|
||||||
|
setSortedChips(withDays);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLoadMore = async () => {
|
const handleLoadMore = async () => {
|
||||||
if (isLoadingMore || !hasMore) return;
|
if (isLoadingMore || !hasMore) return;
|
||||||
|
track('results_load_more', { next_page: currentPage + 1 });
|
||||||
setIsLoadingMore(true);
|
setIsLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, any> = {};
|
||||||
@@ -117,6 +175,54 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNearMe = useCallback(() => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
track('near_me_used', { outcome: 'unsupported' });
|
||||||
|
setGeoState('error');
|
||||||
|
setGeoError('Geolocation is not supported by your browser. Enter a postcode instead.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGeoState('requesting');
|
||||||
|
setGeoError(null);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
async (position) => {
|
||||||
|
const { latitude, longitude } = position.coords;
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.postcodes.io/postcodes?lon=${longitude}&lat=${latitude}&limit=1`
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.result && data.result.length > 0) {
|
||||||
|
const postcode = data.result[0].postcode as string;
|
||||||
|
setGeoState('idle');
|
||||||
|
track('near_me_used', { outcome: 'granted' });
|
||||||
|
track('search_submitted', { query: postcode, via: 'near_me', has_postcode: true, filters_active: '', filters_count: 0 });
|
||||||
|
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`);
|
||||||
|
} else {
|
||||||
|
track('near_me_used', { outcome: 'no_postcode' });
|
||||||
|
setGeoState('error');
|
||||||
|
setGeoError('No postcode found near your location. Try entering one above.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
track('near_me_used', { outcome: 'lookup_error' });
|
||||||
|
setGeoState('error');
|
||||||
|
setGeoError('Could not look up your location. Please try again.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
setGeoState('error');
|
||||||
|
if (err.code === err.PERMISSION_DENIED) {
|
||||||
|
track('near_me_used', { outcome: 'denied' });
|
||||||
|
setGeoError('Location access was denied. Enter a postcode above to find nearby schools.');
|
||||||
|
} else {
|
||||||
|
track('near_me_used', { outcome: 'error' });
|
||||||
|
setGeoError('Could not get your location. Please try again or enter a postcode.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 10000, maximumAge: 60000 }
|
||||||
|
);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
const sortedSchools = [...allSchools].sort((a, b) => {
|
const sortedSchools = [...allSchools].sort((a, b) => {
|
||||||
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
|
if (sortOrder === 'rwm_desc') return (b.rwm_expected_pct ?? -Infinity) - (a.rwm_expected_pct ?? -Infinity);
|
||||||
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
|
if (sortOrder === 'rwm_asc') return (a.rwm_expected_pct ?? Infinity) - (b.rwm_expected_pct ?? Infinity);
|
||||||
@@ -127,13 +233,42 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Empty-results sentinel: track when a search returns nothing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSearchActive) return;
|
||||||
|
if (initialSchools.total !== 0) return;
|
||||||
|
track('empty_results', {
|
||||||
|
query_length: (searchParams.get('search') || '').length,
|
||||||
|
has_postcode: !!searchParams.get('postcode'),
|
||||||
|
});
|
||||||
|
}, [initialSchools.total, isSearchActive, searchParams]);
|
||||||
|
|
||||||
|
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
|
||||||
|
// split which surface drives compare adds.
|
||||||
|
const addSchoolFromSearch = useCallback((school: School) => {
|
||||||
|
addSchool(school);
|
||||||
|
track('compare_school_added', {
|
||||||
|
urn: school.urn,
|
||||||
|
from: 'search',
|
||||||
|
selection_count_after: selectedSchools.length + 1,
|
||||||
|
});
|
||||||
|
}, [addSchool, selectedSchools.length]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.homeView}>
|
<div className={styles.homeView}>
|
||||||
{/* Combined Hero + Search and Filters */}
|
{/* Combined Hero + Search and Filters */}
|
||||||
{!isSearchActive && (
|
{!isSearchActive && (
|
||||||
<div className={styles.heroSection}>
|
<div className={styles.heroSection}>
|
||||||
<h1 className={styles.heroTitle}>Find Local Schools</h1>
|
<span className={styles.heroEyebrow}>
|
||||||
<p className={styles.heroDescription}>Compare school results (SATs and GCSE), for thousands of schools across England</p>
|
<span className={styles.heroEyebrowDot} aria-hidden="true" />
|
||||||
|
Updated with 2024/25 results
|
||||||
|
</span>
|
||||||
|
<h1 className={styles.heroTitle}>
|
||||||
|
Every school in England, <em className={styles.heroEmph}>compared.</em>
|
||||||
|
</h1>
|
||||||
|
<p className={styles.heroDescription}>
|
||||||
|
<strong>24,000+ primary and secondary schools</strong> with Key Stage 2 SATs, GCSE results, Ofsted grades, progress scores and admissions data — side by side, in one place.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -146,17 +281,104 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
{/* Discovery section shown on landing page before any search */}
|
{/* Discovery section shown on landing page before any search */}
|
||||||
{!isSearchActive && initialSchools.schools.length === 0 && (
|
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||||
<div className={styles.discoverySection}>
|
<div className={styles.discoverySection}>
|
||||||
{totalSchools && <p className={styles.discoveryCount}><strong>{totalSchools.toLocaleString()}+</strong> primary and secondary schools across England</p>}
|
<div className={styles.nearMeRow}>
|
||||||
<p className={styles.discoveryHints}>Try searching for a school name, or enter a postcode to find schools near you.</p>
|
<button
|
||||||
<div className={styles.quickSearches}>
|
className={styles.nearMeBtn}
|
||||||
<span className={styles.quickSearchLabel}>Quick searches:</span>
|
onClick={handleNearMe}
|
||||||
{['Manchester', 'Bristol', 'Leeds', 'Birmingham'].map(city => (
|
disabled={geoState === 'requesting'}
|
||||||
<a key={city} href={`/?search=${city}`} className={styles.quickSearchChip}>{city}</a>
|
>
|
||||||
))}
|
{geoState === 'requesting' ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.nearMeBtnSpinner} aria-hidden="true" />
|
||||||
|
Locating you…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
|
||||||
|
<path d="M12 2a7 7 0 0 1 7 7c0 5.25-7 13-7 13S5 14.25 5 9a7 7 0 0 1 7-7z"/>
|
||||||
|
<circle cx="12" cy="9" r="2.5"/>
|
||||||
|
</svg>
|
||||||
|
Schools near me
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{geoError && <p className={styles.geoError} role="alert">{geoError}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Admissions countdown strip — only on landing page */}
|
||||||
|
{!isSearchActive && (
|
||||||
|
<section className={styles.admissionsStrip}>
|
||||||
|
<div className={styles.stripHeader}>
|
||||||
|
<span className={styles.stripLabel}>Key admissions deadlines</span>
|
||||||
|
<a href="/admissions" className={styles.stripCta}>Full admissions guide →</a>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={styles.countdownRail}
|
||||||
|
style={{
|
||||||
|
opacity: sortedChips[0]?.days !== null ? 1 : 0,
|
||||||
|
transition: 'opacity 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sortedChips.map(({ chip, days }) => {
|
||||||
|
const isUrgent = days !== null && days <= 14;
|
||||||
|
const chipClass = [
|
||||||
|
styles.countdownChip,
|
||||||
|
chip.type === 'deadline' ? styles.countdownChipDeadline : styles.countdownChipOffer,
|
||||||
|
isUrgent ? styles.countdownChipUrgent : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
const trackClass = [
|
||||||
|
styles.chipTrack,
|
||||||
|
chip.type === 'deadline' ? styles.chipTrackDeadline : styles.chipTrackOffer,
|
||||||
|
].join(' ');
|
||||||
|
return (
|
||||||
|
<div key={chip.milestone} className={chipClass}>
|
||||||
|
<span className={trackClass}>
|
||||||
|
<span className={styles.chipTrackDot} aria-hidden="true" />
|
||||||
|
{chip.track}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<span className={styles.chipDays}>{days === 0 ? 'Today' : (days ?? '—')}</span>
|
||||||
|
{days !== null && days > 0 && <span className={styles.chipDaysUnit}>days</span>}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chipMilestone}>{chip.milestone}</div>
|
||||||
|
<div className={styles.chipDate}>
|
||||||
|
{days !== null ? formatCountdownDate(chip.month, chip.day) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secondary discovery — moved below deadlines so the admissions
|
||||||
|
countdown (time-sensitive) shows ahead of generic "explore" links. */}
|
||||||
|
{!isSearchActive && initialSchools.schools.length === 0 && (
|
||||||
|
<div className={styles.exploringRow}>
|
||||||
|
<span className={styles.exploringLabel}>Start exploring</span>
|
||||||
|
<div className={styles.exploringChips}>
|
||||||
|
<a href="/rankings" className={styles.exploringChip}>
|
||||||
|
<span className={styles.chipDot} aria-hidden="true" />
|
||||||
|
Top-rated primary schools
|
||||||
|
</a>
|
||||||
|
<a href="/rankings" className={styles.exploringChip}>
|
||||||
|
<span className={styles.chipDot} aria-hidden="true" />
|
||||||
|
Top-rated secondary schools
|
||||||
|
</a>
|
||||||
|
<a href="/compare" className={styles.exploringChip}>
|
||||||
|
<span className={styles.chipDot} aria-hidden="true" />
|
||||||
|
Start a comparison
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* How it works + Editorial — server-rendered slots, only on landing */}
|
||||||
|
{!isSearchActive && howItWorks}
|
||||||
|
{!isSearchActive && editorial}
|
||||||
|
|
||||||
{/* Results Section */}
|
{/* Results Section */}
|
||||||
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
<section className={`${styles.results} ${resultsView === 'map' && isLocationSearch ? styles.mapViewResults : ''}`}>
|
||||||
{!hasSearch && initialSchools.schools.length > 0 && (
|
{!hasSearch && initialSchools.schools.length > 0 && (
|
||||||
@@ -272,7 +494,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
>
|
>
|
||||||
<CompactSchoolItem
|
<CompactSchoolItem
|
||||||
school={school}
|
school={school}
|
||||||
onAddToCompare={addSchool}
|
onAddToCompare={addSchoolFromSearch}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
/>
|
/>
|
||||||
@@ -287,7 +509,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
<button className={styles.closeSheetBtn} onClick={() => setSelectedMapSchool(null)}>×</button>
|
<button className={styles.closeSheetBtn} onClick={() => setSelectedMapSchool(null)}>×</button>
|
||||||
<CompactSchoolItem
|
<CompactSchoolItem
|
||||||
school={selectedMapSchool}
|
school={selectedMapSchool}
|
||||||
onAddToCompare={addSchool}
|
onAddToCompare={addSchoolFromSearch}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
/>
|
/>
|
||||||
@@ -305,7 +527,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
key={school.urn}
|
key={school.urn}
|
||||||
school={school}
|
school={school}
|
||||||
isLocationSearch={isLocationSearch}
|
isLocationSearch={isLocationSearch}
|
||||||
onAddToCompare={addSchool}
|
onAddToCompare={addSchoolFromSearch}
|
||||||
onRemoveFromCompare={removeSchool}
|
onRemoveFromCompare={removeSchool}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||||
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
|
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
|
||||||
@@ -315,7 +537,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
key={school.urn}
|
key={school.urn}
|
||||||
school={school}
|
school={school}
|
||||||
isLocationSearch={isLocationSearch}
|
isLocationSearch={isLocationSearch}
|
||||||
onAddToCompare={addSchool}
|
onAddToCompare={addSchoolFromSearch}
|
||||||
onRemoveFromCompare={removeSchool}
|
onRemoveFromCompare={removeSchool}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Server component: pure markup, no client state.
|
||||||
|
// Rendered into HomeView via a slot prop so its JSX doesn't bloat the
|
||||||
|
// HomeView client bundle.
|
||||||
|
|
||||||
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
|
export function HowItWorksSection() {
|
||||||
|
const miniCascade = [
|
||||||
|
{ subj: 'Reading', exp: 96, exc: 73, nat: 75 },
|
||||||
|
{ subj: 'Writing', exp: 81, exc: 15, nat: 72 },
|
||||||
|
{ subj: 'Maths', exp: 85, exc: 47, nat: 74 },
|
||||||
|
];
|
||||||
|
const compareRows = [
|
||||||
|
{ label: 'Reading, Writing & Maths', a: '70%', b: '64%', aHi: true },
|
||||||
|
{ label: 'Ofsted', a: 'Outstanding', b: 'Good', aHi: true },
|
||||||
|
{ label: 'Reading progress', a: '+2.1', b: '+0.4', aHi: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.howItWorks}>
|
||||||
|
<div className={styles.hiwHeader}>
|
||||||
|
<h2 className={styles.hiwHeading}>What you'll see on every school</h2>
|
||||||
|
<span className={styles.hiwSub}>Primary or secondary — the page adapts to the phase</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.hiwGrid}>
|
||||||
|
{/* Card 1 — Performance */}
|
||||||
|
<div className={styles.hiwCard}>
|
||||||
|
<div className={styles.hiwVisual}>
|
||||||
|
<div className={styles.hiwPhaseBlock}>
|
||||||
|
<div className={styles.hiwPhaseLabel}>Primary · Year 6 · <strong>Key Stage 2 SATs</strong></div>
|
||||||
|
<div className={styles.miniCascade}>
|
||||||
|
{miniCascade.map(({ subj, exp, exc, nat }) => (
|
||||||
|
<div key={subj} className={styles.miniCascadeCol}>
|
||||||
|
<div className={styles.miniSubj}>{subj}</div>
|
||||||
|
<div className={styles.miniRowHead}><span>Expected</span><strong>{exp}%</strong></div>
|
||||||
|
<div className={styles.miniTrack}>
|
||||||
|
<div className={styles.miniNatPill} style={{ left: `${nat}%` }}>{nat}%</div>
|
||||||
|
<div className={styles.miniBarExp} style={{ width: `${exp}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.miniRowHead}><span>Exceeding</span><strong>{exc}%</strong></div>
|
||||||
|
<div className={styles.miniTrack}>
|
||||||
|
<div className={styles.miniBarExc} style={{ width: `${exc}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.hiwPhaseBlock}>
|
||||||
|
<div className={styles.hiwPhaseLabel}>Secondary · Year 11 · <strong>GCSE Attainment 8</strong></div>
|
||||||
|
<div className={styles.att8Row}>
|
||||||
|
<div className={styles.att8BarWrap}>
|
||||||
|
<div className={styles.att8BarHead}><span>This school</span><span>National avg 50.2</span></div>
|
||||||
|
<div className={styles.att8Track}>
|
||||||
|
<div className={styles.att8Fill} style={{ width: '62%' }} />
|
||||||
|
<div className={styles.att8NatLine} style={{ left: '50%' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.att8Score}>
|
||||||
|
<div className={styles.att8Value}>62.4</div>
|
||||||
|
<div className={styles.att8Delta}>+12.2 vs national</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.hiwCardBody}>
|
||||||
|
<div className={styles.hiwStep}>Performance</div>
|
||||||
|
<div className={styles.hiwTitle}>Results against the national average</div>
|
||||||
|
<p className={styles.hiwDesc}>For primary schools, each subject's Expected and Exceeding percentages side by side. For secondary schools, GCSE Attainment 8 with the national benchmark overlaid.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2 — Ofsted */}
|
||||||
|
<div className={styles.hiwCard}>
|
||||||
|
<div className={styles.hiwVisual}>
|
||||||
|
<div className={styles.ofstedPreview}>
|
||||||
|
<div className={styles.ofstedHead}>
|
||||||
|
<span className={styles.ofstedBullet} />
|
||||||
|
<span className={styles.ofstedTitle}>Latest Ofsted inspection</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.ofstedBadge}>OUTSTANDING</span>
|
||||||
|
<div className={styles.ofstedVerdict}>Rated <em>Outstanding</em> at last inspection.</div>
|
||||||
|
<div className={styles.ofstedMeta}>Full inspection · March 2024</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.hiwCardBody}>
|
||||||
|
<div className={styles.hiwStep}>Judgement</div>
|
||||||
|
<div className={styles.hiwTitle}>Ofsted at a glance</div>
|
||||||
|
<p className={styles.hiwDesc}>Current grade, inspection date, and a plain-English headline — without opening a 40-page report.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3 — Compare */}
|
||||||
|
<div className={styles.hiwCard}>
|
||||||
|
<div className={styles.hiwVisual}>
|
||||||
|
<div className={styles.comparePreview}>
|
||||||
|
<div className={styles.compareHead}>
|
||||||
|
<div className={`${styles.compareHeadCell} ${styles.compareHeadLabel}`}>Metric</div>
|
||||||
|
<div className={styles.compareHeadCell}>Our Lady<br />Queen of Heaven</div>
|
||||||
|
<div className={styles.compareHeadCell}>St Mary's<br />Catholic Primary</div>
|
||||||
|
</div>
|
||||||
|
{compareRows.map(({ label, a, b, aHi }) => (
|
||||||
|
<div key={label} className={styles.compareRow}>
|
||||||
|
<span className={styles.compareRowLabel}>{label}</span>
|
||||||
|
<span className={`${styles.compareRowVal} ${aHi ? styles.compareRowValHi : ''}`}>{a}</span>
|
||||||
|
<span className={styles.compareRowVal}>{b}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className={styles.compareFoot}>+ pin up to 5 schools</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.hiwCardBody}>
|
||||||
|
<div className={styles.hiwStep}>Compare</div>
|
||||||
|
<div className={styles.hiwTitle}>Side-by-side shortlists</div>
|
||||||
|
<p className={styles.hiwDesc}>Pin up to five schools and every metric aligns in the same columns — works for primary and secondary alike.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -81,3 +81,13 @@
|
|||||||
width: 180px;
|
width: 180px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On phones the icon was rendering at ~9px and the tooltip relied on
|
||||||
|
:hover, which doesn't fire on touch. Rather than build a tap-to-show
|
||||||
|
layer with backdrop dismissal, hide the helper entirely — the metric
|
||||||
|
labels themselves carry the meaning. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
.container {
|
.container {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1.5rem;
|
/* Add the safe-area inset to horizontal padding so the header content
|
||||||
|
clears the notch in landscape on iPhones. */
|
||||||
|
padding-inline: max(1.5rem, env(safe-area-inset-left)) max(1.5rem, env(safe-area-inset-right));
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -21,11 +23,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
/* Padded hit area so the logo link is ≥44×44 on touch */
|
||||||
|
margin: -0.375rem -0.5rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo:hover {
|
.logo:hover {
|
||||||
@@ -33,11 +39,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logoIcon {
|
.logoIcon {
|
||||||
|
display: inline-flex;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
color: var(--accent-coral, #e07256);
|
color: var(--accent-coral, #e07256);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logoIcon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.logoText {
|
.logoText {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), 'Playfair Display', serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -126,21 +138,109 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
/* ─── Bottom tab bar (mobile only) ──────────────────────────────── */
|
||||||
.container {
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logoText {
|
.bottomBar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.tab {
|
||||||
gap: 0.25rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 0.375rem 0.25rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
transition: color 0.15s ease, background-color 0.15s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLink {
|
.tab:active {
|
||||||
padding: 0.5rem 0.75rem;
|
background: var(--bg-secondary, #f3ede4);
|
||||||
font-size: 0.875rem;
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabIconWrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabIcon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabLabel {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabBadge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
border: 2px solid var(--bg-card, white);
|
||||||
|
border-radius: 9999px;
|
||||||
|
animation: badgePop 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 1rem;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the top text nav; the bottom bar takes over */
|
||||||
|
.nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomBar {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--bg-card, white);
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
box-shadow: 0 -2px 12px rgba(26, 22, 18, 0.06);
|
||||||
|
/* Respect iPhone home-indicator (bottom) and notch (left/right in
|
||||||
|
landscape) insets so the tab content never sits under system UI. */
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
padding-inline: env(safe-area-inset-left, 0) env(safe-area-inset-right, 0);
|
||||||
|
/* Compensate for iOS Chrome's auto-hiding URL bar — Navigation.tsx
|
||||||
|
writes the offset based on the Visual Viewport API. translate3d
|
||||||
|
(instead of translateY) forces hardware compositing so the bar
|
||||||
|
doesn't lag/flicker during the toolbar animation. */
|
||||||
|
transform: translate3d(0, var(--mobile-bar-offset, 0px), 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +1,153 @@
|
|||||||
/**
|
/**
|
||||||
* Navigation Component
|
* Navigation Component
|
||||||
* Main navigation header with active link highlighting
|
* Top header nav for desktop; bottom tab bar for mobile (≤640px).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
import styles from './Navigation.module.css';
|
import styles from './Navigation.module.css';
|
||||||
|
|
||||||
|
type IconProps = { className?: string };
|
||||||
|
|
||||||
|
const SearchIcon = ({ className }: IconProps) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m20 20-3.5-3.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CompareIcon = ({ className }: IconProps) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 7h13l-3-3" />
|
||||||
|
<path d="M20 17H7l3 3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const RankingsIcon = ({ className }: IconProps) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M7 21V11" />
|
||||||
|
<path d="M12 21V4" />
|
||||||
|
<path d="M17 21v-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AdmissionsIcon = ({ className }: IconProps) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||||
|
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export function Navigation() {
|
export function Navigation() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { selectedSchools } = useComparison();
|
const { selectedSchools } = useComparison();
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === '/') {
|
if (path === '/') return pathname === '/';
|
||||||
return pathname === '/';
|
|
||||||
}
|
|
||||||
return pathname.startsWith(path);
|
return pathname.startsWith(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS Chrome (and some Android browsers) auto-hide their URL bar on scroll,
|
||||||
|
* which grows the visual viewport without changing the layout viewport.
|
||||||
|
* `position: fixed; bottom: 0` sticks to the layout viewport, so our tab
|
||||||
|
* bar appears to float mid-screen with a gap beneath it. Track the delta
|
||||||
|
* via VisualViewport and apply it as a translate so the bar always sits
|
||||||
|
* flush against the visible bottom edge.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
const update = () => {
|
||||||
|
const offset = window.innerHeight - (vv.height + vv.offsetTop);
|
||||||
|
// Only positive offsets are meaningful (bar hidden → push down).
|
||||||
|
root.style.setProperty('--mobile-bar-offset', `${Math.max(0, offset)}px`);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
vv.addEventListener('resize', update);
|
||||||
|
vv.addEventListener('scroll', update);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener('resize', update);
|
||||||
|
vv.removeEventListener('scroll', update);
|
||||||
|
root.style.removeProperty('--mobile-bar-offset');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ href: '/', label: 'Search', Icon: SearchIcon },
|
||||||
|
{ href: '/compare', label: 'Compare', Icon: CompareIcon },
|
||||||
|
{ href: '/rankings', label: 'Rankings', Icon: RankingsIcon },
|
||||||
|
{ href: '/admissions', label: 'Admissions', Icon: AdmissionsIcon },
|
||||||
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Link href="/" className={styles.logo}>
|
<Link href="/" className={styles.logo} aria-label="SchoolCompare home">
|
||||||
<div className={styles.logoIcon}>
|
<span className={styles.logoIcon}>
|
||||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
<circle cx="20" cy="20" r="18" stroke="currentColor" strokeWidth="2" />
|
<circle cx="20" cy="20" r="18" stroke="currentColor" strokeWidth="2" />
|
||||||
<path d="M20 6L20 34M8 14L32 14M6 20L34 20M8 26L32 26" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
<path d="M20 6L20 34M8 14L32 14M6 20L34 20M8 26L32 26" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
<circle cx="20" cy="20" r="3" fill="currentColor" />
|
<circle cx="20" cy="20" r="3" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</span>
|
||||||
<span className={styles.logoText}>SchoolCompare</span>
|
<span className={styles.logoText}>SchoolCompare</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className={styles.nav} aria-label="Main navigation">
|
<nav className={styles.nav} aria-label="Main navigation">
|
||||||
|
{items.map(({ href, label }) => {
|
||||||
|
const active = isActive(href);
|
||||||
|
const showBadge = href === '/compare' && selectedSchools.length > 0;
|
||||||
|
return (
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
key={href}
|
||||||
className={isActive('/') ? `${styles.navLink} ${styles.active}` : styles.navLink}
|
href={href}
|
||||||
|
className={active ? `${styles.navLink} ${styles.active}` : styles.navLink}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
Search
|
{label}
|
||||||
</Link>
|
{showBadge && (
|
||||||
<Link
|
|
||||||
href="/compare"
|
|
||||||
className={isActive('/compare') ? `${styles.navLink} ${styles.active}` : styles.navLink}
|
|
||||||
>
|
|
||||||
Compare
|
|
||||||
{selectedSchools.length > 0 && (
|
|
||||||
<span key={selectedSchools.length} className={styles.badge}>
|
<span key={selectedSchools.length} className={styles.badge}>
|
||||||
{selectedSchools.length}
|
{selectedSchools.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
);
|
||||||
href="/rankings"
|
})}
|
||||||
className={isActive('/rankings') ? `${styles.navLink} ${styles.active}` : styles.navLink}
|
|
||||||
>
|
|
||||||
Rankings
|
|
||||||
</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav className={styles.bottomBar} aria-label="Main navigation">
|
||||||
|
{items.map(({ href, label, Icon }) => {
|
||||||
|
const active = isActive(href);
|
||||||
|
const showBadge = href === '/compare' && selectedSchools.length > 0;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={active ? `${styles.tab} ${styles.tabActive}` : styles.tab}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span className={styles.tabIconWrap}>
|
||||||
|
<Icon className={styles.tabIcon} />
|
||||||
|
{showBadge && (
|
||||||
|
<span key={selectedSchools.length} className={styles.tabBadge} aria-label={`${selectedSchools.length} selected`}>
|
||||||
|
{selectedSchools.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={styles.tabLabel}>{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
/* Fill the parent .chartContainer (which carries the height) so the
|
||||||
|
canvas wrapper below can take 100% of a real number. Without this,
|
||||||
|
.chartOuter auto-sizes to content and the canvas falls back to
|
||||||
|
Chart.js's tiny default — leaving empty space below the chart. */
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trendSummary {
|
.trendSummary {
|
||||||
@@ -17,7 +22,12 @@
|
|||||||
|
|
||||||
.chartWrapper {
|
.chartWrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
/* flex:1 instead of height:100% so the trend banner / chip strip
|
||||||
|
above can take their natural size and the canvas fills the rest of
|
||||||
|
the .chartOuter column. min-height:0 keeps flex from refusing to
|
||||||
|
shrink the canvas below its content size. */
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +39,104 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile chip selector ────────────────────────────────────────────
|
||||||
|
Hidden on desktop. Replaces the in-chart legend on phones — one
|
||||||
|
metric at a time so the line variation is actually readable. */
|
||||||
|
|
||||||
|
.mobileChips {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileSubtitle {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
background: var(--bg-card, #fff);
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:active {
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipActive {
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-coral, #e07256);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipActive:active {
|
||||||
|
background: var(--accent-coral-dark, #c45a3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniLegend {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.875rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniLegend span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniDot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.chartWrapper {
|
.chartWrapper {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mobileChips {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* Canvas needs an explicit height now that the parent .chartContainer
|
||||||
|
flows naturally (its old fixed 220px was clipping the chip strip and
|
||||||
|
pushing the disclosure link onto the plot area). */
|
||||||
|
.chartWrapper {
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
/* The desktop "click the legend to show progress" hint is irrelevant
|
||||||
|
once the chip row is the disclosure mechanism. */
|
||||||
|
.chartHint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* PerformanceChart Component
|
* PerformanceChart Component
|
||||||
* Displays school performance data over time using Chart.js
|
* Displays school performance data over time using Chart.js.
|
||||||
|
*
|
||||||
|
* Desktop: full multi-series chart with dual y-axis (percentage + progress).
|
||||||
|
* Mobile (≤640px): a chip selector switches the chart between one focused view
|
||||||
|
* at a time — no dual axis, no legend, auto-scaled y-axis. Designed so the
|
||||||
|
* actual variation in the data is visible on a phone instead of a flat line.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import {
|
import { ChartOptions, ChartDataset } from 'chart.js';
|
||||||
Chart as ChartJS,
|
import '@/lib/chartSetup';
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ChartOptions,
|
|
||||||
ChartDataset,
|
|
||||||
} from 'chart.js';
|
|
||||||
import type { SchoolResult } from '@/lib/types';
|
import type { SchoolResult } from '@/lib/types';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './PerformanceChart.module.css';
|
import styles from './PerformanceChart.module.css';
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
|
||||||
|
|
||||||
interface NationalByYear {
|
interface NationalByYear {
|
||||||
year: number;
|
year: number;
|
||||||
primary: Record<string, number>;
|
primary: Record<string, number>;
|
||||||
@@ -34,17 +29,35 @@ interface PerformanceChartProps {
|
|||||||
data: SchoolResult[];
|
data: SchoolResult[];
|
||||||
schoolName: string;
|
schoolName: string;
|
||||||
isSecondary?: boolean;
|
isSecondary?: boolean;
|
||||||
/** National average RWM expected % for the latest year — fallback if no by_year data */
|
|
||||||
nationalRwmAvg?: number | null;
|
nationalRwmAvg?: number | null;
|
||||||
/** National average Attainment 8 for the latest year — fallback if no by_year data */
|
|
||||||
nationalAtt8Avg?: number | null;
|
nationalAtt8Avg?: number | null;
|
||||||
/** Per-year national averages — used to draw a changing reference line */
|
|
||||||
nationalByYear?: NationalByYear[];
|
nationalByYear?: NationalByYear[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Academic years when SATs/GCSEs were cancelled due to COVID
|
|
||||||
const COVID_YEARS = new Set([201920, 202021]);
|
const COVID_YEARS = new Set([201920, 202021]);
|
||||||
|
|
||||||
|
// Mobile chip definitions: which datasets render when each chip is active.
|
||||||
|
// `series` keys reference the dataset labels so we can filter cleanly.
|
||||||
|
type ChipId = 'expected' | 'higher' | 'progress' | 'attainment8' | 'em_pass' | 'progress8';
|
||||||
|
interface ChipDef {
|
||||||
|
id: ChipId;
|
||||||
|
label: string;
|
||||||
|
/** Dataset labels (from the desktop dataset list below) this chip surfaces. */
|
||||||
|
series: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIMARY_CHIPS: ChipDef[] = [
|
||||||
|
{ id: 'expected', label: 'At expected level', series: ['Reading, Writing & Maths expected %', 'National average'] },
|
||||||
|
{ id: 'higher', label: 'Above expected level', series: ['Exceeding expected level'] },
|
||||||
|
{ id: 'progress', label: 'Pupil progress', series: ['Reading progress', 'Writing progress', 'Maths progress'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECONDARY_CHIPS: ChipDef[] = [
|
||||||
|
{ id: 'attainment8', label: 'Attainment 8', series: ['Attainment 8', 'National average'] },
|
||||||
|
{ id: 'em_pass', label: 'English & Maths grade 4+', series: ['English & Maths Grade 4+'] },
|
||||||
|
{ id: 'progress8', label: 'Progress 8', series: ['Progress 8'] },
|
||||||
|
];
|
||||||
|
|
||||||
export function PerformanceChart({
|
export function PerformanceChart({
|
||||||
data,
|
data,
|
||||||
isSecondary = false,
|
isSecondary = false,
|
||||||
@@ -55,8 +68,18 @@ 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));
|
||||||
|
|
||||||
// Build per-year national average series aligned to the school's data years.
|
// ── Mobile detection ─────────────────────────────────────────────────
|
||||||
// Falls back to a flat line using the scalar prop if by_year isn't available.
|
// 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 ─────────────────────────────────
|
||||||
const natRefRwm: (number | null)[] = sortedData.map(d => {
|
const natRefRwm: (number | null)[] = sortedData.map(d => {
|
||||||
if (nationalByYear) {
|
if (nationalByYear) {
|
||||||
const match = nationalByYear.find(n => n.year === d.year);
|
const match = nationalByYear.find(n => n.year === d.year);
|
||||||
@@ -74,7 +97,7 @@ export function PerformanceChart({
|
|||||||
const hasNatRwm = natRefRwm.some(v => v != null);
|
const hasNatRwm = natRefRwm.some(v => v != null);
|
||||||
const hasNatAtt8 = natRefAtt8.some(v => v != null);
|
const hasNatAtt8 = natRefAtt8.some(v => v != null);
|
||||||
|
|
||||||
// ── Trend summary (primary only) ──────────────────────────────────────
|
// ── Trend summary (primary only — references headline metric) ────────
|
||||||
const trendSummary = (() => {
|
const trendSummary = (() => {
|
||||||
if (isSecondary) return null;
|
if (isSecondary) return null;
|
||||||
const rwm = sortedData.filter(d => d.rwm_expected_pct != null);
|
const rwm = sortedData.filter(d => d.rwm_expected_pct != null);
|
||||||
@@ -87,18 +110,17 @@ export function PerformanceChart({
|
|||||||
const delta = latest.rwm_expected_pct! - prev.rwm_expected_pct!;
|
const delta = latest.rwm_expected_pct! - prev.rwm_expected_pct!;
|
||||||
const arrow = delta > 1 ? '↑' : delta < -1 ? '↓' : '→';
|
const arrow = delta > 1 ? '↑' : delta < -1 ? '↓' : '→';
|
||||||
if (best.year === latest.year) {
|
if (best.year === latest.year) {
|
||||||
return `${arrow} Best year on record — ${latestPct}% Reading, Writing & Maths`;
|
return `${arrow} Best year on record — ${latestPct}% met the expected standard in Reading, Writing & Maths`;
|
||||||
}
|
}
|
||||||
return `${arrow} Peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
return `${arrow} Reading, Writing & Maths peaked at ${bestPct}% (${formatAcademicYear(best.year)}), currently ${latestPct}%`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── COVID gap note ─────────────────────────────────────────────────────
|
|
||||||
const hasCovidGap = isSecondary
|
const hasCovidGap = isSecondary
|
||||||
? false
|
? false
|
||||||
: COVID_YEARS.size > 0 &&
|
: COVID_YEARS.size > 0 &&
|
||||||
[...COVID_YEARS].some(y => !sortedData.find(d => d.year === y));
|
[...COVID_YEARS].some(y => !sortedData.find(d => d.year === y));
|
||||||
|
|
||||||
// ── Datasets ──────────────────────────────────────────────────────────
|
// ── Datasets (full set; mobile filters them via the active chip) ─────
|
||||||
const refLineStyle = {
|
const refLineStyle = {
|
||||||
borderColor: 'rgba(90,80,70,0.35)',
|
borderColor: 'rgba(90,80,70,0.35)',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@@ -109,7 +131,7 @@ export function PerformanceChart({
|
|||||||
order: 10,
|
order: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
const datasets: ChartDataset<'line'>[] = isSecondary ? [
|
const allDatasets: ChartDataset<'line'>[] = isSecondary ? [
|
||||||
{
|
{
|
||||||
label: 'Attainment 8',
|
label: 'Attainment 8',
|
||||||
data: sortedData.map(d => d.attainment_8_score),
|
data: sortedData.map(d => d.attainment_8_score),
|
||||||
@@ -211,7 +233,53 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const options: ChartOptions<'line'> = {
|
// ── Mobile chip state + filtered datasets ────────────────────────────
|
||||||
|
const chips = isSecondary ? SECONDARY_CHIPS : PRIMARY_CHIPS;
|
||||||
|
|
||||||
|
// A chip is enabled only if at least one of its series has any real data.
|
||||||
|
const chipHasData = (chip: ChipDef) =>
|
||||||
|
chip.series.some(name => {
|
||||||
|
const ds = allDatasets.find(d => d.label === name);
|
||||||
|
return ds?.data?.some(v => v != null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstEnabledChip = chips.find(chipHasData)?.id ?? chips[0].id;
|
||||||
|
const [activeChip, setActiveChip] = useState<ChipId>(firstEnabledChip);
|
||||||
|
|
||||||
|
const activeChipDef = chips.find(c => c.id === activeChip) ?? chips[0];
|
||||||
|
|
||||||
|
const mobileDatasets = useMemo(() => {
|
||||||
|
return allDatasets
|
||||||
|
.filter(ds => activeChipDef.series.includes(ds.label ?? ''))
|
||||||
|
.map(ds => ({ ...ds, hidden: false, yAxisID: 'y' as const }));
|
||||||
|
}, [activeChipDef, allDatasets]);
|
||||||
|
|
||||||
|
// Auto-scale Y axis for the mobile chart so variation is visible.
|
||||||
|
// For percentage chips: clamp to 0–100 but tighten when data sits in a band.
|
||||||
|
// For progress chips: centre on 0 with a small symmetric range.
|
||||||
|
const mobileYBounds = useMemo(() => {
|
||||||
|
const isProgress = activeChip === 'progress' || activeChip === 'progress8';
|
||||||
|
const values: number[] = mobileDatasets.flatMap(ds =>
|
||||||
|
(ds.data as Array<number | null | undefined>).filter((v): v is number => typeof v === 'number')
|
||||||
|
);
|
||||||
|
if (values.length === 0) return { min: 0, max: 100, isProgress };
|
||||||
|
const lo = Math.min(...values);
|
||||||
|
const hi = Math.max(...values);
|
||||||
|
if (isProgress) {
|
||||||
|
const reach = Math.max(2, Math.ceil(Math.max(Math.abs(lo), Math.abs(hi)) + 0.5));
|
||||||
|
return { min: -reach, max: reach, isProgress };
|
||||||
|
}
|
||||||
|
// Percentage: leave headroom but never widen below 0 / above 100.
|
||||||
|
const padded = Math.max(5, Math.round((hi - lo) * 0.2));
|
||||||
|
return {
|
||||||
|
min: Math.max(0, Math.floor((lo - padded) / 5) * 5),
|
||||||
|
max: Math.min(100, Math.ceil((hi + padded) / 5) * 5),
|
||||||
|
isProgress,
|
||||||
|
};
|
||||||
|
}, [activeChip, mobileDatasets]);
|
||||||
|
|
||||||
|
// ── Chart options ────────────────────────────────────────────────────
|
||||||
|
const desktopOptions: ChartOptions<'line'> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
interaction: { mode: 'index', intersect: false },
|
interaction: { mode: 'index', intersect: false },
|
||||||
@@ -222,7 +290,6 @@ export function PerformanceChart({
|
|||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
padding: 14,
|
padding: 14,
|
||||||
font: { size: 12 },
|
font: { size: 12 },
|
||||||
filter: item => item.text !== 'National average' || true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
title: { display: false },
|
title: { display: false },
|
||||||
@@ -237,36 +304,22 @@ export function PerformanceChart({
|
|||||||
if (ctx.parsed.y == null) return label;
|
if (ctx.parsed.y == null) return label;
|
||||||
const isProgress = ctx.dataset.yAxisID === 'y1';
|
const isProgress = ctx.dataset.yAxisID === 'y1';
|
||||||
const suffix = isProgress ? '' : '%';
|
const suffix = isProgress ? '' : '%';
|
||||||
const val = ctx.parsed.y.toFixed(1);
|
return `${label}: ${ctx.parsed.y.toFixed(1)}${suffix}`;
|
||||||
return `${label}: ${val}${suffix}`;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
type: 'linear',
|
type: 'linear', display: true, position: 'left',
|
||||||
display: true,
|
title: { display: true, text: isSecondary ? 'Score / %' : 'Percentage (%)', font: { size: 11 } },
|
||||||
position: 'left',
|
min: 0, max: isSecondary ? undefined : 100,
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: isSecondary ? 'Score / %' : 'Percentage (%)',
|
|
||||||
font: { size: 11 },
|
|
||||||
},
|
|
||||||
min: 0,
|
|
||||||
max: isSecondary ? undefined : 100,
|
|
||||||
grid: { color: 'rgba(0,0,0,0.05)' },
|
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||||
ticks: { font: { size: 11 } },
|
ticks: { font: { size: 11 } },
|
||||||
},
|
},
|
||||||
y1: {
|
y1: {
|
||||||
type: 'linear',
|
type: 'linear', display: true, position: 'right',
|
||||||
display: true,
|
title: { display: true, text: isSecondary ? 'Progress 8' : 'Progress score', font: { size: 11 } },
|
||||||
position: 'right',
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: isSecondary ? 'Progress 8' : 'Progress score',
|
|
||||||
font: { size: 11 },
|
|
||||||
},
|
|
||||||
grid: { drawOnChartArea: false },
|
grid: { drawOnChartArea: false },
|
||||||
ticks: { font: { size: 11 } },
|
ticks: { font: { size: 11 } },
|
||||||
},
|
},
|
||||||
@@ -277,19 +330,107 @@ export function PerformanceChart({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mobileOptions: ChartOptions<'line'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
title: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(26,22,18,0.92)',
|
||||||
|
padding: 10,
|
||||||
|
titleFont: { size: 12 },
|
||||||
|
bodyFont: { size: 11 },
|
||||||
|
callbacks: {
|
||||||
|
label: ctx => {
|
||||||
|
const label = ctx.dataset.label ?? '';
|
||||||
|
if (ctx.parsed.y == null) return label;
|
||||||
|
const suffix = mobileYBounds.isProgress ? '' : '%';
|
||||||
|
return `${label}: ${ctx.parsed.y.toFixed(1)}${suffix}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
type: 'linear', display: true, position: 'left',
|
||||||
|
min: mobileYBounds.min, max: mobileYBounds.max,
|
||||||
|
grid: { color: 'rgba(0,0,0,0.05)' },
|
||||||
|
ticks: { font: { size: 10 }, maxTicksLimit: 5 },
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { font: { size: 10 }, autoSkip: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const subtitle = isSecondary
|
||||||
|
? 'GCSE results · Year 11'
|
||||||
|
: 'KS2 SATs · Reading, Writing & Maths';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.chartOuter}>
|
<div className={styles.chartOuter}>
|
||||||
{trendSummary && (
|
{trendSummary && (
|
||||||
<div className={styles.trendSummary}>{trendSummary}</div>
|
<div className={styles.trendSummary}>{trendSummary}</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.chartWrapper}>
|
|
||||||
<Line data={{ labels: years, datasets }} options={options} />
|
{/* Mobile-only chip selector */}
|
||||||
|
<div className={styles.mobileChips} aria-hidden={!isMobile}>
|
||||||
|
<div className={styles.mobileSubtitle}>{subtitle}</div>
|
||||||
|
<div className={styles.chipRow} role="tablist" aria-label="Select metric">
|
||||||
|
{chips.map(chip => {
|
||||||
|
const enabled = chipHasData(chip);
|
||||||
|
const active = chip.id === activeChip;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={chip.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
disabled={!enabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (chip.id !== activeChip) {
|
||||||
|
track('chart_metric_changed', { chip: chip.id, phase: isSecondary ? 'secondary' : 'primary', viewport: 'mobile' });
|
||||||
|
}
|
||||||
|
setActiveChip(chip.id);
|
||||||
|
}}
|
||||||
|
className={`${styles.chip}${active ? ` ${styles.chipActive}` : ''}`}
|
||||||
|
title={!enabled ? 'No data for this school' : undefined}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.chartWrapper}>
|
||||||
|
<Line
|
||||||
|
data={{ labels: years, datasets: isMobile ? mobileDatasets : allDatasets }}
|
||||||
|
options={isMobile ? mobileOptions : desktopOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* When the Progress chip is active on primary, show a tiny inline legend
|
||||||
|
for the 3 sub-series (reading/writing/maths) — they share a unit and
|
||||||
|
belong together. */}
|
||||||
|
{isMobile && activeChip === 'progress' && (
|
||||||
|
<div className={styles.miniLegend}>
|
||||||
|
<span><span className={styles.miniDot} style={{ background: 'rgb(59,130,246)' }} />Reading</span>
|
||||||
|
<span><span className={styles.miniDot} style={{ background: 'rgb(139,92,246)' }} />Writing</span>
|
||||||
|
<span><span className={styles.miniDot} style={{ background: 'rgb(236,72,153)' }} />Maths</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasCovidGap && (
|
{hasCovidGap && (
|
||||||
<p className={styles.covidNote}>
|
<p className={styles.covidNote}>
|
||||||
* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19.
|
* No data for 2019/20 or 2020/21 — national assessments were cancelled due to COVID-19.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Desktop-only hint about toggling progress in the legend */}
|
||||||
{!isSecondary && (
|
{!isSecondary && (
|
||||||
<p className={styles.chartHint}>
|
<p className={styles.chartHint}>
|
||||||
Progress scores (Reading, Writing, Maths) are hidden by default — click them in the legend to show.
|
Progress scores (Reading, Writing, Maths) are hidden by default — click them in the legend to show.
|
||||||
|
|||||||
@@ -396,6 +396,16 @@
|
|||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Long metric labels like "Reading, Writing & Maths Combined %" used to
|
||||||
|
force the whole column wide; let them wrap onto 2 short lines with a
|
||||||
|
tighter font so the value cell can stay compact. */
|
||||||
|
.valueHeader {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
.rankHeader {
|
.rankHeader {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
|||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, schoolUrl } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, schoolUrl } from '@/lib/utils';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import styles from './RankingsView.module.css';
|
import styles from './RankingsView.module.css';
|
||||||
|
|
||||||
@@ -74,11 +75,12 @@ export function RankingsView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePhaseChange = (phase: string) => {
|
const handlePhaseChange = (phase: string) => {
|
||||||
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
|
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_high_pct';
|
||||||
updateFilters({ phase, metric: defaultMetric });
|
updateFilters({ phase, metric: defaultMetric });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMetricChange = (metric: string) => {
|
const handleMetricChange = (metric: string) => {
|
||||||
|
track('metric_compared_in_rankings', { metric, phase: selectedPhase });
|
||||||
updateFilters({ metric });
|
updateFilters({ metric });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ export function RankingsView({
|
|||||||
latitude: null,
|
latitude: null,
|
||||||
longitude: null,
|
longitude: null,
|
||||||
} as any);
|
} as any);
|
||||||
|
track('compare_school_added', { urn: ranking.urn, from: 'rankings' });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get metric definition
|
// Get metric definition
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/* SatsChart — Cascade bar chart for KS2 SATs results */
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.75rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Individual subject column ── */
|
||||||
|
.subjectChart {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subjectName {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chartArea {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Gridlines ── */
|
||||||
|
.gridlines {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridline {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── National average marker ── */
|
||||||
|
.natLine {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: calc(100% - 20px);
|
||||||
|
width: 1.5px;
|
||||||
|
background: rgba(224, 114, 86, 0.35); /* --accent-coral at 35% */
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.natPill {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 3;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bar rows ── */
|
||||||
|
.barGroup {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding-top: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barTrack {
|
||||||
|
width: 100%;
|
||||||
|
height: 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barExpected {
|
||||||
|
background: var(--accent-teal-light, #3a9e9e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barExceeding {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.barLabel {
|
||||||
|
font-family: var(--font-playfair), 'Playfair Display', Georgia, serif;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barLabelSuffix {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ruler ── */
|
||||||
|
.ruler {
|
||||||
|
position: relative;
|
||||||
|
height: 14px;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulerTick {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulerLabel {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anchor first/last labels to edges */
|
||||||
|
.rulerLabelFirst {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rulerLabelLast {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Legend ── */
|
||||||
|
.legend {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendSwatch {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import styles from './SatsChart.module.css';
|
||||||
|
|
||||||
|
interface SubjectData {
|
||||||
|
name: string;
|
||||||
|
expectedPct: number | null;
|
||||||
|
exceedingPct: number | null;
|
||||||
|
nationalExpectedPct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SatsChartProps {
|
||||||
|
subjects: SubjectData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RULER_TICKS = [0, 25, 50, 75, 100];
|
||||||
|
const GRIDLINE_POSITIONS = [25, 50, 75];
|
||||||
|
|
||||||
|
function SubjectColumn({ subject }: { subject: SubjectData }) {
|
||||||
|
const expectedRef = useRef<HTMLDivElement>(null);
|
||||||
|
const exceedingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
|
||||||
|
|
||||||
|
// Animate bars on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const bars = [expectedRef.current, exceedingRef.current];
|
||||||
|
bars.forEach((bar) => {
|
||||||
|
if (!bar) return;
|
||||||
|
const target = bar.dataset.width;
|
||||||
|
bar.style.width = '0%';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
bar.style.width = `${target}%`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [expectedPct, exceedingPct]);
|
||||||
|
|
||||||
|
if (expectedPct == null && exceedingPct == null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.subjectChart}>
|
||||||
|
<div className={styles.subjectName}>{name}</div>
|
||||||
|
<div className={styles.chartArea}>
|
||||||
|
{/* Gridlines */}
|
||||||
|
<div className={styles.gridlines}>
|
||||||
|
{GRIDLINE_POSITIONS.map((pct) => (
|
||||||
|
<div key={pct} className={styles.gridline} style={{ left: `${pct}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* National average marker */}
|
||||||
|
{nationalExpectedPct != null && (
|
||||||
|
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
|
||||||
|
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bars */}
|
||||||
|
<div className={styles.barGroup}>
|
||||||
|
{expectedPct != null && (
|
||||||
|
<div className={styles.barRow}>
|
||||||
|
<div className={styles.barHeader}>
|
||||||
|
<span className={styles.barLabelSuffix}>Expected</span>
|
||||||
|
<span className={styles.barLabel}>{expectedPct.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.barTrack}>
|
||||||
|
<div
|
||||||
|
ref={expectedRef}
|
||||||
|
className={`${styles.bar} ${styles.barExpected}`}
|
||||||
|
data-width={expectedPct}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{exceedingPct != null && (
|
||||||
|
<div className={styles.barRow}>
|
||||||
|
<div className={styles.barHeader}>
|
||||||
|
<span className={styles.barLabelSuffix}>Exceeding</span>
|
||||||
|
<span className={styles.barLabel}>{exceedingPct.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.barTrack}>
|
||||||
|
<div
|
||||||
|
ref={exceedingRef}
|
||||||
|
className={`${styles.bar} ${styles.barExceeding}`}
|
||||||
|
data-width={exceedingPct}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ruler */}
|
||||||
|
<div className={styles.ruler}>
|
||||||
|
{RULER_TICKS.map((pct, i) => (
|
||||||
|
<span key={pct}>
|
||||||
|
<div className={styles.rulerTick} style={{ left: `${pct}%` }} />
|
||||||
|
<div
|
||||||
|
className={`${styles.rulerLabel} ${i === 0 ? styles.rulerLabelFirst : ''} ${i === RULER_TICKS.length - 1 ? styles.rulerLabelLast : ''}`}
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
>
|
||||||
|
{pct}%
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SatsChart({ subjects }: SatsChartProps) {
|
||||||
|
const visibleSubjects = subjects.filter(
|
||||||
|
(s) => s.expectedPct != null || s.exceedingPct != null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visibleSubjects.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.container}>
|
||||||
|
{visibleSubjects.map((subject) => (
|
||||||
|
<SubjectColumn key={subject.name} subject={subject} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal-light, #3a9e9e)' }} />
|
||||||
|
Expected standard
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-teal, #2d7d7d)' }} />
|
||||||
|
Exceeding / high score
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
|
||||||
|
National average
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
@@ -83,6 +83,58 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gender split card — sits in the Pupils & Inclusion heroStatGrid */
|
||||||
|
.genderSplitValue {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||||
|
font-size: 1.55rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderSplitGirls {
|
||||||
|
color: #b45778;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderSplitBoys {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderSplitLabel {
|
||||||
|
font-family: var(--font-body, inherit);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderSplitSep {
|
||||||
|
color: var(--border-color, #c8beb0);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: 0 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderBar {
|
||||||
|
display: flex;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderBarGirls {
|
||||||
|
background: #b45778;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderBarBoys {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -125,7 +177,7 @@
|
|||||||
/* ── Sticky Section Navigation ──────────────────────── */
|
/* ── Sticky Section Navigation ──────────────────────── */
|
||||||
.sectionNav {
|
.sectionNav {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 3.5rem;
|
top: 4rem;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card, white);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
@@ -138,6 +190,8 @@
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
scroll-padding-inline: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionNav::-webkit-scrollbar {
|
.sectionNav::-webkit-scrollbar {
|
||||||
@@ -150,6 +204,21 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Right-edge fade so users see there's more to scroll to. */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sectionNav {
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
|
||||||
|
padding-right: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When scrolled to the end, drop the fade so the last item isn't dimmed. */
|
||||||
|
.sectionNav.atEnd {
|
||||||
|
-webkit-mask-image: none;
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sectionNavBack {
|
.sectionNavBack {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -180,7 +249,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionNavLink {
|
.sectionNavLink {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
padding: 0.3rem 0.625rem;
|
padding: 0.3rem 0.625rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -189,6 +259,16 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sectionNavLink,
|
||||||
|
.sectionNavBack {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionNavLink:hover {
|
.sectionNavLink:hover {
|
||||||
@@ -228,7 +308,7 @@
|
|||||||
margin-bottom: 0.875rem;
|
margin-bottom: 0.875rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
@@ -236,7 +316,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle::before {
|
.sectionTitle::before {
|
||||||
content: '';
|
content: "";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
@@ -323,6 +403,70 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Hero stat cards (RWM combined, Pupils & Inclusion, etc.) ── */
|
||||||
|
/* Larger teal-tinted cards with Playfair serif numbers — reserved for
|
||||||
|
the top-of-section headline metrics. Use .metricCard for denser
|
||||||
|
secondary metrics. */
|
||||||
|
.heroStatGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatCard {
|
||||||
|
background: var(--accent-teal-bg, rgba(45, 125, 125, 0.12));
|
||||||
|
border: 1px solid rgba(45, 125, 125, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatLabel {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatValue {
|
||||||
|
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||||
|
font-size: 2.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatHint {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
font-style: normal;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.heroStatGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatValue {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Progress score colour coding */
|
/* Progress score colour coding */
|
||||||
.progressPositive {
|
.progressPositive {
|
||||||
color: var(--accent-teal, #2d7d7d);
|
color: var(--accent-teal, #2d7d7d);
|
||||||
@@ -491,17 +635,44 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
.ofstedGrade1 {
|
||||||
.ofstedGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
background: var(--accent-teal-bg);
|
||||||
.ofstedGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
|
color: var(--accent-teal, #2d7d7d);
|
||||||
.ofstedGrade4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
}
|
||||||
|
.ofstedGrade2 {
|
||||||
|
background: rgba(60, 140, 60, 0.12);
|
||||||
|
color: #3c8c3c;
|
||||||
|
}
|
||||||
|
.ofstedGrade3 {
|
||||||
|
background: var(--accent-gold-bg);
|
||||||
|
color: #b8920e;
|
||||||
|
}
|
||||||
|
.ofstedGrade4 {
|
||||||
|
background: var(--accent-coral-bg);
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
/* Report Card grade colours (5-level scale, lower = better) */
|
/* Report Card grade colours (5-level scale, lower = better) */
|
||||||
.rcGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); } /* Exceptional */
|
.rcGrade1 {
|
||||||
.rcGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; } /* Strong */
|
background: var(--accent-teal-bg);
|
||||||
.rcGrade3 { background: var(--accent-gold-bg); color: #b8920e; } /* Expected standard */
|
color: var(--accent-teal, #2d7d7d);
|
||||||
.rcGrade4 { background: rgba(249, 115, 22, 0.12); color: #c2410c; } /* Needs attention */
|
} /* Exceptional */
|
||||||
.rcGrade5 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); } /* Urgent improvement */
|
.rcGrade2 {
|
||||||
|
background: rgba(60, 140, 60, 0.12);
|
||||||
|
color: #3c8c3c;
|
||||||
|
} /* Strong */
|
||||||
|
.rcGrade3 {
|
||||||
|
background: var(--accent-gold-bg);
|
||||||
|
color: #b8920e;
|
||||||
|
} /* Expected standard */
|
||||||
|
.rcGrade4 {
|
||||||
|
background: rgba(249, 115, 22, 0.12);
|
||||||
|
color: #c2410c;
|
||||||
|
} /* Needs attention */
|
||||||
|
.rcGrade5 {
|
||||||
|
background: var(--accent-coral-bg);
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
} /* Urgent improvement */
|
||||||
|
|
||||||
/* Safeguarding value (used inside a standard metricCard) */
|
/* Safeguarding value (used inside a standard metricCard) */
|
||||||
.safeguardingMet {
|
.safeguardingMet {
|
||||||
@@ -664,7 +835,6 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────────────────── */
|
/* ── Responsive ──────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.headerContent {
|
.headerContent {
|
||||||
@@ -685,11 +855,23 @@
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pills wrap horizontally instead of stacking — short tokens like
|
||||||
|
"Manchester" / "Voluntary aided" fit 2 per row instead of 3 full
|
||||||
|
rows of empty horizontal space. */
|
||||||
.meta {
|
.meta {
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Secondary header info (headteacher, website, pupil count, trust)
|
||||||
|
isn't needed above the fold on phones — pupil count lives in the
|
||||||
|
Pupils & Inclusion section, website is one scroll away. Reclaim
|
||||||
|
the ~3 vertical lines so the actual metrics surface sooner. */
|
||||||
|
.headerDetails {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.metricsGrid {
|
.metricsGrid {
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
@@ -698,8 +880,12 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PerformanceChart on mobile now stacks: trend banner → chip subtitle →
|
||||||
|
chip row → canvas → mini-legend → COVID footnote. The container must
|
||||||
|
flow naturally instead of clipping to a fixed 220px — PerformanceChart's
|
||||||
|
own .chartWrapper carries the canvas height. */
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
height: 220px;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapContainer {
|
.mapContainer {
|
||||||
@@ -786,11 +972,21 @@
|
|||||||
/* Hero tone scheme — independent of the .ofstedGrade{N} / .rcGrade{N} badges
|
/* Hero tone scheme — independent of the .ofstedGrade{N} / .rcGrade{N} badges
|
||||||
so the same tone class can be applied to a chip (background tint + border)
|
so the same tone class can be applied to a chip (background tint + border)
|
||||||
or a serif number (colour only) without one bleeding into the other. */
|
or a serif number (colour only) without one bleeding into the other. */
|
||||||
.tone-teal { --hero-tone: var(--accent-teal, #2d7d7d); }
|
.tone-teal {
|
||||||
.tone-green { --hero-tone: #3c8c3c; }
|
--hero-tone: var(--accent-teal, #2d7d7d);
|
||||||
.tone-gold { --hero-tone: var(--accent-gold, #c9a227); }
|
}
|
||||||
.tone-coral { --hero-tone: var(--accent-coral, #e07256); }
|
.tone-green {
|
||||||
.tone-neutral { --hero-tone: var(--text-muted, #8a847a); }
|
--hero-tone: #3c8c3c;
|
||||||
|
}
|
||||||
|
.tone-gold {
|
||||||
|
--hero-tone: var(--accent-gold, #c9a227);
|
||||||
|
}
|
||||||
|
.tone-coral {
|
||||||
|
--hero-tone: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
.tone-neutral {
|
||||||
|
--hero-tone: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
|
|
||||||
.heroChip.tone-teal,
|
.heroChip.tone-teal,
|
||||||
.heroChip.tone-green,
|
.heroChip.tone-green,
|
||||||
@@ -825,7 +1021,7 @@
|
|||||||
|
|
||||||
.heroStatNumber,
|
.heroStatNumber,
|
||||||
.heroStatNumberSerif {
|
.heroStatNumberSerif {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
@@ -885,6 +1081,20 @@
|
|||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapse the "Ofsted pending / No inspection on record" empty state
|
||||||
|
into a single compact line on phones — it's a non-result, not worth
|
||||||
|
a full hero card. */
|
||||||
|
.heroChip[data-ofsted-state="none"] {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
.heroChip[data-ofsted-state="none"] .heroChipSub {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.heroChip[data-ofsted-state="none"] .heroChipTitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
.heroStats {
|
.heroStats {
|
||||||
gap: 1rem 1.5rem;
|
gap: 1rem 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -894,3 +1104,225 @@
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── RWM bridge ("Why is combined lower?") ── */
|
||||||
|
.rwmBridge {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0.5rem 0 1.5rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
background: var(--bg-secondary, #f3ede4);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rwmBridgeIcon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: #fff;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rwmBridgeBody {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rwmBridgeText {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rwmBridgeText strong {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rwmBridgeMath {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-style: italic;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rwmBridgeMath strong {
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rwmBridgeMathSep {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress scores row (below SatsChart) ── */
|
||||||
|
.progressScoresRow {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressScoresGrid {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressScoreItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressScoreLabel {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressScoreValue {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Admissions Q&A list ── */
|
||||||
|
.admissionsQa {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsQaRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.85rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsQaRow:first-child {
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsQaRow:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsQaQuestion {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-secondary, #5c564d);
|
||||||
|
line-height: 1.35;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsQaAnswer {
|
||||||
|
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsQaAnswerSub {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
font-family: var(--font-dm-sans), "DM Sans", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsVerdict {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsVerdictHeadline {
|
||||||
|
font-family: var(--font-playfair), "Playfair Display", Georgia, serif;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-primary, #1a1612);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsVerdictOver {
|
||||||
|
color: var(--accent-coral-dark, #c45a3f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsVerdictUnder {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsVerdictSub {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admissionsQaRow {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.7rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admissionsQaAnswer {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── History accordion ── */
|
||||||
|
.historyDisclosure {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyToggle {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyToggle::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyToggle::before {
|
||||||
|
content: "▸";
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyDisclosure[open] > .historyToggle::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
import { PerformanceChart } from './PerformanceChart';
|
|
||||||
import { SchoolMap } from './SchoolMap';
|
import { SchoolMap } from './SchoolMap';
|
||||||
import { MetricTooltip } from './MetricTooltip';
|
import { MetricTooltip } from './MetricTooltip';
|
||||||
import type {
|
import type {
|
||||||
@@ -22,6 +22,13 @@ import {
|
|||||||
buildOfstedHeroChip,
|
buildOfstedHeroChip,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
|
||||||
|
const PerformanceChart = dynamic(
|
||||||
|
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
const SatsChart = dynamic(() => import('./SatsChart'), { ssr: false });
|
||||||
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
import styles from './SchoolDetailView.module.css';
|
import styles from './SchoolDetailView.module.css';
|
||||||
|
|
||||||
const OFSTED_LABELS: Record<number, string> = {
|
const OFSTED_LABELS: Record<number, string> = {
|
||||||
@@ -74,6 +81,29 @@ export function SchoolDetailView({
|
|||||||
const isInComparison = isSelected(schoolInfo.urn);
|
const isInComparison = isSelected(schoolInfo.urn);
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState<string>('');
|
const [activeSection, setActiveSection] = useState<string>('');
|
||||||
|
const sectionNavRef = useRef<HTMLElement | null>(null);
|
||||||
|
const [sectionNavAtEnd, setSectionNavAtEnd] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sectionNavRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const update = () => {
|
||||||
|
const overflow = el.scrollWidth - el.clientWidth;
|
||||||
|
// No overflow → treat as "at end" so the fade is hidden.
|
||||||
|
if (overflow <= 1) {
|
||||||
|
setSectionNavAtEnd(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSectionNavAtEnd(el.scrollLeft >= overflow - 2);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
el.addEventListener('scroll', update, { passive: true });
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', update);
|
||||||
|
window.removeEventListener('resize', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
const latestResults = yearlyData.length > 0 ? yearlyData[yearlyData.length - 1] : null;
|
||||||
|
|
||||||
@@ -97,22 +127,43 @@ export function SchoolDetailView({
|
|||||||
const handleComparisonToggle = () => {
|
const handleComparisonToggle = () => {
|
||||||
if (isInComparison) {
|
if (isInComparison) {
|
||||||
removeSchool(schoolInfo.urn);
|
removeSchool(schoolInfo.urn);
|
||||||
|
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||||
} else {
|
} else {
|
||||||
addSchool(schoolInfo);
|
addSchool(schoolInfo);
|
||||||
|
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Page-view event with funnel attribution. Fires once per mount.
|
||||||
|
useEffect(() => {
|
||||||
|
track('school_viewed', {
|
||||||
|
urn: schoolInfo.urn,
|
||||||
|
phase: phase || 'unknown',
|
||||||
|
local_authority: schoolInfo.local_authority || 'unknown',
|
||||||
|
from: getNavigationSource(),
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [schoolInfo.urn]);
|
||||||
|
|
||||||
const deprivationDesc = (decile: number) => {
|
const deprivationDesc = (decile: number) => {
|
||||||
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
|
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
|
||||||
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
|
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
|
||||||
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
|
return `This school is in one of England's less deprived areas (decile ${decile}/10).`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gender split availability (only meaningful for Mixed schools with census data)
|
||||||
|
const isMixedSchool = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
|
||||||
|
const hasGenderSplit = isMixedSchool
|
||||||
|
&& census?.female_pupils != null
|
||||||
|
&& census?.male_pupils != null
|
||||||
|
&& (census.female_pupils + census.male_pupils) > 0;
|
||||||
|
|
||||||
// Guard for Pupils & Inclusion — only show if at least one metric is available
|
// Guard for Pupils & Inclusion — only show if at least one metric is available
|
||||||
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|
const hasInclusionData = (latestResults?.disadvantaged_pct != null)
|
||||||
|| (latestResults?.eal_pct != null)
|
|| (latestResults?.eal_pct != null)
|
||||||
|| (latestResults?.sen_support_pct != null)
|
|| (latestResults?.sen_support_pct != null)
|
||||||
|| senDetail != null;
|
|| senDetail != null
|
||||||
|
|| hasGenderSplit;
|
||||||
|
|
||||||
const hasSchoolLife = absenceData != null || census?.class_size_avg != null;
|
const hasSchoolLife = absenceData != null || census?.class_size_avg != null;
|
||||||
const hasPhonics = phonics != null && phonics.year1_phonics_pct != null;
|
const hasPhonics = phonics != null && phonics.year1_phonics_pct != null;
|
||||||
@@ -229,17 +280,27 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
{schoolInfo.website && (
|
{schoolInfo.website && (
|
||||||
<span className={styles.headerDetail}>
|
<span className={styles.headerDetail}>
|
||||||
<a href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="school_website"
|
||||||
|
>
|
||||||
School website ↗
|
School website ↗
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{latestResults?.total_pupils != null && (
|
{(() => {
|
||||||
|
const total = census?.total_pupils ?? latestResults?.total_pupils ?? null;
|
||||||
|
if (total == null) return null;
|
||||||
|
return (
|
||||||
<span className={styles.headerDetail}>
|
<span className={styles.headerDetail}>
|
||||||
<strong>Pupils:</strong> {latestResults.total_pupils.toLocaleString()}
|
<strong>Pupils:</strong> {total.toLocaleString()}
|
||||||
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
{schoolInfo.capacity != null && ` (capacity: ${schoolInfo.capacity})`}
|
||||||
</span>
|
</span>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
{schoolInfo.trust_name && (
|
{schoolInfo.trust_name && (
|
||||||
<span className={styles.headerDetail}>
|
<span className={styles.headerDetail}>
|
||||||
Part of <strong>{schoolInfo.trust_name}</strong>
|
Part of <strong>{schoolInfo.trust_name}</strong>
|
||||||
@@ -259,7 +320,10 @@ export function SchoolDetailView({
|
|||||||
|
|
||||||
{/* Hero signal chip strip */}
|
{/* Hero signal chip strip */}
|
||||||
<div className={styles.heroChips}>
|
<div className={styles.heroChips}>
|
||||||
<div className={`${styles.heroChip} ${styles[`tone-${ofstedHeroChip.tone}`]}`}>
|
<div
|
||||||
|
className={`${styles.heroChip} ${styles[`tone-${ofstedHeroChip.tone}`]}`}
|
||||||
|
data-ofsted-state={ofstedHeroChip.state}
|
||||||
|
>
|
||||||
<div className={styles.heroChipTitle}>{ofstedHeroChip.title}</div>
|
<div className={styles.heroChipTitle}>{ofstedHeroChip.title}</div>
|
||||||
<div className={styles.heroChipSub}>{ofstedHeroChip.subtitle}</div>
|
<div className={styles.heroChipSub}>{ofstedHeroChip.subtitle}</div>
|
||||||
{ofstedHeroChip.detail && (
|
{ofstedHeroChip.detail && (
|
||||||
@@ -343,7 +407,11 @@ export function SchoolDetailView({
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Sticky Section Navigation */}
|
{/* Sticky Section Navigation */}
|
||||||
<nav className={styles.sectionNav} aria-label="Page sections">
|
<nav
|
||||||
|
ref={sectionNavRef}
|
||||||
|
className={`${styles.sectionNav}${sectionNavAtEnd ? ` ${styles.atEnd}` : ''}`}
|
||||||
|
aria-label="Page sections"
|
||||||
|
>
|
||||||
<div className={styles.sectionNavInner}>
|
<div className={styles.sectionNavInner}>
|
||||||
<button onClick={() => router.back()} className={styles.sectionNavBack}>← Back</button>
|
<button onClick={() => router.back()} className={styles.sectionNavBack}>← Back</button>
|
||||||
{navItems.length > 0 && <div className={styles.sectionNavDivider} />}
|
{navItems.length > 0 && <div className={styles.sectionNavDivider} />}
|
||||||
@@ -352,6 +420,7 @@ export function SchoolDetailView({
|
|||||||
key={id}
|
key={id}
|
||||||
href={`#${id}`}
|
href={`#${id}`}
|
||||||
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
|
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
|
||||||
|
onClick={() => track('section_nav_used', { section: id })}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
@@ -374,6 +443,8 @@ export function SchoolDetailView({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={styles.ofstedReportLink}
|
className={styles.ofstedReportLink}
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="ofsted"
|
||||||
>
|
>
|
||||||
Full report ↗
|
Full report ↗
|
||||||
</a>
|
</a>
|
||||||
@@ -514,14 +585,14 @@ export function SchoolDetailView({
|
|||||||
{/* ── Primary / KS2 content ── */}
|
{/* ── Primary / KS2 content ── */}
|
||||||
{hasKS2Results && (
|
{hasKS2Results && (
|
||||||
<>
|
<>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.heroStatGrid}>
|
||||||
{latestResults.rwm_expected_pct !== null && (
|
{latestResults.rwm_expected_pct !== null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.heroStatCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.heroStatLabel}>
|
||||||
Reading, Writing & Maths combined
|
Reading, Writing & Maths combined
|
||||||
<MetricTooltip metricKey="rwm_expected_pct" />
|
<MetricTooltip metricKey="rwm_expected_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.rwm_expected_pct)}
|
{formatPercentage(latestResults.rwm_expected_pct)}
|
||||||
{primaryAvg.rwm_expected_pct != null && (
|
{primaryAvg.rwm_expected_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
@@ -533,17 +604,17 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.rwm_expected_pct != null && (
|
{primaryAvg.rwm_expected_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_expected_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults.rwm_high_pct !== null && (
|
{latestResults.rwm_high_pct !== null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.heroStatCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.heroStatLabel}>
|
||||||
Exceeding expected level (Reading, Writing & Maths)
|
Exceeding expected level (Reading, Writing & Maths)
|
||||||
<MetricTooltip metricKey="rwm_high_pct" />
|
<MetricTooltip metricKey="rwm_high_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.rwm_high_pct)}
|
{formatPercentage(latestResults.rwm_high_pct)}
|
||||||
{primaryAvg.rwm_high_pct != null && (
|
{primaryAvg.rwm_high_pct != null && (
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
@@ -555,133 +626,91 @@ export function SchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.rwm_high_pct != null && (
|
{primaryAvg.rwm_high_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>National avg: {primaryAvg.rwm_high_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.metricGroupsGrid} style={{ marginTop: '1rem' }}>
|
{latestResults.rwm_expected_pct != null &&
|
||||||
<div className={styles.metricGroup}>
|
latestResults.reading_expected_pct != null &&
|
||||||
<h3 className={styles.metricGroupTitle}>Reading</h3>
|
latestResults.writing_expected_pct != null &&
|
||||||
<div className={styles.metricTable}>
|
latestResults.maths_expected_pct != null && (
|
||||||
{latestResults.reading_expected_pct !== null && (
|
<div className={styles.rwmBridge}>
|
||||||
<div className={styles.metricRow}>
|
<span className={styles.rwmBridgeIcon} aria-hidden="true">?</span>
|
||||||
<span className={styles.metricName}>Expected level</span>
|
<div className={styles.rwmBridgeBody}>
|
||||||
<span className={styles.metricValue}>
|
<div className={styles.rwmBridgeText}>
|
||||||
{formatPercentage(latestResults.reading_expected_pct)}
|
Why is combined lower? A pupil is only counted if they met the bar in{' '}
|
||||||
{primaryAvg.reading_expected_pct != null && (
|
<strong>all three</strong> subjects. Some passed reading but not writing; some passed writing but not maths.
|
||||||
<DeltaChip value={latestResults.reading_expected_pct} baseline={primaryAvg.reading_expected_pct} unit="pts" size="sm" />
|
</div>
|
||||||
)}
|
<div className={styles.rwmBridgeMath}>
|
||||||
</span>
|
<span>Reading <strong>{latestResults.reading_expected_pct.toFixed(0)}%</strong></span>
|
||||||
|
<span className={styles.rwmBridgeMathSep}>·</span>
|
||||||
|
<span>Writing <strong>{latestResults.writing_expected_pct.toFixed(0)}%</strong></span>
|
||||||
|
<span className={styles.rwmBridgeMathSep}>·</span>
|
||||||
|
<span>Maths <strong>{latestResults.maths_expected_pct.toFixed(0)}%</strong></span>
|
||||||
|
<span className={styles.rwmBridgeMathSep}>→</span>
|
||||||
|
<span>All three <strong>{latestResults.rwm_expected_pct.toFixed(0)}%</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults.reading_high_pct !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
<SatsChart
|
||||||
<span className={styles.metricName}>Exceeding</span>
|
subjects={[
|
||||||
<span className={styles.metricValue}>{formatPercentage(latestResults.reading_high_pct)}</span>
|
{
|
||||||
</div>
|
name: 'Reading',
|
||||||
)}
|
expectedPct: latestResults.reading_expected_pct,
|
||||||
{latestResults.reading_progress !== null && (
|
exceedingPct: latestResults.reading_high_pct,
|
||||||
<div className={styles.metricRow}>
|
nationalExpectedPct: primaryAvg.reading_expected_pct,
|
||||||
<span className={styles.metricName}>
|
},
|
||||||
Progress score
|
{
|
||||||
<MetricTooltip metricKey="reading_progress" />
|
name: 'Writing',
|
||||||
</span>
|
expectedPct: latestResults.writing_expected_pct,
|
||||||
<span className={`${styles.metricValue} ${progressClass(latestResults.reading_progress)}`}>
|
exceedingPct: latestResults.writing_high_pct,
|
||||||
|
nationalExpectedPct: primaryAvg.writing_expected_pct,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Maths',
|
||||||
|
expectedPct: latestResults.maths_expected_pct,
|
||||||
|
exceedingPct: latestResults.maths_high_pct,
|
||||||
|
nationalExpectedPct: primaryAvg.maths_expected_pct,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Progress scores row */}
|
||||||
|
{(latestResults.reading_progress != null || latestResults.writing_progress != null || latestResults.maths_progress != null) && (
|
||||||
|
<div className={styles.progressScoresRow}>
|
||||||
|
<h3 className={styles.subSectionTitle}>Progress Scores</h3>
|
||||||
|
<div className={styles.progressScoresGrid}>
|
||||||
|
{latestResults.reading_progress != null && (
|
||||||
|
<div className={styles.progressScoreItem}>
|
||||||
|
<span className={styles.progressScoreLabel}>Reading</span>
|
||||||
|
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.reading_progress)}`}>
|
||||||
{formatProgress(latestResults.reading_progress)}
|
{formatProgress(latestResults.reading_progress)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults.reading_avg_score !== null && (
|
{latestResults.writing_progress != null && (
|
||||||
<div className={styles.metricRow}>
|
<div className={styles.progressScoreItem}>
|
||||||
<span className={styles.metricName}>
|
<span className={styles.progressScoreLabel}>Writing</span>
|
||||||
Average score
|
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.writing_progress)}`}>
|
||||||
<MetricTooltip metricKey="reading_avg_score" />
|
|
||||||
</span>
|
|
||||||
<span className={styles.metricValue}>{latestResults.reading_avg_score.toFixed(1)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.metricGroup}>
|
|
||||||
<h3 className={styles.metricGroupTitle}>Writing</h3>
|
|
||||||
<div className={styles.metricTable}>
|
|
||||||
{latestResults.writing_expected_pct !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>Expected level</span>
|
|
||||||
<span className={styles.metricValue}>
|
|
||||||
{formatPercentage(latestResults.writing_expected_pct)}
|
|
||||||
{primaryAvg.writing_expected_pct != null && (
|
|
||||||
<DeltaChip value={latestResults.writing_expected_pct} baseline={primaryAvg.writing_expected_pct} unit="pts" size="sm" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.writing_high_pct !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>Exceeding</span>
|
|
||||||
<span className={styles.metricValue}>{formatPercentage(latestResults.writing_high_pct)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.writing_progress !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>
|
|
||||||
Progress score
|
|
||||||
<MetricTooltip metricKey="writing_progress" />
|
|
||||||
</span>
|
|
||||||
<span className={`${styles.metricValue} ${progressClass(latestResults.writing_progress)}`}>
|
|
||||||
{formatProgress(latestResults.writing_progress)}
|
{formatProgress(latestResults.writing_progress)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{latestResults.maths_progress != null && (
|
||||||
</div>
|
<div className={styles.progressScoreItem}>
|
||||||
|
<span className={styles.progressScoreLabel}>Maths</span>
|
||||||
<div className={styles.metricGroup}>
|
<span className={`${styles.progressScoreValue} ${progressClass(latestResults.maths_progress)}`}>
|
||||||
<h3 className={styles.metricGroupTitle}>Maths</h3>
|
|
||||||
<div className={styles.metricTable}>
|
|
||||||
{latestResults.maths_expected_pct !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>Expected level</span>
|
|
||||||
<span className={styles.metricValue}>
|
|
||||||
{formatPercentage(latestResults.maths_expected_pct)}
|
|
||||||
{primaryAvg.maths_expected_pct != null && (
|
|
||||||
<DeltaChip value={latestResults.maths_expected_pct} baseline={primaryAvg.maths_expected_pct} unit="pts" size="sm" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.maths_high_pct !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>Exceeding</span>
|
|
||||||
<span className={styles.metricValue}>{formatPercentage(latestResults.maths_high_pct)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{latestResults.maths_progress !== null && (
|
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>
|
|
||||||
Progress score
|
|
||||||
<MetricTooltip metricKey="maths_progress" />
|
|
||||||
</span>
|
|
||||||
<span className={`${styles.metricValue} ${progressClass(latestResults.maths_progress)}`}>
|
|
||||||
{formatProgress(latestResults.maths_progress)}
|
{formatProgress(latestResults.maths_progress)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults.maths_avg_score !== null && (
|
</div>
|
||||||
<div className={styles.metricRow}>
|
|
||||||
<span className={styles.metricName}>
|
|
||||||
Average score
|
|
||||||
<MetricTooltip metricKey="maths_avg_score" />
|
|
||||||
</span>
|
|
||||||
<span className={styles.metricValue}>{latestResults.maths_avg_score.toFixed(1)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
|
{(latestResults.reading_progress !== null || latestResults.writing_progress !== null || latestResults.maths_progress !== null) && (
|
||||||
<p className={styles.progressNote}>
|
<p className={styles.progressNote}>
|
||||||
@@ -852,33 +881,59 @@ export function SchoolDetailView({
|
|||||||
{admissions && (
|
{admissions && (
|
||||||
<section id="admissions" className={styles.card}>
|
<section id="admissions" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({formatAcademicYear(admissions.year)})</h2>
|
<h2 className={styles.sectionTitle}>How Hard to Get Into This School ({formatAcademicYear(admissions.year)})</h2>
|
||||||
|
|
||||||
{admissions.oversubscribed != null && (
|
{admissions.oversubscribed != null && (
|
||||||
<div className={`${styles.admissionsBadge} ${admissions.oversubscribed ? styles.statusWarn : styles.statusGood}`}>
|
<div className={styles.admissionsVerdict}>
|
||||||
{admissions.oversubscribed
|
<div className={styles.admissionsVerdictHeadline}>
|
||||||
? '⚠ Oversubscribed'
|
This school is{' '}
|
||||||
: '✓ Not Oversubscribed'}
|
<span className={admissions.oversubscribed ? styles.admissionsVerdictOver : styles.admissionsVerdictUnder}>
|
||||||
|
{admissions.oversubscribed ? 'oversubscribed' : 'not oversubscribed'}
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
<div className={styles.admissionsVerdictSub}>
|
||||||
|
{admissions.oversubscribed ? 'Demand exceeds capacity.' : 'Supply meets demand.'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={styles.metricsGrid}>
|
|
||||||
{admissions.published_admission_number != null && (
|
<dl className={styles.admissionsQa}>
|
||||||
<div className={styles.metricCard}>
|
{admissions.places_offered != null && (
|
||||||
<div className={styles.metricLabel}>{isSecondary ? 'Year 7' : 'Reception'} places per year</div>
|
<div className={styles.admissionsQaRow}>
|
||||||
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
|
<dt className={styles.admissionsQaQuestion}>How many places were offered?</dt>
|
||||||
|
<dd className={styles.admissionsQaAnswer}>{admissions.places_offered}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions.total_applications != null && (
|
{admissions.first_preference_applications != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.admissionsQaRow}>
|
||||||
<div className={styles.metricLabel}>Applications received</div>
|
<dt className={styles.admissionsQaQuestion}>How many families wanted this school first?</dt>
|
||||||
<div className={styles.metricValue}>{admissions.total_applications.toLocaleString()}</div>
|
<dd className={styles.admissionsQaAnswer}>{admissions.first_preference_applications}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions.first_preference_offer_pct != null && (
|
{admissions.first_preference_offer_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.admissionsQaRow}>
|
||||||
<div className={styles.metricLabel}>Families who got their first-choice</div>
|
<dt className={styles.admissionsQaQuestion}>How many got their first choice?</dt>
|
||||||
<div className={styles.metricValue}>{formatPercentage(admissions.first_preference_offer_pct)}</div>
|
<dd className={styles.admissionsQaAnswer}>
|
||||||
|
{admissions.first_preference_offers != null && admissions.first_preference_applications != null ? (
|
||||||
|
<>
|
||||||
|
{admissions.first_preference_offers}
|
||||||
|
<span className={styles.admissionsQaAnswerSub}>
|
||||||
|
of {admissions.first_preference_applications} ({formatPercentage(admissions.first_preference_offer_pct)})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
formatPercentage(admissions.first_preference_offer_pct)
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{admissions.total_applications != null && (
|
||||||
|
<div className={styles.admissionsQaRow}>
|
||||||
|
<dt className={styles.admissionsQaQuestion}>How many applied in total?</dt>
|
||||||
|
<dd className={styles.admissionsQaAnswer}>{admissions.total_applications.toLocaleString()}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -886,45 +941,82 @@ export function SchoolDetailView({
|
|||||||
{hasInclusionData && (
|
{hasInclusionData && (
|
||||||
<section id="inclusion" className={styles.card}>
|
<section id="inclusion" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>Pupils & Inclusion</h2>
|
<h2 className={styles.sectionTitle}>Pupils & Inclusion</h2>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.heroStatGrid}>
|
||||||
{latestResults?.disadvantaged_pct != null && (
|
{latestResults?.disadvantaged_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.heroStatCard}>
|
||||||
<div className={styles.metricLabel}>Eligible for pupil premium</div>
|
<div className={styles.heroStatLabel}>Eligible for pupil premium</div>
|
||||||
<div className={styles.metricValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.disadvantaged_pct)}
|
{formatPercentage(latestResults.disadvantaged_pct)}
|
||||||
{primaryAvg.disadvantaged_pct != null && (
|
{primaryAvg.disadvantaged_pct != null && (
|
||||||
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
|
<DeltaChip value={latestResults.disadvantaged_pct} baseline={primaryAvg.disadvantaged_pct} unit="pts" size="sm" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
<div className={styles.heroStatHint}>Pupils from disadvantaged backgrounds{primaryAvg.disadvantaged_pct != null ? ` · national avg: ${primaryAvg.disadvantaged_pct.toFixed(0)}%` : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults?.eal_pct != null && (
|
{latestResults?.eal_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.heroStatCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.heroStatLabel}>
|
||||||
English as an additional language
|
English as an additional language
|
||||||
<MetricTooltip metricKey="eal_pct" />
|
<MetricTooltip metricKey="eal_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.eal_pct)}</div>
|
<div className={styles.heroStatValue}>
|
||||||
|
{formatPercentage(latestResults.eal_pct)}
|
||||||
|
{primaryAvg.eal_pct != null && (
|
||||||
|
<DeltaChip value={latestResults.eal_pct} baseline={primaryAvg.eal_pct} unit="pts" size="sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{primaryAvg.eal_pct != null && (
|
||||||
|
<div className={styles.heroStatHint}>National avg: {primaryAvg.eal_pct.toFixed(0)}%</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults?.sen_support_pct != null && (
|
{latestResults?.sen_support_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.heroStatCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.heroStatLabel}>
|
||||||
Pupils receiving SEN support
|
Pupils receiving SEN support
|
||||||
<MetricTooltip metricKey="sen_support_pct" />
|
<MetricTooltip metricKey="sen_support_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>
|
<div className={styles.heroStatValue}>
|
||||||
{formatPercentage(latestResults.sen_support_pct)}
|
{formatPercentage(latestResults.sen_support_pct)}
|
||||||
{primaryAvg.sen_support_pct != null && (
|
{primaryAvg.sen_support_pct != null && (
|
||||||
<DeltaChip value={latestResults.sen_support_pct} baseline={primaryAvg.sen_support_pct} unit="pts" size="sm" />
|
<DeltaChip value={latestResults.sen_support_pct} baseline={primaryAvg.sen_support_pct} unit="pts" size="sm" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{primaryAvg.sen_support_pct != null && (
|
{primaryAvg.sen_support_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
<div className={styles.heroStatHint}>National avg: {primaryAvg.sen_support_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hasGenderSplit && (() => {
|
||||||
|
const female = census!.female_pupils!;
|
||||||
|
const male = census!.male_pupils!;
|
||||||
|
const girlsPct = Math.round((female / (female + male)) * 100);
|
||||||
|
const boysPct = 100 - girlsPct;
|
||||||
|
return (
|
||||||
|
<div className={styles.heroStatCard}>
|
||||||
|
<div className={styles.heroStatLabel}>Boys and girls</div>
|
||||||
|
<div className={styles.genderSplitValue}>
|
||||||
|
<span className={styles.genderSplitGirls}>{girlsPct}%</span>
|
||||||
|
<span className={styles.genderSplitLabel}>girls</span>
|
||||||
|
<span className={styles.genderSplitSep}>·</span>
|
||||||
|
<span className={styles.genderSplitBoys}>{boysPct}%</span>
|
||||||
|
<span className={styles.genderSplitLabel}>boys</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={styles.genderBar}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
|
||||||
|
>
|
||||||
|
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
|
||||||
|
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroStatHint}>
|
||||||
|
{female.toLocaleString()} girls, {male.toLocaleString()} boys
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{senDetail && (
|
{senDetail && (
|
||||||
<>
|
<>
|
||||||
@@ -1034,8 +1126,8 @@ export function SchoolDetailView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{yearlyData.length > 1 && (
|
{yearlyData.length > 1 && (
|
||||||
<>
|
<details className={styles.historyDisclosure}>
|
||||||
<p className={styles.historicalSubtitle}>Detailed year-by-year figures</p>
|
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||||
<div className={styles.tableWrapper}>
|
<div className={styles.tableWrapper}>
|
||||||
<table className={styles.dataTable}>
|
<table className={styles.dataTable}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -1084,7 +1176,7 @@ export function SchoolDetailView({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</details>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
.mapWrapper.fullscreen {
|
.mapWrapper.fullscreen {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fullscreenBtn {
|
.fullscreenBtn {
|
||||||
|
|||||||
@@ -225,8 +225,14 @@
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Promote the headline metric onto its own line; secondary stats
|
||||||
|
(delta vs LA, pupils) wrap below with a visible row gap. */
|
||||||
.line3 {
|
.line3 {
|
||||||
gap: 0 1rem;
|
row-gap: 0.25rem;
|
||||||
|
column-gap: 1rem;
|
||||||
|
}
|
||||||
|
.line3 > .stat:first-child {
|
||||||
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowActions {
|
.rowActions {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Modal } from "./Modal";
|
|||||||
import { useComparison } from "@/hooks/useComparison";
|
import { useComparison } from "@/hooks/useComparison";
|
||||||
import { debounce } from "@/lib/utils";
|
import { debounce } from "@/lib/utils";
|
||||||
import { fetchSchools } from "@/lib/api";
|
import { fetchSchools } from "@/lib/api";
|
||||||
|
import { track } from "@/lib/analytics";
|
||||||
import type { School } from "@/lib/types";
|
import type { School } from "@/lib/types";
|
||||||
import styles from "./SchoolSearchModal.module.css";
|
import styles from "./SchoolSearchModal.module.css";
|
||||||
|
|
||||||
@@ -60,6 +61,11 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
|
|
||||||
const handleAddSchool = (school: School) => {
|
const handleAddSchool = (school: School) => {
|
||||||
addSchool(school);
|
addSchool(school);
|
||||||
|
track('compare_school_added', {
|
||||||
|
urn: school.urn,
|
||||||
|
from: 'compare',
|
||||||
|
selection_count_after: selectedSchools.length + 1,
|
||||||
|
});
|
||||||
// Don't close modal, allow adding multiple schools
|
// Don't close modal, allow adding multiple schools
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
/* ── Tab Navigation (sticky) ─────────────────────────── */
|
/* ── Tab Navigation (sticky) ─────────────────────────── */
|
||||||
.tabNav {
|
.tabNav {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 3.5rem;
|
top: 4rem;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
background: var(--bg-card, white);
|
background: var(--bg-card, white);
|
||||||
border: 1px solid var(--border-color, #e5dfd5);
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
margin-bottom: 0.875rem;
|
margin-bottom: 0.875rem;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
border-bottom: 2px solid var(--border-color, #e5dfd5);
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle::before {
|
.sectionTitle::before {
|
||||||
content: '';
|
content: "";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
@@ -469,16 +469,43 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ofstedGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
.ofstedGrade1 {
|
||||||
.ofstedGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
background: var(--accent-teal-bg);
|
||||||
.ofstedGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
|
color: var(--accent-teal, #2d7d7d);
|
||||||
.ofstedGrade4 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
}
|
||||||
|
.ofstedGrade2 {
|
||||||
|
background: rgba(60, 140, 60, 0.12);
|
||||||
|
color: #3c8c3c;
|
||||||
|
}
|
||||||
|
.ofstedGrade3 {
|
||||||
|
background: var(--accent-gold-bg);
|
||||||
|
color: #b8920e;
|
||||||
|
}
|
||||||
|
.ofstedGrade4 {
|
||||||
|
background: var(--accent-coral-bg);
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
.rcGrade1 { background: var(--accent-teal-bg); color: var(--accent-teal, #2d7d7d); }
|
.rcGrade1 {
|
||||||
.rcGrade2 { background: rgba(60, 140, 60, 0.12); color: #3c8c3c; }
|
background: var(--accent-teal-bg);
|
||||||
.rcGrade3 { background: var(--accent-gold-bg); color: #b8920e; }
|
color: var(--accent-teal, #2d7d7d);
|
||||||
.rcGrade4 { background: rgba(249, 115, 22, 0.12); color: #c2410c; }
|
}
|
||||||
.rcGrade5 { background: var(--accent-coral-bg); color: var(--accent-coral, #e07256); }
|
.rcGrade2 {
|
||||||
|
background: rgba(60, 140, 60, 0.12);
|
||||||
|
color: #3c8c3c;
|
||||||
|
}
|
||||||
|
.rcGrade3 {
|
||||||
|
background: var(--accent-gold-bg);
|
||||||
|
color: #b8920e;
|
||||||
|
}
|
||||||
|
.rcGrade4 {
|
||||||
|
background: rgba(249, 115, 22, 0.12);
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
.rcGrade5 {
|
||||||
|
background: var(--accent-coral-bg);
|
||||||
|
color: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
.safeguardingMet {
|
.safeguardingMet {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -718,11 +745,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hero tone system */
|
/* Hero tone system */
|
||||||
.tone-teal { --hero-tone: var(--accent-teal, #2d7d7d); }
|
.tone-teal {
|
||||||
.tone-green { --hero-tone: #3c8c3c; }
|
--hero-tone: var(--accent-teal, #2d7d7d);
|
||||||
.tone-gold { --hero-tone: var(--accent-gold, #c9a227); }
|
}
|
||||||
.tone-coral { --hero-tone: var(--accent-coral, #e07256); }
|
.tone-green {
|
||||||
.tone-neutral { --hero-tone: var(--text-muted, #8a847a); }
|
--hero-tone: #3c8c3c;
|
||||||
|
}
|
||||||
|
.tone-gold {
|
||||||
|
--hero-tone: var(--accent-gold, #c9a227);
|
||||||
|
}
|
||||||
|
.tone-coral {
|
||||||
|
--hero-tone: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
.tone-neutral {
|
||||||
|
--hero-tone: var(--text-muted, #8a847a);
|
||||||
|
}
|
||||||
|
|
||||||
.heroChip.tone-teal,
|
.heroChip.tone-teal,
|
||||||
.heroChip.tone-green,
|
.heroChip.tone-green,
|
||||||
@@ -757,7 +794,7 @@
|
|||||||
|
|
||||||
.heroStatNumber,
|
.heroStatNumber,
|
||||||
.heroStatNumberSerif {
|
.heroStatNumberSerif {
|
||||||
font-family: var(--font-playfair), 'Playfair Display', serif;
|
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--text-primary, #1a1612);
|
color: var(--text-primary, #1a1612);
|
||||||
@@ -802,6 +839,248 @@
|
|||||||
color: var(--text-muted, #8a847a);
|
color: var(--text-muted, #8a847a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── GCSE hero stat cards (mirrors primary heroStatCard) ─ */
|
||||||
|
.heroStatGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatCard {
|
||||||
|
background: rgba(45, 125, 125, 0.1);
|
||||||
|
border: 1px solid rgba(45, 125, 125, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatCard .heroStatLabel {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatCard .heroStatValue {
|
||||||
|
font-family: var(--font-playfair), "Playfair Display", serif;
|
||||||
|
font-size: 2.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatCard .heroStatHint {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
font-style: normal;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gender split — attached beneath the Total pupils stat value */
|
||||||
|
.genderBar {
|
||||||
|
display: flex;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderBarGirls {
|
||||||
|
background: #b45778;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderBarBoys {
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderSplitHint {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderSplitGirls {
|
||||||
|
color: #b45778;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderSplitBoys {
|
||||||
|
color: var(--accent-teal, #2d7d7d);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genderSplitSep {
|
||||||
|
color: var(--border-color, #e5dfd5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Attainment 8 visual bar ─────────────────────────── */
|
||||||
|
.att8Viz {
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8VizLabel {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8VizTrack {
|
||||||
|
position: relative;
|
||||||
|
height: 14px;
|
||||||
|
background: rgba(45, 125, 125, 0.08);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8VizFill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8VizNatLine {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
bottom: -4px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
border-radius: 2px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8VizNatPill {
|
||||||
|
position: absolute;
|
||||||
|
top: -20px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.att8VizTicks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress 8 number line ──────────────────────────── */
|
||||||
|
.p8Viz {
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p8VizLabel {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p8VizTrack {
|
||||||
|
position: relative;
|
||||||
|
height: 14px;
|
||||||
|
background: rgba(45, 125, 125, 0.06);
|
||||||
|
border: 1px solid var(--border-color, #e5dfd5);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p8VizCi {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(45, 125, 125, 0.18);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p8VizZero {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
bottom: -4px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border-color, #e5dfd5);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p8VizDot {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-teal, #2d7d7d);
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p8VizDotNeg {
|
||||||
|
background: var(--accent-coral, #e07256);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p8VizTicks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── History accordion ───────────────────────────────── */
|
||||||
|
.historyDisclosure {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyToggle {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted, #6d685f);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyToggle::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyToggle::before {
|
||||||
|
content: "▶";
|
||||||
|
font-size: 0.6rem;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historyDisclosure[open] .historyToggle::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────────────────── */
|
/* ── Responsive ──────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.header {
|
.header {
|
||||||
@@ -848,6 +1127,14 @@
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heroStatGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heroStatCard .heroStatValue {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.chartContainer {
|
.chartContainer {
|
||||||
height: 220px;
|
height: 220px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,15 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { useComparison } from '@/hooks/useComparison';
|
import { useComparison } from '@/hooks/useComparison';
|
||||||
import { PerformanceChart } from './PerformanceChart';
|
|
||||||
import { MetricTooltip } from './MetricTooltip';
|
import { MetricTooltip } from './MetricTooltip';
|
||||||
import { SchoolMap } from './SchoolMap';
|
import { SchoolMap } from './SchoolMap';
|
||||||
|
|
||||||
|
const PerformanceChart = dynamic(
|
||||||
|
() => import('./PerformanceChart').then((m) => m.PerformanceChart),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
import type {
|
import type {
|
||||||
School, SchoolResult, AbsenceData,
|
School, SchoolResult, AbsenceData,
|
||||||
OfstedInspection, OfstedParentView, SchoolCensus,
|
OfstedInspection, OfstedParentView, SchoolCensus,
|
||||||
@@ -20,6 +25,7 @@ import type {
|
|||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, buildOfstedHeroChip } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, buildOfstedHeroChip } from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
import styles from './SecondarySchoolDetailView.module.css';
|
import styles from './SecondarySchoolDetailView.module.css';
|
||||||
|
|
||||||
const OFSTED_LABELS: Record<number, string> = {
|
const OFSTED_LABELS: Record<number, string> = {
|
||||||
@@ -70,7 +76,7 @@ interface SecondarySchoolDetailViewProps {
|
|||||||
|
|
||||||
export function SecondarySchoolDetailView({
|
export function SecondarySchoolDetailView({
|
||||||
schoolInfo, yearlyData,
|
schoolInfo, yearlyData,
|
||||||
ofsted, parentView, admissions, senDetail, deprivation, finance, absenceData,
|
ofsted, parentView, census, admissions, senDetail, deprivation, finance, absenceData,
|
||||||
}: SecondarySchoolDetailViewProps) {
|
}: SecondarySchoolDetailViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { addSchool, removeSchool, isSelected } = useComparison();
|
const { addSchool, removeSchool, isSelected } = useComparison();
|
||||||
@@ -111,11 +117,23 @@ export function SecondarySchoolDetailView({
|
|||||||
const handleComparisonToggle = () => {
|
const handleComparisonToggle = () => {
|
||||||
if (isInComparison) {
|
if (isInComparison) {
|
||||||
removeSchool(schoolInfo.urn);
|
removeSchool(schoolInfo.urn);
|
||||||
|
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||||
} else {
|
} else {
|
||||||
addSchool(schoolInfo);
|
addSchool(schoolInfo);
|
||||||
|
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
track('school_viewed', {
|
||||||
|
urn: schoolInfo.urn,
|
||||||
|
phase: schoolInfo.phase || 'secondary',
|
||||||
|
local_authority: schoolInfo.local_authority || 'unknown',
|
||||||
|
from: getNavigationSource(),
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [schoolInfo.urn]);
|
||||||
|
|
||||||
// Build nav items dynamically based on available data
|
// Build nav items dynamically based on available data
|
||||||
const navItems: { id: string; label: string }[] = [];
|
const navItems: { id: string; label: string }[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
@@ -210,7 +228,13 @@ export function SecondarySchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
{schoolInfo.website && (
|
{schoolInfo.website && (
|
||||||
<span className={styles.headerDetail}>
|
<span className={styles.headerDetail}>
|
||||||
<a href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="school_website"
|
||||||
|
>
|
||||||
School website ↗
|
School website ↗
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
@@ -318,6 +342,7 @@ export function SecondarySchoolDetailView({
|
|||||||
key={id}
|
key={id}
|
||||||
href={`#${id}`}
|
href={`#${id}`}
|
||||||
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
|
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
|
||||||
|
onClick={() => track('section_nav_used', { section: id })}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
@@ -340,6 +365,8 @@ export function SecondarySchoolDetailView({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={styles.ofstedReportLink}
|
className={styles.ofstedReportLink}
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="ofsted"
|
||||||
>
|
>
|
||||||
Full report ↗
|
Full report ↗
|
||||||
</a>
|
</a>
|
||||||
@@ -494,61 +521,153 @@ export function SecondarySchoolDetailView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.metricsGrid}>
|
{/* Hero stat cards — top GCSE metrics */}
|
||||||
|
<div className={styles.heroStatGrid}>
|
||||||
{latestResults.attainment_8_score != null && (
|
{latestResults.attainment_8_score != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.heroStatCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.heroStatLabel}>
|
||||||
Attainment 8
|
Attainment 8 score
|
||||||
<MetricTooltip metricKey="attainment_8_score" />
|
<MetricTooltip metricKey="attainment_8_score" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{latestResults.attainment_8_score.toFixed(1)}</div>
|
<div className={styles.heroStatValue}>
|
||||||
|
{latestResults.attainment_8_score.toFixed(1)}
|
||||||
{secondaryAvg.attainment_8_score != null && (
|
{secondaryAvg.attainment_8_score != null && (
|
||||||
<div className={styles.metricHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
<DeltaChip
|
||||||
|
value={latestResults.attainment_8_score}
|
||||||
|
baseline={secondaryAvg.attainment_8_score}
|
||||||
|
unit="pts"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{secondaryAvg.attainment_8_score != null && (
|
||||||
|
<div className={styles.heroStatHint}>National avg: {secondaryAvg.attainment_8_score.toFixed(1)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults.progress_8_score != null && (
|
{latestResults.progress_8_score != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.heroStatCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.heroStatLabel}>
|
||||||
Progress 8
|
Progress 8 score
|
||||||
<MetricTooltip metricKey="progress_8_score" />
|
<MetricTooltip metricKey="progress_8_score" />
|
||||||
</div>
|
</div>
|
||||||
<div className={`${styles.metricValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
|
<div className={`${styles.heroStatValue} ${progressClass(latestResults.progress_8_score, styles)}`}>
|
||||||
{formatProgress(latestResults.progress_8_score)}
|
{formatProgress(latestResults.progress_8_score)}
|
||||||
</div>
|
</div>
|
||||||
{(latestResults.progress_8_lower_ci != null || latestResults.progress_8_upper_ci != null) && (
|
{(latestResults.progress_8_lower_ci != null && latestResults.progress_8_upper_ci != null) ? (
|
||||||
<div className={styles.metricHint}>
|
<div className={styles.heroStatHint}>
|
||||||
CI: {latestResults.progress_8_lower_ci?.toFixed(2) ?? '?'} to {latestResults.progress_8_upper_ci?.toFixed(2) ?? '?'}
|
CI: {latestResults.progress_8_lower_ci.toFixed(2)} to {latestResults.progress_8_upper_ci.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<div className={styles.heroStatHint}>National baseline: 0.0</div>
|
||||||
)}
|
|
||||||
{latestResults.english_maths_standard_pass_pct != null && (
|
|
||||||
<div className={styles.metricCard}>
|
|
||||||
<div className={styles.metricLabel}>
|
|
||||||
English & Maths Grade 4+
|
|
||||||
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
|
||||||
</div>
|
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_standard_pass_pct)}</div>
|
|
||||||
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
|
||||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults.english_maths_strong_pass_pct != null && (
|
{latestResults.english_maths_strong_pass_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.heroStatCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.heroStatLabel}>
|
||||||
English & Maths Grade 5+
|
English & Maths Grade 5+
|
||||||
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
<MetricTooltip metricKey="english_maths_strong_pass_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.english_maths_strong_pass_pct)}</div>
|
<div className={styles.heroStatValue}>
|
||||||
|
{formatPercentage(latestResults.english_maths_strong_pass_pct)}
|
||||||
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
<div className={styles.metricHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
<DeltaChip
|
||||||
|
value={latestResults.english_maths_strong_pass_pct}
|
||||||
|
baseline={secondaryAvg.english_maths_strong_pass_pct}
|
||||||
|
unit="pts"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{secondaryAvg.english_maths_strong_pass_pct != null && (
|
||||||
|
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_strong_pass_pct.toFixed(0)}%</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResults.english_maths_standard_pass_pct != null && (
|
||||||
|
<div className={styles.heroStatCard}>
|
||||||
|
<div className={styles.heroStatLabel}>
|
||||||
|
English & Maths Grade 4+
|
||||||
|
<MetricTooltip metricKey="english_maths_standard_pass_pct" />
|
||||||
|
</div>
|
||||||
|
<div className={styles.heroStatValue}>
|
||||||
|
{formatPercentage(latestResults.english_maths_standard_pass_pct)}
|
||||||
|
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
|
<DeltaChip
|
||||||
|
value={latestResults.english_maths_standard_pass_pct}
|
||||||
|
baseline={secondaryAvg.english_maths_standard_pass_pct}
|
||||||
|
unit="pts"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{secondaryAvg.english_maths_standard_pass_pct != null && (
|
||||||
|
<div className={styles.heroStatHint}>National avg: {secondaryAvg.english_maths_standard_pass_pct.toFixed(0)}%</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Attainment 8 visual bar (0–80 scale) */}
|
||||||
|
{latestResults.attainment_8_score != null && (
|
||||||
|
<div className={styles.att8Viz}>
|
||||||
|
<div className={styles.att8VizLabel}>Attainment 8 — school vs national</div>
|
||||||
|
<div className={styles.att8VizTrack}>
|
||||||
|
<div
|
||||||
|
className={styles.att8VizFill}
|
||||||
|
style={{ width: `${Math.min((latestResults.attainment_8_score / 80) * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
|
{secondaryAvg.attainment_8_score != null && (
|
||||||
|
<div
|
||||||
|
className={styles.att8VizNatLine}
|
||||||
|
style={{ left: `${(secondaryAvg.attainment_8_score / 80) * 100}%` }}
|
||||||
|
>
|
||||||
|
<div className={styles.att8VizNatPill}>
|
||||||
|
Nat avg {secondaryAvg.attainment_8_score.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.att8VizTicks}>
|
||||||
|
<span>0</span><span>20</span><span>40</span><span>60</span><span>80</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress 8 number line with CI */}
|
||||||
|
{latestResults.progress_8_score != null && !p8Suspended && (
|
||||||
|
<div className={styles.p8Viz}>
|
||||||
|
<div className={styles.p8VizLabel}>Progress 8 — relative to national baseline (0)</div>
|
||||||
|
{(() => {
|
||||||
|
const p8 = latestResults.progress_8_score!;
|
||||||
|
const lo = latestResults.progress_8_lower_ci ?? p8;
|
||||||
|
const hi = latestResults.progress_8_upper_ci ?? p8;
|
||||||
|
const range = 6; // −3 to +3
|
||||||
|
const toX = (v: number) => `${Math.min(Math.max(((v + 3) / range) * 100, 0), 100)}%`;
|
||||||
|
return (
|
||||||
|
<div className={styles.p8VizTrack}>
|
||||||
|
{/* CI band */}
|
||||||
|
<div
|
||||||
|
className={styles.p8VizCi}
|
||||||
|
style={{ left: toX(lo), width: `calc(${toX(hi)} - ${toX(lo)})` }}
|
||||||
|
/>
|
||||||
|
{/* Zero line */}
|
||||||
|
<div className={styles.p8VizZero} style={{ left: toX(0) }} />
|
||||||
|
{/* Score dot */}
|
||||||
|
<div
|
||||||
|
className={`${styles.p8VizDot} ${p8 < 0 ? styles.p8VizDotNeg : ''}`}
|
||||||
|
style={{ left: toX(p8) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div className={styles.p8VizTicks}>
|
||||||
|
<span>−3</span><span>−2</span><span>−1</span><span>0</span><span>+1</span><span>+2</span><span>+3</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Progress 8 component breakdown */}
|
{/* Progress 8 component breakdown */}
|
||||||
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
|
{(latestResults.progress_8_english != null || latestResults.progress_8_maths != null ||
|
||||||
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
|
latestResults.progress_8_ebacc != null || latestResults.progress_8_open != null) && (
|
||||||
@@ -608,21 +727,6 @@ export function SecondarySchoolDetailView({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Performance chart */}
|
|
||||||
{yearlyData.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
|
|
||||||
<div className={styles.chartContainer}>
|
|
||||||
<PerformanceChart
|
|
||||||
data={yearlyData}
|
|
||||||
schoolName={schoolInfo.school_name}
|
|
||||||
isSecondary={true}
|
|
||||||
nationalAtt8Avg={heroAtt8Nat}
|
|
||||||
nationalByYear={nationalAvg?.by_year}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -641,10 +745,10 @@ export function SecondarySchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.metricsGrid}>
|
||||||
{admissions.published_admission_number != null && (
|
{admissions.places_offered != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.metricCard}>
|
||||||
<div className={styles.metricLabel}>Year 7 places per year (PAN)</div>
|
<div className={styles.metricLabel}>Year 7 places offered</div>
|
||||||
<div className={styles.metricValue}>{admissions.published_admission_number}</div>
|
<div className={styles.metricValue}>{admissions.places_offered}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{admissions.total_applications != null && (
|
{admissions.total_applications != null && (
|
||||||
@@ -695,36 +799,64 @@ export function SecondarySchoolDetailView({
|
|||||||
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
|
{(latestResults?.sen_support_pct != null || latestResults?.sen_ehcp_pct != null) && (
|
||||||
<>
|
<>
|
||||||
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
|
<h3 className={styles.subSectionTitle}>Special Educational Needs (SEN)</h3>
|
||||||
<div className={styles.metricsGrid}>
|
<div className={styles.heroStatGrid}>
|
||||||
{latestResults?.sen_support_pct != null && (
|
{latestResults?.sen_support_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.heroStatCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.heroStatLabel}>
|
||||||
Pupils receiving SEN support
|
SEN support
|
||||||
<MetricTooltip metricKey="sen_support_pct" />
|
<MetricTooltip metricKey="sen_support_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
|
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_support_pct)}</div>
|
||||||
<div className={styles.metricHint}>SEN support without an EHCP</div>
|
<div className={styles.heroStatHint}>Without an EHCP</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{latestResults?.sen_ehcp_pct != null && (
|
{latestResults?.sen_ehcp_pct != null && (
|
||||||
<div className={styles.metricCard}>
|
<div className={styles.heroStatCard}>
|
||||||
<div className={styles.metricLabel}>
|
<div className={styles.heroStatLabel}>
|
||||||
Pupils with an EHCP
|
Pupils with EHCP
|
||||||
<MetricTooltip metricKey="sen_ehcp_pct" />
|
<MetricTooltip metricKey="sen_ehcp_pct" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.metricValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
|
<div className={styles.heroStatValue}>{formatPercentage(latestResults.sen_ehcp_pct)}</div>
|
||||||
<div className={styles.metricHint}>Education, Health and Care Plan</div>
|
<div className={styles.heroStatHint}>Education, Health and Care Plan</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(schoolInfo.total_pupils != null || latestResults?.total_pupils != null) && (
|
{(() => {
|
||||||
<div className={styles.metricCard}>
|
const total = census?.total_pupils ?? schoolInfo.total_pupils ?? latestResults?.total_pupils ?? null;
|
||||||
<div className={styles.metricLabel}>Total pupils</div>
|
if (total == null) return null;
|
||||||
<div className={styles.metricValue}>{(schoolInfo.total_pupils ?? latestResults!.total_pupils!).toLocaleString()}</div>
|
const female = census?.female_pupils ?? null;
|
||||||
{schoolInfo.capacity != null && (
|
const male = census?.male_pupils ?? null;
|
||||||
<div className={styles.metricHint}>Capacity: {schoolInfo.capacity}</div>
|
const isMixed = schoolInfo.gender === 'Mixed' || schoolInfo.gender == null;
|
||||||
|
const hasSplit = isMixed && female != null && male != null && female + male > 0;
|
||||||
|
const sum = hasSplit ? female! + male! : 0;
|
||||||
|
const girlsPct = hasSplit ? Math.round((female! / sum) * 100) : 0;
|
||||||
|
const boysPct = hasSplit ? 100 - girlsPct : 0;
|
||||||
|
return (
|
||||||
|
<div className={styles.heroStatCard}>
|
||||||
|
<div className={styles.heroStatLabel}>Total pupils</div>
|
||||||
|
<div className={styles.heroStatValue}>{total.toLocaleString()}</div>
|
||||||
|
{hasSplit && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={styles.genderBar}
|
||||||
|
role="img"
|
||||||
|
aria-label={`Gender split: ${girlsPct}% girls, ${boysPct}% boys`}
|
||||||
|
>
|
||||||
|
<span className={styles.genderBarGirls} style={{ width: `${girlsPct}%` }} />
|
||||||
|
<span className={styles.genderBarBoys} style={{ width: `${boysPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.genderSplitHint}>
|
||||||
|
<span className={styles.genderSplitGirls}>{girlsPct}% girls</span>
|
||||||
|
<span className={styles.genderSplitSep}> · </span>
|
||||||
|
<span className={styles.genderSplitBoys}>{boysPct}% boys</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{schoolInfo.capacity != null && !hasSplit && (
|
||||||
|
<div className={styles.heroStatHint}>Capacity: {schoolInfo.capacity}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -808,6 +940,22 @@ export function SecondarySchoolDetailView({
|
|||||||
{yearlyData.length > 1 && (
|
{yearlyData.length > 1 && (
|
||||||
<section id="history" className={styles.card}>
|
<section id="history" className={styles.card}>
|
||||||
<h2 className={styles.sectionTitle}>Historical Results</h2>
|
<h2 className={styles.sectionTitle}>Historical Results</h2>
|
||||||
|
{yearlyData.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className={styles.subSectionTitle} style={{ marginTop: '1.25rem' }}>Results Over Time</h3>
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<PerformanceChart
|
||||||
|
data={yearlyData}
|
||||||
|
schoolName={schoolInfo.school_name}
|
||||||
|
isSecondary={true}
|
||||||
|
nationalAtt8Avg={heroAtt8Nat}
|
||||||
|
nationalByYear={nationalAvg?.by_year}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<details className={styles.historyDisclosure}>
|
||||||
|
<summary className={styles.historyToggle}>View raw year-by-year data</summary>
|
||||||
<div className={styles.tableWrapper}>
|
<div className={styles.tableWrapper}>
|
||||||
<table className={styles.dataTable}>
|
<table className={styles.dataTable}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -832,6 +980,7 @@ export function SecondarySchoolDetailView({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -236,8 +236,15 @@
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Promote the headline metric (Attainment 8) onto its own line so
|
||||||
|
the secondary stats — delta vs LA, pupils — wrap below it with
|
||||||
|
visible row gap instead of crowding the same row. */
|
||||||
.line3 {
|
.line3 {
|
||||||
gap: 0 1rem;
|
row-gap: 0.25rem;
|
||||||
|
column-gap: 1rem;
|
||||||
|
}
|
||||||
|
.line3 > .stat:first-child {
|
||||||
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowActions {
|
.rowActions {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Analytics tracking for Umami.
|
||||||
|
*
|
||||||
|
* Single typed wrapper around `window.umami.track()`. All events flow
|
||||||
|
* through `track(name, data?)` — this gives us:
|
||||||
|
* - Refactor-safe event names (one place to maintain).
|
||||||
|
* - A schema for properties so we don't ship typos that fragment dashboards.
|
||||||
|
* - No-op on the server and never-throws semantics, so analytics outages
|
||||||
|
* can't take the app down.
|
||||||
|
*
|
||||||
|
* Umami is privacy-friendly (no cookies, no IPs, no PII), so it's safe to
|
||||||
|
* include school identifiers and full search query text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type EventName =
|
||||||
|
// Discovery
|
||||||
|
| 'search_submitted'
|
||||||
|
| 'near_me_used'
|
||||||
|
| 'empty_results'
|
||||||
|
// Engagement
|
||||||
|
| 'school_viewed'
|
||||||
|
| 'section_nav_used'
|
||||||
|
| 'chart_metric_changed'
|
||||||
|
| 'metric_compared_in_rankings'
|
||||||
|
| 'external_link_clicked'
|
||||||
|
// Conversion
|
||||||
|
| 'compare_school_added'
|
||||||
|
| 'compare_school_removed'
|
||||||
|
| 'compare_viewed'
|
||||||
|
| 'compare_metric_changed'
|
||||||
|
| 'compare_shared'
|
||||||
|
// Operational
|
||||||
|
| 'api_error'
|
||||||
|
| 'results_load_more';
|
||||||
|
|
||||||
|
type Primitive = string | number | boolean;
|
||||||
|
type Payload = Record<string, Primitive>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire an event. No-ops if Umami isn't loaded yet (the script is `defer`)
|
||||||
|
* or if we're rendering server-side. Never throws.
|
||||||
|
*/
|
||||||
|
export function track(name: EventName, data?: Payload): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const umami = (window as unknown as { umami?: { track?: (n: string, d?: Payload) => void } }).umami;
|
||||||
|
if (!umami?.track) return;
|
||||||
|
try {
|
||||||
|
umami.track(name, data);
|
||||||
|
} catch {
|
||||||
|
// Analytics must never crash the app.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorise where the user navigated from, for funnel attribution
|
||||||
|
* (mostly used on school_viewed). Only checks same-origin referrers.
|
||||||
|
*/
|
||||||
|
export function getNavigationSource(): 'search' | 'rankings' | 'compare' | 'detail' | 'direct' {
|
||||||
|
if (typeof window === 'undefined' || !document.referrer) return 'direct';
|
||||||
|
try {
|
||||||
|
const ref = new URL(document.referrer);
|
||||||
|
if (ref.origin !== window.location.origin) return 'direct';
|
||||||
|
const p = ref.pathname;
|
||||||
|
if (p === '/' || p === '') return 'search';
|
||||||
|
if (p.startsWith('/rankings')) return 'rankings';
|
||||||
|
if (p.startsWith('/compare')) return 'compare';
|
||||||
|
if (p.startsWith('/school/')) return 'detail';
|
||||||
|
return 'direct';
|
||||||
|
} catch {
|
||||||
|
return 'direct';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split mobile vs desktop on a per-event basis. */
|
||||||
|
export function getViewport(): 'mobile' | 'desktop' {
|
||||||
|
if (typeof window === 'undefined') return 'desktop';
|
||||||
|
return window.matchMedia('(max-width: 640px)').matches ? 'mobile' : 'desktop';
|
||||||
|
}
|
||||||
@@ -65,6 +65,16 @@ async function handleResponse<T>(response: Response): Promise<T> {
|
|||||||
// If parsing JSON fails, use the default error
|
// If parsing JSON fails, use the default error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client-side: report to analytics so we can spot silent failures.
|
||||||
|
// No-ops on SSR (track guards against missing window).
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const { track } = await import('./analytics');
|
||||||
|
try {
|
||||||
|
const endpoint = new URL(response.url).pathname;
|
||||||
|
track('api_error', { endpoint, status: response.status, route: window.location.pathname });
|
||||||
|
} catch { /* never */ }
|
||||||
|
}
|
||||||
|
|
||||||
throw new APIFetchError(
|
throw new APIFetchError(
|
||||||
`API request failed: ${errorDetail}`,
|
`API request failed: ${errorDetail}`,
|
||||||
response.status,
|
response.status,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Single Chart.js registration point.
|
||||||
|
* Import this module (side-effect import) in any file that uses react-chartjs-2.
|
||||||
|
* Chart.js is idempotent about duplicate registrations, but importing the
|
||||||
|
* registration from one place ensures every primitive is only bundled once
|
||||||
|
* and the register() call is not duplicated at runtime.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
+13
-7
@@ -120,18 +120,24 @@ export interface OfstedParentView {
|
|||||||
|
|
||||||
export interface SchoolCensus {
|
export interface SchoolCensus {
|
||||||
year: number;
|
year: number;
|
||||||
class_size_avg: number | null;
|
total_pupils: number | null;
|
||||||
ethnicity_white_pct: number | null;
|
female_pupils: number | null;
|
||||||
ethnicity_asian_pct: number | null;
|
male_pupils: number | null;
|
||||||
ethnicity_black_pct: number | null;
|
fsm_pct: number | null;
|
||||||
ethnicity_mixed_pct: number | null;
|
eal_pct: number | null;
|
||||||
ethnicity_other_pct: number | null;
|
class_size_avg?: number | null;
|
||||||
|
ethnicity_white_pct?: number | null;
|
||||||
|
ethnicity_asian_pct?: number | null;
|
||||||
|
ethnicity_black_pct?: number | null;
|
||||||
|
ethnicity_mixed_pct?: number | null;
|
||||||
|
ethnicity_other_pct?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchoolAdmissions {
|
export interface SchoolAdmissions {
|
||||||
year: number;
|
year: number;
|
||||||
school_phase?: string | null;
|
school_phase?: string | null;
|
||||||
published_admission_number: number | null;
|
/** Number of places the school offered in this admissions round (not PAN — EES doesn't expose PAN). */
|
||||||
|
places_offered: number | null;
|
||||||
total_applications: number | null;
|
total_applications: number | null;
|
||||||
first_preference_applications?: number | null;
|
first_preference_applications?: number | null;
|
||||||
first_preference_offers?: number | null;
|
first_preference_offers?: number | null;
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export function formatPercentage(value: number | null | undefined, decimals: num
|
|||||||
return `${value.toFixed(decimals)}%`;
|
return `${value.toFixed(decimals)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatWithSuppression(value: number | null | undefined): { display: string; suppressed: boolean } {
|
||||||
|
if (value == null) return { display: '—', suppressed: true };
|
||||||
|
return { display: formatPercentage(value), suppressed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPPRESSED_TOOLTIP = 'Data not available — may be suppressed to protect small cohorts.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a progress score (can be negative)
|
* Format a progress score (can be negative)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const nextConfig = {
|
|||||||
{ protocol: 'https', hostname: 'cdnjs.cloudflare.com' },
|
{ protocol: 'https', hostname: 'cdnjs.cloudflare.com' },
|
||||||
],
|
],
|
||||||
formats: ['image/avif', 'image/webp'],
|
formats: ['image/avif', 'image/webp'],
|
||||||
minimumCacheTTL: 60,
|
minimumCacheTTL: 31536000,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Performance optimizations
|
// Performance optimizations
|
||||||
|
|||||||
Generated
+11
-22
@@ -90,7 +90,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -641,7 +640,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -665,7 +663,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -2295,6 +2292,7 @@
|
|||||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dequal": "^2.0.3"
|
"dequal": "^2.0.3"
|
||||||
}
|
}
|
||||||
@@ -2383,7 +2381,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
@@ -2564,7 +2563,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||||
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -2574,7 +2572,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -2652,7 +2649,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz",
|
||||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.54.0",
|
"@typescript-eslint/scope-manager": "8.54.0",
|
||||||
"@typescript-eslint/types": "8.54.0",
|
"@typescript-eslint/types": "8.54.0",
|
||||||
@@ -3128,7 +3124,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -3596,7 +3591,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -3745,7 +3739,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -4145,7 +4138,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@@ -4411,7 +4405,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -4589,7 +4582,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -7137,7 +7129,6 @@
|
|||||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssstyle": "^4.2.1",
|
"cssstyle": "^4.2.1",
|
||||||
"data-urls": "^5.0.0",
|
"data-urls": "^5.0.0",
|
||||||
@@ -7267,8 +7258,7 @@
|
|||||||
"version": "1.9.4",
|
"version": "1.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
@@ -7348,6 +7338,7 @@
|
|||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
@@ -8094,6 +8085,7 @@
|
|||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1",
|
"ansi-regex": "^5.0.1",
|
||||||
"ansi-styles": "^5.0.0",
|
"ansi-styles": "^5.0.0",
|
||||||
@@ -8109,6 +8101,7 @@
|
|||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@@ -8121,7 +8114,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
@@ -8185,7 +8179,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -8205,7 +8198,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -9219,7 +9211,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -9448,7 +9439,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -10023,7 +10013,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "SchoolCompare",
|
"name": "SchoolCompare",
|
||||||
"short_name": "SchoolCompare",
|
"short_name": "SchoolCompare",
|
||||||
"description": "Compare primary school KS2 performance across England",
|
"description": "Compare primary and secondary school performance across England",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#faf7f2",
|
||||||
"theme_color": "#3b82f6",
|
"theme_color": "#faf7f2",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/favicon.svg",
|
"src": "/favicon.svg",
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ with DAG(
|
|||||||
|
|
||||||
dbt_build_ees = BashOperator(
|
dbt_build_ees = BashOperator(
|
||||||
task_id="dbt_build",
|
task_id="dbt_build",
|
||||||
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+",
|
bash_command=f"cd {PIPELINE_DIR}/transform && {DBT_BIN} build --profiles-dir . --target production --select stg_ees_ks2+ stg_legacy_ks2+ stg_ees_ks4+ stg_legacy_ks4+ stg_ees_census+ stg_ees_admissions+ stg_ees_ks2_national+",
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_typesense_ees = BashOperator(
|
sync_typesense_ees = BashOperator(
|
||||||
|
|||||||
+12
-4
@@ -26,12 +26,20 @@ plugins:
|
|||||||
- name: legacy_ks2_urls
|
- name: legacy_ks2_urls
|
||||||
kind: object
|
kind: object
|
||||||
description: "Year code → URL mapping for legacy KS2 CSVs"
|
description: "Year code → URL mapping for legacy KS2 CSVs"
|
||||||
|
- name: legacy_ks4_urls
|
||||||
|
kind: object
|
||||||
|
description: "Year code → URL mapping for legacy KS4 ZIPs (england_ks4final.csv inside)"
|
||||||
config:
|
config:
|
||||||
legacy_ks2_urls:
|
legacy_ks2_urls:
|
||||||
"201516": "http://10.0.1.224:8081/filebrowser/api/public/dl/R9jjXFWa?inline=true"
|
"201516": "http://10.0.1.224:8081/filebrowser/api/public/dl/iaoSkg1v?inline=true"
|
||||||
"201617": "http://10.0.1.224:8081/filebrowser/api/public/dl/tIwJPVQS?inline=true"
|
"201617": "http://10.0.1.224:8081/filebrowser/api/public/dl/bqCMUcIH?inline=true"
|
||||||
"201718": "http://10.0.1.224:8081/filebrowser/api/public/dl/GO7SKE0p?inline=true"
|
"201718": "http://10.0.1.224:8081/filebrowser/api/public/dl/0L61fE_a?inline=true"
|
||||||
"201819": "http://10.0.1.224:8081/filebrowser/api/public/dl/jchDEHsv?inline=true"
|
"201819": "http://10.0.1.224:8081/filebrowser/api/public/dl/XJGJ5lG1?inline=true"
|
||||||
|
legacy_ks4_urls:
|
||||||
|
"201516": "http://10.0.1.224:8081/filebrowser/api/public/dl/iaoSkg1v?inline=true"
|
||||||
|
"201617": "http://10.0.1.224:8081/filebrowser/api/public/dl/bqCMUcIH?inline=true"
|
||||||
|
"201718": "http://10.0.1.224:8081/filebrowser/api/public/dl/0L61fE_a?inline=true"
|
||||||
|
"201819": "http://10.0.1.224:8081/filebrowser/api/public/dl/XJGJ5lG1?inline=true"
|
||||||
|
|
||||||
- name: tap-uk-ofsted
|
- name: tap-uk-ofsted
|
||||||
namespace: uk_ofsted
|
namespace: uk_ofsted
|
||||||
|
|||||||
@@ -40,17 +40,36 @@ def _slug_to_time_period(slug: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def get_all_releases(publication_slug: str) -> list[dict]:
|
def get_all_releases(publication_slug: str) -> list[dict]:
|
||||||
"""Return all releases for a publication as dicts with 'id' and 'time_period'."""
|
"""Return all releases for a publication as dicts with 'id' and 'time_period'.
|
||||||
url = f"{CONTENT_API_BASE}/publications/{publication_slug}/releases"
|
|
||||||
|
The EES content API paginates with a 'paging' envelope when there are many
|
||||||
|
releases. This function follows all pages so no historical release is missed.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
url = f"{CONTENT_API_BASE}/publications/{publication_slug}/releases?page={page}&pageSize=20"
|
||||||
resp = requests.get(url, timeout=TIMEOUT)
|
resp = requests.get(url, timeout=TIMEOUT)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
# API returns either a plain list or a paginated object with a "results" key
|
# API returns either a plain list or a paginated object with a "results" key
|
||||||
releases = data if isinstance(data, list) else data.get("results", [])
|
if isinstance(data, list):
|
||||||
result = []
|
releases = data
|
||||||
|
total_pages = 1
|
||||||
|
else:
|
||||||
|
releases = data.get("results", [])
|
||||||
|
paging = data.get("paging", {})
|
||||||
|
total_pages = paging.get("totalPages", 1)
|
||||||
|
|
||||||
for r in releases:
|
for r in releases:
|
||||||
time_period = _slug_to_time_period(r.get("slug", ""))
|
time_period = _slug_to_time_period(r.get("slug", ""))
|
||||||
result.append({"id": r["id"], "time_period": time_period})
|
result.append({"id": r["id"], "time_period": time_period})
|
||||||
|
|
||||||
|
if page >= total_pages:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -663,8 +682,31 @@ class LegacyKS2Stream(Stream):
|
|||||||
self.logger.warning("Failed to download %s: %s", url, e)
|
self.logger.warning("Failed to download %s: %s", url, e)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
content = resp.content
|
||||||
|
|
||||||
|
# Auto-detect ZIP — the DfE annual archives contain both KS2 and KS4
|
||||||
|
# CSVs in one ZIP. If the download is a ZIP, extract england_ks2final.csv;
|
||||||
|
# otherwise treat the content as a bare CSV (legacy individual-file URLs).
|
||||||
|
csv_bytes = None
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(io.BytesIO(content))
|
||||||
|
target = next(
|
||||||
|
(n for n in zf.namelist() if "ks2final" in n.lower() and n.endswith(".csv")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if target:
|
||||||
|
with zf.open(target) as f:
|
||||||
|
csv_bytes = f.read()
|
||||||
|
self.logger.info("Extracted %s from ZIP for %s", target, year_code)
|
||||||
|
else:
|
||||||
|
self.logger.warning("No ks2final CSV found in ZIP for %s", year_code)
|
||||||
|
continue
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
# Not a ZIP — treat as a bare CSV file
|
||||||
|
csv_bytes = content
|
||||||
|
|
||||||
df = pd.read_csv(
|
df = pd.read_csv(
|
||||||
io.BytesIO(resp.content),
|
io.BytesIO(csv_bytes),
|
||||||
dtype=str,
|
dtype=str,
|
||||||
keep_default_na=False,
|
keep_default_na=False,
|
||||||
encoding="latin-1",
|
encoding="latin-1",
|
||||||
@@ -693,6 +735,138 @@ class LegacyKS2Stream(Stream):
|
|||||||
yield record
|
yield record
|
||||||
|
|
||||||
|
|
||||||
|
# ── Legacy KS4 (pre-EES wide format from DfE performance tables) ──────────────
|
||||||
|
# The DfE "Compare School Performance" ZIPs include england_ks4final.csv in a
|
||||||
|
# wide format (one row per school, ~416 columns, uppercase abbreviated names).
|
||||||
|
# EES only hosts 2 years of KS4 data; this stream backfills 2015-16 to 2018-19.
|
||||||
|
# Column mapping: old DfE CSV column → Singer field name (matches stg output).
|
||||||
|
|
||||||
|
_LEGACY_KS4_COLUMN_MAP = {
|
||||||
|
"URN": "urn",
|
||||||
|
"TPUP": "total_pupils",
|
||||||
|
# Attainment 8
|
||||||
|
"ATT8SCR": "attainment_8_score",
|
||||||
|
# Progress 8
|
||||||
|
"P8MEA": "progress_8_score",
|
||||||
|
"P8CILOW": "progress_8_lower_ci",
|
||||||
|
"P8CIUPP": "progress_8_upper_ci",
|
||||||
|
"P8MEAENG": "progress_8_english",
|
||||||
|
"P8MEAMAT": "progress_8_maths",
|
||||||
|
"P8MEAEBAC": "progress_8_ebacc",
|
||||||
|
"P8MEAOPEN": "progress_8_open",
|
||||||
|
# English & Maths pass rates (% suffix stripped at extract time)
|
||||||
|
"PTL2BASICS_95": "english_maths_strong_pass_pct",
|
||||||
|
"PTL2BASICS_94": "english_maths_standard_pass_pct",
|
||||||
|
# EBacc
|
||||||
|
"PTEBACC_E_PTQ_EE": "ebacc_entry_pct",
|
||||||
|
"PTEBACC_95": "ebacc_strong_pass_pct",
|
||||||
|
"PTEBACC_94": "ebacc_standard_pass_pct",
|
||||||
|
# Context
|
||||||
|
"PSENSE4": "sen_ehcp_pct",
|
||||||
|
"PSENAPK4": "sen_support_pct",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyKS4Stream(Stream):
|
||||||
|
"""Stream for pre-EES KS4 data from DfE 'Compare School Performance' ZIPs.
|
||||||
|
|
||||||
|
Downloads ZIPs from URLs configured in legacy_ks4_urls (a mapping of
|
||||||
|
6-digit year code → download URL), extracts england_ks4final.csv from each,
|
||||||
|
maps old DfE column names to match stg_ees_ks4 output schema, and emits
|
||||||
|
one record per school per year. The % suffix present on percentage columns
|
||||||
|
(e.g. "39.60%") is stripped here so safe_numeric in dbt can cast cleanly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "legacy_ks4"
|
||||||
|
primary_keys = ["urn", "year"]
|
||||||
|
replication_key = None
|
||||||
|
|
||||||
|
schema = th.PropertiesList(
|
||||||
|
th.Property("urn", th.StringType, required=True),
|
||||||
|
th.Property("year", th.StringType, required=True),
|
||||||
|
th.Property("total_pupils", th.StringType),
|
||||||
|
th.Property("attainment_8_score", th.StringType),
|
||||||
|
th.Property("progress_8_score", th.StringType),
|
||||||
|
th.Property("progress_8_lower_ci", th.StringType),
|
||||||
|
th.Property("progress_8_upper_ci", th.StringType),
|
||||||
|
th.Property("progress_8_english", th.StringType),
|
||||||
|
th.Property("progress_8_maths", th.StringType),
|
||||||
|
th.Property("progress_8_ebacc", th.StringType),
|
||||||
|
th.Property("progress_8_open", th.StringType),
|
||||||
|
th.Property("english_maths_strong_pass_pct", th.StringType),
|
||||||
|
th.Property("english_maths_standard_pass_pct", th.StringType),
|
||||||
|
th.Property("ebacc_entry_pct", th.StringType),
|
||||||
|
th.Property("ebacc_strong_pass_pct", th.StringType),
|
||||||
|
th.Property("ebacc_standard_pass_pct", th.StringType),
|
||||||
|
th.Property("sen_ehcp_pct", th.StringType),
|
||||||
|
th.Property("sen_support_pct", th.StringType),
|
||||||
|
).to_dict()
|
||||||
|
|
||||||
|
def get_records(self, context):
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
url_map = self.config.get("legacy_ks4_urls", {})
|
||||||
|
if not url_map:
|
||||||
|
self.logger.warning("legacy_ks4_urls not configured, skipping legacy KS4")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.logger.info("Loading legacy KS4 for %d year(s)", len(url_map))
|
||||||
|
|
||||||
|
for year_code, url in url_map.items():
|
||||||
|
self.logger.info("Downloading %s for %s", url, year_code)
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=120)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning("Failed to download %s: %s", url, e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(io.BytesIO(resp.content))
|
||||||
|
except zipfile.BadZipFile as e:
|
||||||
|
self.logger.warning("Not a ZIP for %s: %s", year_code, e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find england_ks4final.csv inside the ZIP
|
||||||
|
target = next(
|
||||||
|
(n for n in zf.namelist() if "ks4final" in n.lower() and n.endswith(".csv")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not target:
|
||||||
|
self.logger.warning("england_ks4final.csv not found in ZIP for %s", year_code)
|
||||||
|
continue
|
||||||
|
|
||||||
|
with zf.open(target) as f:
|
||||||
|
df = pd.read_csv(
|
||||||
|
f,
|
||||||
|
dtype=str,
|
||||||
|
keep_default_na=False,
|
||||||
|
encoding="latin-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Strip BOM from first column name
|
||||||
|
cols = list(df.columns)
|
||||||
|
if cols:
|
||||||
|
cols[0] = cols[0].lstrip("\ufeff").lstrip("")
|
||||||
|
df.columns = cols
|
||||||
|
|
||||||
|
# Filter to school-level rows: URN must be a plain integer
|
||||||
|
if "URN" in df.columns:
|
||||||
|
df = df[df["URN"].str.match(r"^\d+$", na=False)]
|
||||||
|
|
||||||
|
self.logger.info("Emitting %d schools for %s", len(df), year_code)
|
||||||
|
|
||||||
|
for _, row in df.iterrows():
|
||||||
|
record = {"year": year_code}
|
||||||
|
for old_col, new_col in _LEGACY_KS4_COLUMN_MAP.items():
|
||||||
|
val = row.get(old_col, "")
|
||||||
|
# Strip % suffix — legacy DfE CSVs use "39.60%" not "39.60"
|
||||||
|
if isinstance(val, str) and val.endswith("%"):
|
||||||
|
val = val[:-1]
|
||||||
|
record[new_col] = val
|
||||||
|
yield record
|
||||||
|
|
||||||
|
|
||||||
class TapUKEES(Tap):
|
class TapUKEES(Tap):
|
||||||
"""Singer tap for UK Explore Education Statistics."""
|
"""Singer tap for UK Explore Education Statistics."""
|
||||||
|
|
||||||
@@ -711,6 +885,11 @@ class TapUKEES(Tap):
|
|||||||
th.ObjectType(),
|
th.ObjectType(),
|
||||||
description="Mapping of 6-digit year code to download URL for legacy KS2 CSVs (e.g. {\"201819\": \"https://...\"})",
|
description="Mapping of 6-digit year code to download URL for legacy KS2 CSVs (e.g. {\"201819\": \"https://...\"})",
|
||||||
),
|
),
|
||||||
|
th.Property(
|
||||||
|
"legacy_ks4_urls",
|
||||||
|
th.ObjectType(),
|
||||||
|
description="Mapping of 6-digit year code to download URL for legacy KS4 ZIPs (e.g. {\"201819\": \"https://...\"})",
|
||||||
|
),
|
||||||
).to_dict()
|
).to_dict()
|
||||||
|
|
||||||
def discover_streams(self):
|
def discover_streams(self):
|
||||||
@@ -722,6 +901,7 @@ class TapUKEES(Tap):
|
|||||||
EESCensusStream(self),
|
EESCensusStream(self),
|
||||||
EESAdmissionsStream(self),
|
EESAdmissionsStream(self),
|
||||||
LegacyKS2Stream(self),
|
LegacyKS2Stream(self),
|
||||||
|
LegacyKS4Stream(self),
|
||||||
EESKs2NationalStream(self),
|
EESKs2NationalStream(self),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
-- Intermediate model: KS4 data chained across academy conversions
|
-- Intermediate model: KS4 data chained across academy conversions
|
||||||
|
-- Unions EES (2023/24 onwards) and legacy (2015/16–2018/19) school-level data
|
||||||
|
|
||||||
with current_ks4 as (
|
with all_ks4 as (
|
||||||
|
select * from {{ ref('stg_ees_ks4') }}
|
||||||
|
union all
|
||||||
|
select * from {{ ref('stg_legacy_ks4') }}
|
||||||
|
),
|
||||||
|
|
||||||
|
current_ks4 as (
|
||||||
select
|
select
|
||||||
urn as current_urn,
|
urn as current_urn,
|
||||||
urn as source_urn,
|
urn as source_urn,
|
||||||
@@ -11,8 +18,8 @@ with current_ks4 as (
|
|||||||
english_maths_strong_pass_pct, english_maths_standard_pass_pct,
|
english_maths_strong_pass_pct, english_maths_standard_pass_pct,
|
||||||
ebacc_entry_pct, ebacc_strong_pass_pct, ebacc_standard_pass_pct, ebacc_avg_score,
|
ebacc_entry_pct, ebacc_strong_pass_pct, ebacc_standard_pass_pct, ebacc_avg_score,
|
||||||
gcse_grade_91_pct,
|
gcse_grade_91_pct,
|
||||||
sen_pct, sen_ehcp_pct, sen_support_pct
|
sen_pct, sen_support_pct, sen_ehcp_pct
|
||||||
from {{ ref('stg_ees_ks4') }}
|
from all_ks4
|
||||||
),
|
),
|
||||||
|
|
||||||
predecessor_ks4 as (
|
predecessor_ks4 as (
|
||||||
@@ -27,12 +34,12 @@ predecessor_ks4 as (
|
|||||||
ks4.english_maths_strong_pass_pct, ks4.english_maths_standard_pass_pct,
|
ks4.english_maths_strong_pass_pct, ks4.english_maths_standard_pass_pct,
|
||||||
ks4.ebacc_entry_pct, ks4.ebacc_strong_pass_pct, ks4.ebacc_standard_pass_pct, ks4.ebacc_avg_score,
|
ks4.ebacc_entry_pct, ks4.ebacc_strong_pass_pct, ks4.ebacc_standard_pass_pct, ks4.ebacc_avg_score,
|
||||||
ks4.gcse_grade_91_pct,
|
ks4.gcse_grade_91_pct,
|
||||||
ks4.sen_pct, ks4.sen_ehcp_pct, ks4.sen_support_pct
|
ks4.sen_pct, ks4.sen_support_pct, ks4.sen_ehcp_pct
|
||||||
from {{ ref('stg_ees_ks4') }} ks4
|
from all_ks4 ks4
|
||||||
inner join {{ ref('int_school_lineage') }} lin
|
inner join {{ ref('int_school_lineage') }} lin
|
||||||
on ks4.urn = lin.predecessor_urn
|
on ks4.urn = lin.predecessor_urn
|
||||||
where not exists (
|
where not exists (
|
||||||
select 1 from {{ ref('stg_ees_ks4') }} curr
|
select 1 from all_ks4 curr
|
||||||
where curr.urn = lin.current_urn
|
where curr.urn = lin.current_urn
|
||||||
and curr.year = ks4.year
|
and curr.year = ks4.year
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ with schools as (
|
|||||||
|
|
||||||
{% set ofsted_relation = adapter.get_relation(
|
{% set ofsted_relation = adapter.get_relation(
|
||||||
database=target.database,
|
database=target.database,
|
||||||
schema=target.schema,
|
schema='intermediate',
|
||||||
identifier='int_ofsted_latest'
|
identifier='int_ofsted_latest'
|
||||||
) %}
|
) %}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ select
|
|||||||
urn,
|
urn,
|
||||||
year,
|
year,
|
||||||
school_phase,
|
school_phase,
|
||||||
published_admission_number,
|
places_offered,
|
||||||
total_applications,
|
total_applications,
|
||||||
first_preference_applications,
|
first_preference_applications,
|
||||||
first_preference_offers,
|
first_preference_offers,
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ sources:
|
|||||||
- name: ees_ks4_info
|
- name: ees_ks4_info
|
||||||
description: KS4 school information (wide format — context/demographics per school)
|
description: KS4 school information (wide format — context/demographics per school)
|
||||||
|
|
||||||
|
- name: legacy_ks4
|
||||||
|
description: Pre-EES KS4 school-level data (2015/16–2018/19) from DfE Compare School Performance ZIPs
|
||||||
|
|
||||||
- name: ees_census
|
- name: ees_census
|
||||||
description: School census pupil characteristics
|
description: School census pupil characteristics
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ renamed as (
|
|||||||
entry_year,
|
entry_year,
|
||||||
|
|
||||||
-- Places and offers
|
-- Places and offers
|
||||||
{{ safe_numeric('total_number_places_offered') }}::integer as published_admission_number,
|
-- places_offered: number of places the school actually offered in this
|
||||||
|
-- year's admissions round. NOT the Published Admission Number (PAN),
|
||||||
|
-- which is the school's published capacity — EES does not expose PAN
|
||||||
|
-- at school level, so we use the count of offers as the best proxy.
|
||||||
|
{{ safe_numeric('total_number_places_offered') }}::integer as places_offered,
|
||||||
{{ safe_numeric('number_preferred_offers') }}::integer as total_offers,
|
{{ safe_numeric('number_preferred_offers') }}::integer as total_offers,
|
||||||
{{ safe_numeric('number_1st_preference_offers') }}::integer as first_preference_offers,
|
{{ safe_numeric('number_1st_preference_offers') }}::integer as first_preference_offers,
|
||||||
{{ safe_numeric('number_2nd_preference_offers') }}::integer as second_preference_offers,
|
{{ safe_numeric('number_2nd_preference_offers') }}::integer as second_preference_offers,
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
-- Staging model: KS4 attainment data from EES
|
-- Staging model: KS4 attainment data from EES
|
||||||
-- KS4 performance data is long-format with breakdown dimensions (breakdown_topic,
|
-- KS4 performance data is long-format with breakdown dimensions (breakdown_topic,
|
||||||
-- breakdown, sex). Unlike KS2 which has a subject dimension, KS4 metrics are
|
-- breakdown, sex). Unlike KS2 which has a subject dimension, KS4 metrics are
|
||||||
-- already in separate columns — we just filter to the 'All pupils' breakdown.
|
-- already in separate columns — we just filter to the all-pupils total row.
|
||||||
-- EES uses 'z' (not applicable) and 'c' (confidential) as suppression codes —
|
-- EES uses 'z' (not applicable) and 'c' (confidential) as suppression codes —
|
||||||
-- safe_numeric handles both by treating any non-numeric string as NULL.
|
-- safe_numeric handles both by treating any non-numeric string as NULL.
|
||||||
|
-- NOTE: older EES releases (pre-2023/24) use breakdown_topic = 'All pupils';
|
||||||
|
-- the 2023/24 release switched to breakdown_topic = 'Total'. Both are included.
|
||||||
|
|
||||||
with performance as (
|
with performance as (
|
||||||
select * from {{ source('raw', 'ees_ks4_performance') }}
|
select * from {{ source('raw', 'ees_ks4_performance') }}
|
||||||
@@ -46,7 +48,7 @@ all_pupils as (
|
|||||||
{{ safe_numeric('gcse_91_percent') }} as gcse_grade_91_pct
|
{{ safe_numeric('gcse_91_percent') }} as gcse_grade_91_pct
|
||||||
|
|
||||||
from performance
|
from performance
|
||||||
where breakdown_topic = 'Total'
|
where breakdown_topic in ('Total', 'All pupils')
|
||||||
and breakdown = 'Total'
|
and breakdown = 'Total'
|
||||||
and sex = 'Total'
|
and sex = 'Total'
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{{ config(materialized='table') }}
|
||||||
|
|
||||||
|
-- Staging model: Legacy KS4 data from pre-EES DfE performance tables
|
||||||
|
-- Covers 2015/16 – 2018/19; EES provides 2023/24 onwards.
|
||||||
|
-- The tap already maps old column names and strips % suffixes;
|
||||||
|
-- this model just applies safe_numeric casts and adds NULL placeholders
|
||||||
|
-- for columns not available in the legacy format.
|
||||||
|
|
||||||
|
select
|
||||||
|
cast(trim(urn) as integer) as urn,
|
||||||
|
cast(trim(year) as integer) as year,
|
||||||
|
|
||||||
|
{{ safe_numeric('total_pupils') }}::integer as total_pupils,
|
||||||
|
{{ safe_numeric('total_pupils') }}::integer as eligible_pupils,
|
||||||
|
null::numeric as prior_attainment_avg,
|
||||||
|
|
||||||
|
-- Attainment 8
|
||||||
|
{{ safe_numeric('attainment_8_score') }} as attainment_8_score,
|
||||||
|
|
||||||
|
-- Progress 8
|
||||||
|
{{ safe_numeric('progress_8_score') }} as progress_8_score,
|
||||||
|
{{ safe_numeric('progress_8_lower_ci') }} as progress_8_lower_ci,
|
||||||
|
{{ safe_numeric('progress_8_upper_ci') }} as progress_8_upper_ci,
|
||||||
|
{{ safe_numeric('progress_8_english') }} as progress_8_english,
|
||||||
|
{{ safe_numeric('progress_8_maths') }} as progress_8_maths,
|
||||||
|
{{ safe_numeric('progress_8_ebacc') }} as progress_8_ebacc,
|
||||||
|
{{ safe_numeric('progress_8_open') }} as progress_8_open,
|
||||||
|
|
||||||
|
-- English & Maths pass rates
|
||||||
|
{{ safe_numeric('english_maths_strong_pass_pct') }} as english_maths_strong_pass_pct,
|
||||||
|
{{ safe_numeric('english_maths_standard_pass_pct') }} as english_maths_standard_pass_pct,
|
||||||
|
|
||||||
|
-- EBacc
|
||||||
|
{{ safe_numeric('ebacc_entry_pct') }} as ebacc_entry_pct,
|
||||||
|
{{ safe_numeric('ebacc_strong_pass_pct') }} as ebacc_strong_pass_pct,
|
||||||
|
{{ safe_numeric('ebacc_standard_pass_pct') }} as ebacc_standard_pass_pct,
|
||||||
|
null::numeric as ebacc_avg_score,
|
||||||
|
|
||||||
|
-- GCSE grade 9-1 (not published in legacy format)
|
||||||
|
null::numeric as gcse_grade_91_pct,
|
||||||
|
|
||||||
|
-- SEN
|
||||||
|
null::numeric as sen_pct,
|
||||||
|
{{ safe_numeric('sen_support_pct') }} as sen_support_pct,
|
||||||
|
{{ safe_numeric('sen_ehcp_pct') }} as sen_ehcp_pct
|
||||||
|
|
||||||
|
from {{ source('raw', 'legacy_ks4') }}
|
||||||
|
where urn is not null
|
||||||
|
and urn ~ '^[0-9]+$'
|
||||||
Reference in New Issue
Block a user