diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index 284baa1..2be8c7a 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -91,6 +91,32 @@ test('comparing two schools shows both side by side', async ({ page }) => { await expect(page.locator(`a[href*="${urns[1]}"]`).first()).toBeVisible(); }); +test('compare chart on mobile shows school chips with tap-to-focus', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + + await searchByName(page, 'primary'); + await expect(schoolLinks(page).first()).toBeVisible({ timeout: 15_000 }); + const hrefs = await schoolLinks(page).evaluateAll((links) => + links.map((l) => (l as HTMLAnchorElement).getAttribute('href') || '') + ); + const urns = [...new Set(hrefs.map((h) => h.match(/\/school\/(\d+)/)?.[1]).filter(Boolean))]; + expect(urns.length).toBeGreaterThanOrEqual(2); + + await page.goto(`/compare?urns=${urns[0]},${urns[1]}`); + await expect(page.locator('canvas:visible').first()).toBeVisible({ timeout: 15_000 }); + + // The mobile chart legend renders one chip per school inside the chart card. + const chipGroup = page.getByRole('group', { name: /highlight a school/i }); + const chips = chipGroup.getByRole('button'); + await expect(chips).toHaveCount(2); + + // Tapping a chip focuses that school's line; tapping again releases it. + await chips.first().click(); + await expect(chips.first()).toHaveAttribute('aria-pressed', 'true'); + await chips.first().click(); + await expect(chips.first()).toHaveAttribute('aria-pressed', 'false'); +}); + test('rankings page loads a populated table', async ({ page }) => { await page.goto('/rankings'); await expect(page.getByRole('heading', { name: /rankings/i }).first()).toBeVisible(); diff --git a/nextjs-app/__tests__/lib/utils.test.ts b/nextjs-app/__tests__/lib/utils.test.ts index 07ee5ca..2a9c664 100644 --- a/nextjs-app/__tests__/lib/utils.test.ts +++ b/nextjs-app/__tests__/lib/utils.test.ts @@ -9,6 +9,8 @@ import { isValidPostcode, debounce, buildOfstedListBadge, + metricKind, + computeYBounds, } from '@/lib/utils'; describe('formatPercentage', () => { @@ -159,3 +161,54 @@ describe('buildOfstedListBadge', () => { expect(badge.cssClass).toBe('ofstedPending'); }); }); + +describe('metricKind', () => { + it('classifies metrics by key', () => { + expect(metricKind('rwm_expected_pct')).toBe('percentage'); + expect(metricKind('absence_rate')).toBe('percentage'); + expect(metricKind('reading_progress')).toBe('progress'); + expect(metricKind('progress_8_score')).toBe('progress'); + expect(metricKind('attainment_8_score')).toBe('score'); + expect(metricKind('reading_avg_score')).toBe('score'); + }); +}); + +describe('computeYBounds', () => { + it('tightens clustered percentages instead of framing 0-100', () => { + const b = computeYBounds([86, 86, 86, 80, 96], 'percentage'); + expect(b.min).toBeGreaterThanOrEqual(0); + expect(b.max).toBeLessThanOrEqual(100); + expect(b.min).toBeGreaterThan(50); + expect(b.max! - b.min!).toBeGreaterThanOrEqual(10); + }); + + it('never widens percentages beyond 0-100 for non-negative data', () => { + const b = computeYBounds([2, 5, 98], 'percentage'); + expect(b.min).toBe(0); + expect(b.max).toBe(100); + }); + + it('does not clamp to zero when pct-named trend data is negative', () => { + const b = computeYBounds([-12, -3, 4], 'percentage'); + expect(b.min).toBeLessThan(-12); + }); + + it('keeps progress bounds symmetric around zero', () => { + const b = computeYBounds([-1.2, 0.4, 2.1], 'progress'); + expect(b.min).toBe(-b.max!); + expect(b.min).toBeLessThanOrEqual(-1.2); + expect(b.max).toBeGreaterThanOrEqual(2.1); + }); + + it('fits score metrics without a fixed frame', () => { + const b = computeYBounds([42.3, 48.9, 51.2], 'score'); + expect(b.min).toBeGreaterThanOrEqual(0); + expect(b.min).toBeLessThanOrEqual(42.3); + expect(b.max).toBeGreaterThanOrEqual(51.2); + }); + + it('returns empty bounds when there is no numeric data', () => { + expect(computeYBounds([null, undefined, NaN], 'percentage')).toEqual({}); + expect(computeYBounds([], 'progress')).toEqual({}); + }); +}); diff --git a/nextjs-app/components/ComparisonChart.module.css b/nextjs-app/components/ComparisonChart.module.css new file mode 100644 index 0000000..b4df055 --- /dev/null +++ b/nextjs-app/components/ComparisonChart.module.css @@ -0,0 +1,63 @@ +/* Chart wrapper: chips (mobile) above, canvas filling the rest of the + parent .chartContainer, whose fixed height drives Chart.js sizing via + maintainAspectRatio: false. */ +.wrapper { + display: flex; + flex-direction: column; + height: 100%; +} + +.canvasBox { + position: relative; + flex: 1 1 auto; + min-height: 0; +} + +/* School chips: mobile-only legend + tap-to-focus control. Desktop keeps + Chart.js's built-in legend (with per-school point shapes). */ +.chips { + display: none; +} + +@media (max-width: 640px) { + .chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding-bottom: 8px; + } + + .chip { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 44px; + max-width: 100%; + padding: 4px 10px; + border: 1px solid rgba(0, 0, 0, .12); + border-radius: 999px; + background: transparent; + cursor: pointer; + font-size: 12px; + font-weight: 600; + } + + .chip[aria-pressed="true"] { + background: rgba(0, 0, 0, .06); + border-color: rgba(0, 0, 0, .35); + } + + .chipDot { + flex: 0 0 auto; + width: 10px; + height: 10px; + border-radius: 50%; + } + + .chipName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 9rem; + } +} diff --git a/nextjs-app/components/ComparisonChart.tsx b/nextjs-app/components/ComparisonChart.tsx index 3fa9af1..d922991 100644 --- a/nextjs-app/components/ComparisonChart.tsx +++ b/nextjs-app/components/ComparisonChart.tsx @@ -1,47 +1,82 @@ /** * ComparisonChart Component - * Multi-school comparison chart using Chart.js + * Multi-school comparison chart using Chart.js. + * + * Desktop: built-in legend (point-style markers double as per-school shapes). + * Mobile (≤640px): the in-chart legend and axis titles are dropped in favour + * of a chip row above the canvas; tapping a chip highlights that school's + * line and dims the rest. The y-axis auto-fits the data on all viewports so + * clustered schools stay distinguishable. */ 'use client'; +import { useEffect, useState } from 'react'; import { Line } from 'react-chartjs-2'; -import { ChartOptions } from 'chart.js'; +import { ChartOptions, ChartDataset, PointStyle } from 'chart.js'; import '@/lib/chartSetup'; import type { ComparisonData } from '@/lib/types'; -import { CHART_COLORS, formatAcademicYear } from '@/lib/utils'; +import { + CHART_COLORS, + CHART_TEXT_COLORS, + computeYBounds, + formatAcademicYear, + metricKind, + rgbToRgba, +} from '@/lib/utils'; +import { useIsMobile } from '@/hooks/useIsMobile'; +import { track } from '@/lib/analytics'; +import styles from './ComparisonChart.module.css'; interface ComparisonChartProps { comparisonData: Record; + /** Ordered as displayed in the school cards, so colours match by index. */ + schools: Array<{ urn: number; school_name: string }>; metric: string; metricLabel: string; } -export function ComparisonChart({ comparisonData, metric, metricLabel }: ComparisonChartProps) { - // Get all schools and their data - const schools = Object.entries(comparisonData); +// One shape per basket slot (MAX_SCHOOLS = 5) — secondary encoding so +// converging lines stay tellable apart without relying on hue alone. +const POINT_STYLES: PointStyle[] = ['circle', 'triangle', 'rect', 'rectRot', 'star']; + +export function ComparisonChart({ comparisonData, schools, metric, metricLabel }: ComparisonChartProps) { + const isMobile = useIsMobile(); + const [focusedUrn, setFocusedUrn] = useState(null); + + // A focused school that leaves the basket must not linger. + const urnKey = schools.map((s) => s.urn).join(','); + useEffect(() => { + setFocusedUrn(null); + }, [urnKey]); if (schools.length === 0) { return
No data available
; } - // Get years from first school (assuming all schools have same years) - const years = schools[0][1].yearly_data.map((d) => d.year).sort((a, b) => a - b); + // Union of years across all schools — coverage differs between them. + const years = [ + ...new Set(schools.flatMap((s) => comparisonData[String(s.urn)]?.yearly_data.map((d) => d.year) ?? [])), + ].sort((a, b) => a - b); - // Create datasets for each school - const datasets = schools.map(([urn, data], index) => { - const schoolInfo = data.school_info; + const datasets: ChartDataset<'line'>[] = schools.map((school, index) => { + const data = comparisonData[String(school.urn)]; const color = CHART_COLORS[index % CHART_COLORS.length]; + const dimmed = focusedUrn !== null && focusedUrn !== school.urn; return { - label: schoolInfo.school_name, + label: school.school_name, data: years.map((year) => { - const yearData = data.yearly_data.find((d) => d.year === year); + const yearData = data?.yearly_data.find((d) => d.year === year); if (!yearData) return null; return yearData[metric as keyof typeof yearData] as number | null; }), - borderColor: color, - backgroundColor: color.replace('rgb', 'rgba').replace(')', ', 0.1)'), + borderColor: dimmed ? rgbToRgba(color, 0.2) : color, + backgroundColor: dimmed ? 'transparent' : rgbToRgba(color, 0.1), + borderWidth: focusedUrn === school.urn ? 3 : dimmed ? 1.5 : 2, + pointStyle: POINT_STYLES[index % POINT_STYLES.length], + pointRadius: dimmed ? 2 : isMobile ? 3 : 4, + pointHoverRadius: isMobile ? 5 : 6, tension: 0.3, spanGaps: true, }; @@ -52,9 +87,11 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari datasets, }; - // Determine if metric is a progress score or percentage - const isProgressScore = metric.includes('progress'); - const isPercentage = metric.includes('pct') || metric.includes('rate'); + const kind = metricKind(metric); + const yBounds = computeYBounds( + datasets.flatMap((ds) => ds.data as Array), + kind, + ); const options: ChartOptions<'line'> = { responsive: true, @@ -65,6 +102,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari }, plugins: { legend: { + display: !isMobile, position: 'top' as const, labels: { usePointStyle: true, @@ -74,26 +112,22 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari }, }, }, + // No in-chart title: the section heading and metric selector above the + // chart already state the metric. title: { - display: true, - text: `${metricLabel} - Comparison`, - font: { - size: 16, - weight: 'bold', - }, - padding: { - bottom: 20, - }, + display: false, }, tooltip: { backgroundColor: 'rgba(0, 0, 0, 0.8)', - padding: 12, + padding: isMobile ? 10 : 12, titleFont: { - size: 14, + size: isMobile ? 12 : 14, }, bodyFont: { - size: 13, + size: isMobile ? 11 : 13, }, + usePointStyle: true, + itemSort: (a, b) => (b.parsed.y ?? -Infinity) - (a.parsed.y ?? -Infinity), callbacks: { label: function (context) { let label = context.dataset.label || ''; @@ -101,13 +135,7 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari label += ': '; } if (context.parsed.y !== null) { - if (isProgressScore) { - label += context.parsed.y.toFixed(1); - } else if (isPercentage) { - label += context.parsed.y.toFixed(1) + '%'; - } else { - label += context.parsed.y.toFixed(1); - } + label += context.parsed.y.toFixed(1) + (kind === 'percentage' ? '%' : ''); } else { label += 'N/A'; } @@ -121,17 +149,18 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari type: 'linear' as const, display: true, title: { - display: true, - text: isPercentage ? 'Percentage (%)' : isProgressScore ? 'Progress Score' : 'Value', + display: !isMobile, + text: kind === 'percentage' ? 'Percentage (%)' : kind === 'progress' ? 'Progress Score' : 'Value', font: { size: 12, weight: 'bold', }, }, - ...(isPercentage && { - min: 0, - max: 100, - }), + ...yBounds, + ticks: { + font: { size: isMobile ? 10 : 12 }, + ...(isMobile && { maxTicksLimit: 5 }), + }, grid: { color: 'rgba(0, 0, 0, 0.05)', }, @@ -141,16 +170,58 @@ export function ComparisonChart({ comparisonData, metric, metricLabel }: Compari display: false, }, title: { - display: true, + display: !isMobile, text: 'Year', font: { size: 12, weight: 'bold', }, }, + ticks: { + font: { size: isMobile ? 10 : 12 }, + ...(isMobile && { maxRotation: 0, autoSkip: true, maxTicksLimit: 4 }), + }, }, }, }; - return ; + const toggleFocus = (urn: number) => { + const next = focusedUrn === urn ? null : urn; + setFocusedUrn(next); + if (next !== null) track('compare_focus_school', { urn: next }); + }; + + return ( +
+ {/* Mobile legend + focus control; a single series needs no legend. */} + {schools.length > 1 && ( +
+ {schools.map((school, index) => ( + + ))} +
+ )} +
+ +
+
+ ); } diff --git a/nextjs-app/components/ComparisonView.module.css b/nextjs-app/components/ComparisonView.module.css index 7d1d5ff..b6f1af6 100644 --- a/nextjs-app/components/ComparisonView.module.css +++ b/nextjs-app/components/ComparisonView.module.css @@ -454,7 +454,10 @@ } .chartContainer { - height: 300px; + /* Taller than desktop's proportion would suggest: the chip legend row + sits inside, and the in-chart title/legend/axis titles are gone, so + nearly all of this is plot area. */ + height: 340px; } .comparisonTable { diff --git a/nextjs-app/components/ComparisonView.tsx b/nextjs-app/components/ComparisonView.tsx index 3ecb5f5..047d3b5 100644 --- a/nextjs-app/components/ComparisonView.tsx +++ b/nextjs-app/components/ComparisonView.tsx @@ -429,6 +429,7 @@ export function ComparisonView({
diff --git a/nextjs-app/components/PerformanceChart.tsx b/nextjs-app/components/PerformanceChart.tsx index b4ec39f..153a0d6 100644 --- a/nextjs-app/components/PerformanceChart.tsx +++ b/nextjs-app/components/PerformanceChart.tsx @@ -10,12 +10,13 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Line } from 'react-chartjs-2'; import { ChartOptions, ChartDataset } from 'chart.js'; import '@/lib/chartSetup'; import type { SchoolResult } from '@/lib/types'; import { formatAcademicYear } from '@/lib/utils'; +import { useIsMobile } from '@/hooks/useIsMobile'; import { track } from '@/lib/analytics'; import styles from './PerformanceChart.module.css'; @@ -68,16 +69,7 @@ export function PerformanceChart({ const sortedData = [...data].sort((a, b) => a.year - b.year); const years = sortedData.map(d => formatAcademicYear(d.year)); - // ── Mobile detection ───────────────────────────────────────────────── - // Hydration-safe: SSR renders desktop; client flips to mobile after mount. - const [isMobile, setIsMobile] = useState(false); - useEffect(() => { - const mq = window.matchMedia('(max-width: 640px)'); - const update = () => setIsMobile(mq.matches); - update(); - mq.addEventListener('change', update); - return () => mq.removeEventListener('change', update); - }, []); + const isMobile = useIsMobile(); // ── Build per-year national averages ───────────────────────────────── const natRefRwm: (number | null)[] = sortedData.map(d => { diff --git a/nextjs-app/hooks/useIsMobile.ts b/nextjs-app/hooks/useIsMobile.ts new file mode 100644 index 0000000..216252c --- /dev/null +++ b/nextjs-app/hooks/useIsMobile.ts @@ -0,0 +1,23 @@ +/** + * Viewport hook shared by the chart components. + * Hydration-safe: SSR and the first client render report desktop; the + * media-query subscription flips the value after mount. + */ + +'use client'; + +import { useEffect, useState } from 'react'; + +export function useIsMobile(maxWidth = 640): boolean { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const mq = window.matchMedia(`(max-width: ${maxWidth}px)`); + const update = () => setIsMobile(mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, [maxWidth]); + + return isMobile; +} diff --git a/nextjs-app/lib/analytics.ts b/nextjs-app/lib/analytics.ts index 1046abc..793cc07 100644 --- a/nextjs-app/lib/analytics.ts +++ b/nextjs-app/lib/analytics.ts @@ -29,6 +29,7 @@ export type EventName = | 'compare_viewed' | 'compare_metric_changed' | 'compare_shared' + | 'compare_focus_school' // Operational | 'api_error' | 'results_load_more'; diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts index aaa5aa4..07b5d2e 100644 --- a/nextjs-app/lib/utils.ts +++ b/nextjs-app/lib/utils.ts @@ -317,6 +317,57 @@ export function getTrendColor(trend: 'up' | 'down' | 'stable'): string { } } +/** + * Broad shape of a KS2/KS4 metric, used to scale chart axes and format values. + */ +export type MetricKind = 'percentage' | 'progress' | 'score'; + +export function metricKind(metric: string): MetricKind { + if (metric.includes('progress')) return 'progress'; + if (metric.includes('pct') || metric.includes('rate')) return 'percentage'; + return 'score'; +} + +/** + * Fit a chart y-axis to the data instead of a fixed frame, so clustered + * series remain distinguishable. Padding keeps a minimum span so noise is + * not magnified into drama. + * + * - percentage: pad and snap to 5s; cap at 100; floor at 0 only when the + * data is non-negative (some trend metrics have `pct` in the key but hold + * negative year-over-year deltas). + * - progress: symmetric around 0 so the zero line always shows. + * - score (Attainment 8, scaled scores): pad and snap to integers; floor at + * 0 only when the data is non-negative. + */ +export function computeYBounds( + values: Array, + kind: MetricKind, +): { min?: number; max?: number } { + const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v)); + if (nums.length === 0) return {}; + + const lo = Math.min(...nums); + const hi = Math.max(...nums); + + if (kind === 'progress') { + const reach = Math.max(2, Math.ceil(Math.max(Math.abs(lo), Math.abs(hi)) + 0.5)); + return { min: -reach, max: reach }; + } + + if (kind === 'percentage') { + const pad = Math.max(5, Math.round((hi - lo) * 0.2)); + const min = Math.floor((lo - pad) / 5) * 5; + const max = Math.min(100, Math.ceil((hi + pad) / 5) * 5); + return { min: lo >= 0 ? Math.max(0, min) : min, max }; + } + + // score + const pad = Math.max(2, (hi - lo) * 0.2); + const min = Math.floor(lo - pad); + return { min: lo >= 0 ? Math.max(0, min) : min, max: Math.ceil(hi + pad) }; +} + // ============================================================================ // Local Storage Utilities // ============================================================================