feat(rankings,metrics): score visible on phones; definitions usable everywhere (P2.2, P2.3)
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 48s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 0s

Rankings mobile: the metric value — the point of the page — sat behind
a sideways swipe at 390px. The Area column now folds into a subline
under the school name, leaving Rank | School | Value to fit the
viewport with no horizontal scroll.

Rankings default metric becomes 'expected standard' (rwm_expected_pct);
'higher standard' stays available but no longer frames every school's
headline number in the terms parents least understand.

MetricTooltip was hover-only and display:none on phones — the mobile-
primary audience had zero access to the Attainment 8 / Progress 8 /
EBacc definitions. It is now a real button: tap/click/keyboard
toggleable with outside-click and Escape dismissal, 24px target,
shown at all viewports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Tudor
2026-07-02 21:34:07 +01:00
co-authored by Claude Fable 5
parent 0294038fd3
commit 29f79fe948
5 changed files with 98 additions and 26 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ 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_high_pct'); const metric = metricParam || (phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_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
+33 -12
View File
@@ -6,7 +6,17 @@
} }
.icon { .icon {
font-size: 0.85em; /* A real button: 24px tap target (WCAG 2.5.8) drawn as the small glyph. */
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
min-height: 24px;
margin: -6px 0;
padding: 0;
border: none;
background: none;
font-size: 0.9em;
color: var(--text-muted, #8a7a72); color: var(--text-muted, #8a7a72);
cursor: help; cursor: help;
line-height: 1; line-height: 1;
@@ -14,8 +24,9 @@
transition: color 0.15s ease; transition: color 0.15s ease;
} }
.wrapper:hover .icon { .wrapper:hover .icon,
color: var(--accent-coral, #e07256); .icon[aria-expanded="true"] {
color: var(--accent-coral-dark, #b04a2e);
} }
.tooltip { .tooltip {
@@ -39,12 +50,18 @@
transition: opacity 0.15s ease, visibility 0.15s ease; transition: opacity 0.15s ease, visibility 0.15s ease;
} }
/* Keep tooltip visible when hovering over it */ /* Reveal on hover (desktop), keyboard focus, or explicit tap/click toggle. */
.wrapper:hover .tooltip { .wrapper:hover .tooltip,
.wrapper:focus-within .tooltip,
.tooltipOpen {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
} }
.tooltipOpen {
pointer-events: auto;
}
/* Small arrow pointing down */ /* Small arrow pointing down */
.tooltip::after { .tooltip::after {
content: ''; content: '';
@@ -75,19 +92,23 @@
margin-top: 0.1rem; margin-top: 0.1rem;
} }
/* Flip tooltip below when near top of screen */
@media (max-width: 480px) { @media (max-width: 480px) {
.tooltip { .tooltip {
width: 180px; width: 180px;
} }
} }
/* On phones the icon was rendering at ~9px and the tooltip relied on /* Anchor the bubble to open rightward on phones — icons follow their labels,
:hover, which doesn't fire on touch. Rather than build a tap-to-show which start at the left edge, so centring pushed the bubble off-screen. */
layer with backdrop dismissal, hide the helper entirely — the metric
labels themselves carry the meaning. */
@media (max-width: 640px) { @media (max-width: 640px) {
.wrapper { .tooltip {
display: none; left: -12px;
right: auto;
transform: none;
}
.tooltip::after {
left: 16px;
transform: none;
} }
} }
+35 -3
View File
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react';
import { METRIC_EXPLANATIONS } from '@/lib/metrics'; import { METRIC_EXPLANATIONS } from '@/lib/metrics';
import styles from './MetricTooltip.module.css'; import styles from './MetricTooltip.module.css';
@@ -16,12 +17,43 @@ export function MetricTooltip({ metricKey, label, plain, detail }: MetricTooltip
const tooltipPlain = plain ?? explanation?.plain; const tooltipPlain = plain ?? explanation?.plain;
const tooltipDetail = detail ?? explanation?.detail; const tooltipDetail = detail ?? explanation?.detail;
// Tap/click/keyboard toggle so the definition is reachable on touch devices
// and by keyboard, not just mouse hover (hover still works on desktop).
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return;
const dismiss = (e: Event) => {
if (wrapperRef.current && e.target instanceof Node && !wrapperRef.current.contains(e.target)) {
setOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('click', dismiss);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('click', dismiss);
document.removeEventListener('keydown', onKey);
};
}, [open]);
if (!tooltipPlain) return null; if (!tooltipPlain) return null;
return ( return (
<span className={styles.wrapper}> <span className={styles.wrapper} ref={wrapperRef}>
<span className={styles.icon} aria-label={tooltipLabel ?? 'More information'} role="img"></span> <button
<span className={styles.tooltip} role="tooltip"> type="button"
className={styles.icon}
aria-expanded={open}
aria-label={`What does ${tooltipLabel ?? 'this metric'} mean?`}
onClick={() => setOpen((o) => !o)}
>
</button>
<span className={`${styles.tooltip}${open ? ` ${styles.tooltipOpen}` : ''}`} role="tooltip">
{tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>} {tooltipLabel && <span className={styles.tooltipLabel}>{tooltipLabel}</span>}
<span className={styles.tooltipPlain}>{tooltipPlain}</span> <span className={styles.tooltipPlain}>{tooltipPlain}</span>
{tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>} {tooltipDetail && <span className={styles.tooltipDetail}>{tooltipDetail}</span>}
+23 -9
View File
@@ -55,13 +55,13 @@
} }
.phaseTabActive { .phaseTabActive {
background: var(--accent-coral, #e07256); background: var(--accent-coral-dark, #b04a2e);
color: white; color: white;
font-weight: 600; font-weight: 600;
} }
.phaseTabActive:hover { .phaseTabActive:hover {
background: var(--accent-coral, #e07256); background: var(--accent-coral-darker, #9c3f26);
} }
/* Filters */ /* Filters */
@@ -290,7 +290,7 @@
} }
.schoolLink:hover { .schoolLink:hover {
color: var(--accent-coral, #e07256); color: var(--accent-coral-dark, #b04a2e);
} }
.areaCell, .areaCell,
@@ -298,6 +298,14 @@
color: var(--text-secondary, #5c564d); color: var(--text-secondary, #5c564d);
} }
/* LA subline under the school name — mobile only (Area column hidden there). */
.schoolCellArea {
display: none;
font-size: 0.75rem;
font-weight: 400;
color: var(--text-muted, #8a847a);
}
.valueCell { .valueCell {
text-align: center; text-align: center;
font-size: 1rem; font-size: 1rem;
@@ -374,20 +382,26 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
/* Hide less-critical columns on mobile so the metric value stays visible */ /* Hide less-critical columns on mobile so the metric value stays visible.
Area moves to a subline under the school name (.schoolCellArea) — with a
four-column layout the value still overflowed a 390px viewport and the
whole point of the page (the score) needed a sideways swipe to see. */
.typeHeader, .typeHeader,
.typeCell, .typeCell,
.actionHeader, .actionHeader,
.actionCell { .actionCell,
.areaHeader,
.areaCell {
display: none; display: none;
} }
.schoolHeader { .schoolCellArea {
min-width: 140px; display: block;
margin-top: 0.15rem;
} }
.areaHeader { .schoolHeader {
min-width: 80px; min-width: 0;
} }
.valueHeader, .valueHeader,
+6 -1
View File
@@ -75,7 +75,7 @@ export function RankingsView({
}; };
const handlePhaseChange = (phase: string) => { const handlePhaseChange = (phase: string) => {
const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_high_pct'; const defaultMetric = phase === 'secondary' ? 'attainment_8_score' : 'rwm_expected_pct';
updateFilters({ phase, metric: defaultMetric }); updateFilters({ phase, metric: defaultMetric });
}; };
@@ -273,6 +273,11 @@ export function RankingsView({
<a href={schoolUrl(ranking.urn, ranking.school_name)} className={styles.schoolLink}> <a href={schoolUrl(ranking.urn, ranking.school_name)} className={styles.schoolLink}>
{ranking.school_name} {ranking.school_name}
</a> </a>
{/* On phones the Area column is hidden; the LA moves here
so the metric value fits on screen without swiping. */}
{ranking.local_authority && (
<span className={styles.schoolCellArea}>{ranking.local_authority}</span>
)}
</td> </td>
<td className={styles.areaCell}>{ranking.local_authority || '-'}</td> <td className={styles.areaCell}>{ranking.local_authority || '-'}</td>
<td className={styles.typeCell}>{ranking.school_type || '-'}</td> <td className={styles.typeCell}>{ranking.school_type || '-'}</td>