Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc85254ad2 | ||
|
|
d02a323cdc | ||
|
|
32f8a02862 | ||
|
|
7e3096c881 | ||
|
|
1f80571b1f | ||
|
|
3132f07af2 | ||
|
|
8a9ba30cc2 | ||
|
|
388b496cc5 | ||
|
|
5981916b82 |
@@ -807,6 +807,10 @@ async def get_la_averages(request: Request):
|
||||
_KS2_NATIONAL_METRICS = [
|
||||
"rwm_expected_pct", "rwm_high_pct",
|
||||
"reading_expected_pct", "writing_expected_pct", "maths_expected_pct",
|
||||
# Per-subject higher-standard nationals: reading/maths reach the "higher
|
||||
# standard" in the tests; writing is teacher-assessed at "greater depth"
|
||||
# (writing_gd_pct). Needed so each SATs bar compares to its own benchmark.
|
||||
"reading_high_pct", "writing_gd_pct", "maths_high_pct",
|
||||
"gps_expected_pct", "gps_high_pct", "science_expected_pct",
|
||||
"reading_avg_score", "maths_avg_score", "gps_avg_score",
|
||||
"reading_progress", "writing_progress", "maths_progress",
|
||||
|
||||
@@ -25,6 +25,11 @@ class _Ks2Row:
|
||||
year = LATEST
|
||||
rwm_expected_pct = 62.1
|
||||
gps_expected_pct = 72.0
|
||||
# Per-subject higher-standard nationals — reading/maths reach the higher
|
||||
# standard, writing is teacher-assessed at greater depth (writing_gd_pct).
|
||||
reading_high_pct = 29.0
|
||||
writing_gd_pct = 13.0
|
||||
maths_high_pct = 24.0
|
||||
|
||||
|
||||
class _Ks4Row:
|
||||
@@ -84,6 +89,16 @@ def test_ks4_averages_come_from_the_mart_not_the_dataframe(payload):
|
||||
assert body["by_year"][-1]["secondary"]["progress_8_score"] == -0.02
|
||||
|
||||
|
||||
def test_per_subject_higher_standard_nationals_are_surfaced(payload):
|
||||
# The SATs chart compares each bar to its own benchmark, so the per-subject
|
||||
# higher-standard / greater-depth nationals must reach the payload — not
|
||||
# only the combined rwm_high_pct.
|
||||
body = payload(_StubSession)
|
||||
assert body["primary"]["reading_high_pct"] == 29.0
|
||||
assert body["primary"]["writing_gd_pct"] == 13.0
|
||||
assert body["primary"]["maths_high_pct"] == 24.0
|
||||
|
||||
|
||||
def test_ks4_secondary_empty_when_mart_missing(payload):
|
||||
# No computed stand-in: the UI labels national figures as official DfE
|
||||
# data, so an empty mart must yield an empty secondary series.
|
||||
|
||||
@@ -41,6 +41,30 @@ describe('buildCompareChart', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores a no-results school whose only row has a null year', () => {
|
||||
// A school with no performance rows comes back from /api/compare with a
|
||||
// single phantom yearly_data row (LEFT JOIN) where year and every metric
|
||||
// are null. That null year must NOT pollute the axis: Math.trunc(null) is
|
||||
// 0, and filling from year 0 blows past the real years, blanking every
|
||||
// school's line. Regression guard for "add a no-data school → chart empty".
|
||||
const withNoData = {
|
||||
...THREE_SCHOOLS,
|
||||
'4': school(4, [[null as unknown as number, null]]),
|
||||
};
|
||||
const list = [...SCHOOL_LIST, { urn: 4, school_name: 'School 4' }];
|
||||
const chart = buildCompareChart(withNoData, list, 'rwm_expected_pct');
|
||||
|
||||
// The real years still drive the axis; the phantom year 0 is gone.
|
||||
expect(chart.years).toContain(201819);
|
||||
expect(chart.years).toContain(202425);
|
||||
expect(chart.years).not.toContain(0);
|
||||
// The three real schools still render their lines.
|
||||
for (const urn of [1, 2, 3]) {
|
||||
const ds = chart.schoolDatasets[urn - 1];
|
||||
expect(ds.data.some((v) => v != null)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles float years from the API (202425.0 style)', () => {
|
||||
const floaty = {
|
||||
'1': school(1, [[201819.0 as number, 80], [202425.0 as number, 85]]),
|
||||
|
||||
@@ -85,6 +85,14 @@ export default function RootLayout({
|
||||
data-performance="true"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
{/* Heatmap / session recorder — same data-domains guard so staging
|
||||
(same image, different host) never feeds the heatmap. */}
|
||||
<Script
|
||||
src="https://analytics.schoolcompare.co.uk/recorder.js"
|
||||
data-website-id="d7fb0c95-bb6c-4336-8209-bd10077e50dd"
|
||||
data-domains="schoolcompare.co.uk,www.schoolcompare.co.uk"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${dmSans.variable} ${playfairDisplay.variable}`}>
|
||||
<div className="noise-overlay" />
|
||||
|
||||
@@ -44,30 +44,46 @@
|
||||
background: var(--bg-secondary, #f3ede4);
|
||||
}
|
||||
|
||||
/* ── National average marker ── */
|
||||
.natLine {
|
||||
/* ── Per-bar national average marker ──
|
||||
Each bar compares against its own benchmark (expected vs higher standard /
|
||||
greater depth), so the marker sits on the individual bar's track rather than
|
||||
as one line spanning both bars. */
|
||||
.natTick {
|
||||
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;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent-coral, #e07256);
|
||||
border-radius: 2px;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.natPill {
|
||||
.natTick::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
top: -3px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-coral, #e07256);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.barHeaderRight {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.natLabel {
|
||||
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;
|
||||
color: var(--accent-coral-dark, #b04a2e);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Bar rows ── */
|
||||
|
||||
@@ -8,6 +8,9 @@ interface SubjectData {
|
||||
expectedPct: number | null;
|
||||
exceedingPct: number | null;
|
||||
nationalExpectedPct: number | null;
|
||||
/** National higher-standard / greater-depth benchmark for the exceeding
|
||||
* bar — a different figure from the expected national. */
|
||||
nationalExceedingPct: number | null;
|
||||
}
|
||||
|
||||
interface SatsChartProps {
|
||||
@@ -17,11 +20,45 @@ interface SatsChartProps {
|
||||
const RULER_TICKS = [0, 25, 50, 75, 100];
|
||||
const GRIDLINE_POSITIONS = [25, 50, 75];
|
||||
|
||||
/** One results bar with a national marker positioned on its own track, so the
|
||||
* expected and exceeding bars each compare against their own benchmark. */
|
||||
function BarRow({
|
||||
label,
|
||||
value,
|
||||
national,
|
||||
barRef,
|
||||
barClass,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
national: number | null;
|
||||
barRef: React.RefObject<HTMLDivElement | null>;
|
||||
barClass: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.barRow}>
|
||||
<div className={styles.barHeader}>
|
||||
<span className={styles.barLabelSuffix}>{label}</span>
|
||||
<span className={styles.barHeaderRight}>
|
||||
{national != null && <span className={styles.natLabel}>nat {national.toFixed(0)}%</span>}
|
||||
<span className={styles.barLabel}>{value.toFixed(0)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.barTrack}>
|
||||
<div ref={barRef} className={`${styles.bar} ${barClass}`} data-width={value} />
|
||||
{national != null && (
|
||||
<div className={styles.natTick} style={{ left: `${national}%` }} aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubjectColumn({ subject }: { subject: SubjectData }) {
|
||||
const expectedRef = useRef<HTMLDivElement>(null);
|
||||
const exceedingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { name, expectedPct, exceedingPct, nationalExpectedPct } = subject;
|
||||
const { name, expectedPct, exceedingPct, nationalExpectedPct, nationalExceedingPct } = subject;
|
||||
|
||||
// Animate bars on mount
|
||||
useEffect(() => {
|
||||
@@ -51,44 +88,25 @@ function SubjectColumn({ subject }: { subject: SubjectData }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* National average marker */}
|
||||
{nationalExpectedPct != null && (
|
||||
<div className={styles.natLine} style={{ left: `${nationalExpectedPct}%` }}>
|
||||
<div className={styles.natPill}>{nationalExpectedPct.toFixed(0)}%</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bars */}
|
||||
{/* Bars — each carries its own national marker */}
|
||||
<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>
|
||||
<BarRow
|
||||
label="Expected"
|
||||
value={expectedPct}
|
||||
national={nationalExpectedPct}
|
||||
barRef={expectedRef}
|
||||
barClass={styles.barExpected}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
<BarRow
|
||||
label="Exceeding"
|
||||
value={exceedingPct}
|
||||
national={nationalExceedingPct}
|
||||
barRef={exceedingRef}
|
||||
barClass={styles.barExceeding}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -135,7 +153,7 @@ export default function SatsChart({ subjects }: SatsChartProps) {
|
||||
Exceeding / high score
|
||||
</div>
|
||||
<div className={styles.legendItem}>
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', borderRadius: '50%' }} />
|
||||
<div className={styles.legendSwatch} style={{ background: 'var(--accent-coral, #e07256)', width: '3px', height: '12px', borderRadius: '2px' }} />
|
||||
National average
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -855,7 +855,10 @@
|
||||
/* Charts Section */
|
||||
.chartContainer {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
/* Taller on desktop so the trend lines have vertical room to separate
|
||||
and read clearly. Mobile overrides this to height:auto below (the
|
||||
max-width:768px query), so this only affects desktop. */
|
||||
height: 380px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -752,18 +752,22 @@ export function SchoolDetailView({
|
||||
expectedPct: latestResults.reading_expected_pct,
|
||||
exceedingPct: latestResults.reading_high_pct,
|
||||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.reading_expected_pct,
|
||||
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.reading_high_pct,
|
||||
},
|
||||
{
|
||||
name: 'Writing',
|
||||
expectedPct: latestResults.writing_expected_pct,
|
||||
exceedingPct: latestResults.writing_high_pct,
|
||||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.writing_expected_pct,
|
||||
// Writing's higher level is teacher-assessed "greater depth".
|
||||
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.writing_gd_pct,
|
||||
},
|
||||
{
|
||||
name: 'Maths',
|
||||
expectedPct: latestResults.maths_expected_pct,
|
||||
exceedingPct: latestResults.maths_high_pct,
|
||||
nationalExpectedPct: suppressKs2Comparison ? null : primaryAvg.maths_expected_pct,
|
||||
nationalExceedingPct: suppressKs2Comparison ? null : primaryAvg.maths_high_pct,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -490,7 +490,10 @@
|
||||
/* ── Charts & Map ────────────────────────────────────── */
|
||||
.chartContainer {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
/* Taller on desktop so the trend lines have vertical room to separate
|
||||
and read clearly. Mobile overrides this to height:auto below (the
|
||||
max-width:768px query), so this only affects desktop. */
|
||||
height: 380px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
|
||||
import type { ComparisonData } from './types';
|
||||
|
||||
/**
|
||||
* A yearly row only counts once it carries a real academic year. A school with
|
||||
* no performance data still comes back from /api/compare with a single phantom
|
||||
* row (the dim_school LEFT JOIN) where `year` is null — and Math.trunc(null) is
|
||||
* 0, which would seed the axis at year 0 and, via fillAcademicYears, blow past
|
||||
* every real year and blank all schools' lines. Drop those rows up front.
|
||||
*/
|
||||
function hasYear(row: { year: number }): boolean {
|
||||
return typeof row.year === 'number' && Number.isFinite(row.year);
|
||||
}
|
||||
|
||||
/** 201819 → 201920 (academic-year arithmetic on YYYYYY codes). */
|
||||
function nextAcademicYear(year: number): number {
|
||||
const start = Math.floor(year / 100);
|
||||
@@ -66,14 +77,19 @@ export function buildCompareChart(
|
||||
nationalByYear?: Record<number, number | null | undefined>,
|
||||
): CompareChart {
|
||||
const rawYears = schools.flatMap(
|
||||
(s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => Math.trunc(d.year)) ?? [],
|
||||
(s) =>
|
||||
comparisonData[String(s.urn)]?.yearly_data.filter(hasYear).map((d) => Math.trunc(d.year)) ??
|
||||
[],
|
||||
);
|
||||
const years = fillAcademicYears(rawYears);
|
||||
|
||||
const schoolDatasets: CompareChartSeries[] = schools.map((school, schoolIndex) => {
|
||||
const rows = comparisonData[String(school.urn)]?.yearly_data ?? [];
|
||||
const byYear = new Map<number, Record<string, unknown>>();
|
||||
for (const row of rows) byYear.set(Math.trunc(row.year), row as unknown as Record<string, unknown>);
|
||||
for (const row of rows) {
|
||||
if (!hasYear(row)) continue;
|
||||
byYear.set(Math.trunc(row.year), row as unknown as Record<string, unknown>);
|
||||
}
|
||||
return {
|
||||
label: school.school_name,
|
||||
data: years.map((year) => {
|
||||
|
||||
Reference in New Issue
Block a user