Files
school_compare/nextjs-app/lib/utils.ts
T
TudorandClaude Fable 5 22769b6295
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m49s
feat(compare): readable comparison chart on mobile
The compare chart squashed clustered schools into a thin band (y pinned
0-100) under an in-chart title + per-school legend that ate ~40% of a
300px card, leaving converging lines indistinguishable on phones.

- Auto-fit the y-axis to the data on all viewports (computeYBounds in
  lib/utils: padded + min-span for percentages, symmetric around 0 for
  progress, fitted for scores; negative pct-named trend metrics are not
  zero-clamped).
- Distinct point style per school (circle/triangle/rect/rectRot/star)
  as secondary encoding for convergence and colour-blindness.
- Mobile: drop in-chart title/legend/axis titles; add a chip row (colour
  dot + name) that doubles as tap-to-focus — highlights one school's
  line and dims the rest. Chart card 300px -> 340px, nearly all plot.
- Fix a latent colour mismatch: datasets were built from Object.entries
  whose integer-like URN keys enumerate in ascending numeric order,
  desyncing line colours from card colours; the chart now receives the
  ordered school list.
- Union years across schools instead of taking the first school's.
- Extract PerformanceChart's matchMedia pattern into hooks/useIsMobile.

Unit tests for metricKind/computeYBounds; e2e journey covers the mobile
chips and focus toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 22:02:26 +01:00

721 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Utility functions for SchoolCompare
*/
import type { School, MetricDefinition, OfstedInspection, SchoolAdmissions, SchoolResult } from './types';
// ============================================================================
// String Utilities
// ============================================================================
/**
* Create a URL-friendly slug from a string
*/
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
}
/**
* Build a school URL path: /school/123456-school-name (capped at ~80 chars total)
*/
const MAX_SLUG_LENGTH = 60;
export function schoolUrl(urn: number, schoolName?: string): string {
if (!schoolName) return `/school/${urn}`;
let slug = slugify(schoolName);
if (slug.length > MAX_SLUG_LENGTH) {
slug = slug.slice(0, MAX_SLUG_LENGTH).replace(/-+$/, '');
}
return `/school/${urn}-${slug}`;
}
/**
* Extract the URN from a school slug (e.g. "138267-some-school-name" → 138267)
*/
export function parseSchoolSlug(slug: string): number | null {
const match = slug.match(/^(\d{6})/);
return match ? parseInt(match[1], 10) : null;
}
/**
* Escape HTML to prevent XSS
*/
export function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Truncate text to a maximum length
*/
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trim() + '...';
}
/**
* Format a school's age range for display, e.g. "3-11" → "Ages 311".
* Display-only — leaves the raw `age_range` field (used for sixth-form
* detection) untouched. Falls back to the raw value if it's not a plain range.
*/
export function formatAgeRange(ageRange: string | null | undefined): string {
if (!ageRange) return '';
const match = ageRange.match(/^\s*(\d+)\s*[-]\s*(\d+)\s*$/);
if (!match) return ageRange;
return `Ages ${match[1]}${match[2]}`;
}
// ============================================================================
// Number Formatting
// ============================================================================
/**
* Format a number as a percentage
*/
export function formatPercentage(value: number | null | undefined, decimals: number = 1): string {
if (value === null || value === undefined) return 'N/A';
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)
*/
export function formatProgress(value: number | null | undefined, decimals: number = 1): string {
if (value === null || value === undefined) return 'N/A';
const formatted = value.toFixed(decimals);
return value > 0 ? `+${formatted}` : formatted;
}
/**
* Format a score (e.g., test scores)
*/
export function formatScore(value: number | null | undefined, decimals: number = 1): string {
if (value === null || value === undefined) return 'N/A';
return value.toFixed(decimals);
}
/**
* Format a metric value based on its type
*/
export function formatMetricValue(
value: number | null | undefined,
format: MetricDefinition['format']
): string {
switch (format) {
case 'percentage':
return formatPercentage(value);
case 'progress':
return formatProgress(value);
case 'score':
return formatScore(value);
default:
return value?.toString() || 'N/A';
}
}
// ============================================================================
// Trend Analysis
// ============================================================================
/**
* Calculate the trend between two values
*/
export function calculateTrend(
current: number | null | undefined,
previous: number | null | undefined
): 'up' | 'down' | 'stable' {
if (current === null || current === undefined || previous === null || previous === undefined) {
return 'stable';
}
const diff = current - previous;
if (Math.abs(diff) < 0.5) return 'stable'; // Within 0.5% is considered stable
return diff > 0 ? 'up' : 'down';
}
/**
* Calculate percentage change
*/
export function calculateChange(
current: number | null | undefined,
previous: number | null | undefined
): number | null {
if (current === null || current === undefined || previous === null || previous === undefined) {
return null;
}
return current - previous;
}
// ============================================================================
// Statistical Functions
// ============================================================================
/**
* Calculate the average of an array of numbers
*/
export function average(values: (number | null)[]): number | null {
const validValues = values.filter((v): v is number => v !== null && !isNaN(v));
if (validValues.length === 0) return null;
const sum = validValues.reduce((acc, val) => acc + val, 0);
return sum / validValues.length;
}
/**
* Calculate the standard deviation
*/
export function standardDeviation(values: (number | null)[]): number | null {
const validValues = values.filter((v): v is number => v !== null && !isNaN(v));
if (validValues.length === 0) return null;
const avg = average(validValues);
if (avg === null) return null;
const squareDiffs = validValues.map((value) => Math.pow(value - avg, 2));
const avgSquareDiff = average(squareDiffs);
return avgSquareDiff !== null ? Math.sqrt(avgSquareDiff) : null;
}
/**
* Calculate variability label based on standard deviation
*/
export function getVariabilityLabel(stdDev: number | null): string {
if (stdDev === null) return 'Unknown';
if (stdDev < 2) return 'Very Stable';
if (stdDev < 5) return 'Stable';
if (stdDev < 10) return 'Moderate';
return 'Variable';
}
// ============================================================================
// Validation
// ============================================================================
/**
* Validate UK postcode format
*/
export function isValidPostcode(postcode: string): boolean {
const postcodeRegex = /^[A-Z]{1,2}[0-9][A-Z0-9]?\s*[0-9][A-Z]{2}$/i;
return postcodeRegex.test(postcode.trim());
}
/**
* Validate URN (Unique Reference Number)
*/
export function isValidUrn(urn: number | string): boolean {
const urnNumber = typeof urn === 'string' ? parseInt(urn, 10) : urn;
return !isNaN(urnNumber) && urnNumber >= 100000 && urnNumber <= 999999;
}
// ============================================================================
// Debounce
// ============================================================================
/**
* Debounce a function call
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
}
// ============================================================================
// Color Utilities
// ============================================================================
/**
* Chart color palette (consistent with vanilla JS app)
*/
export const CHART_COLORS = [
'rgb(75, 192, 192)',
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(255, 206, 86)',
'rgb(153, 102, 255)',
'rgb(255, 159, 64)',
'rgb(201, 203, 207)',
'rgb(255, 0, 255)',
];
/**
* Get a color from the palette by index
*/
export function getChartColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
/**
* Darker counterparts of CHART_COLORS for use as TEXT on the site's light
* backgrounds — the raw series colours fail WCAG AA (e.g. 1.9:1 on cream).
* Same hue per index so text still keys to its chart line. All ≥4.5:1 on
* #f3ede4 and lighter.
*/
export const CHART_TEXT_COLORS = [
'#256868', // teal
'#c2255c', // pink/red
'#2069ad', // blue
'#8a5a00', // yellow → ochre
'#6d43c8', // purple
'#8f4e00', // orange → brown
'#5f5952', // grey
'#a300a3', // magenta
];
export function getChartTextColor(index: number): string {
return CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length];
}
/**
* Convert RGB color to RGBA with opacity
*/
export function rgbToRgba(rgb: string, alpha: number): string {
return rgb.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
}
/**
* Get trend color based on direction
*/
export function getTrendColor(trend: 'up' | 'down' | 'stable'): string {
switch (trend) {
case 'up':
return '#22c55e'; // green
case 'down':
return '#ef4444'; // red
case 'stable':
return '#6b7280'; // gray
}
}
/**
* Broad shape of a KS2/KS4 metric, used to scale chart axes and format values.
*/
export type MetricKind = 'percentage' | 'progress' | 'score';
export function metricKind(metric: string): MetricKind {
if (metric.includes('progress')) return 'progress';
if (metric.includes('pct') || metric.includes('rate')) return 'percentage';
return 'score';
}
/**
* Fit a chart y-axis to the data instead of a fixed frame, so clustered
* series remain distinguishable. Padding keeps a minimum span so noise is
* not magnified into drama.
*
* - percentage: pad and snap to 5s; cap at 100; floor at 0 only when the
* data is non-negative (some trend metrics have `pct` in the key but hold
* negative year-over-year deltas).
* - progress: symmetric around 0 so the zero line always shows.
* - score (Attainment 8, scaled scores): pad and snap to integers; floor at
* 0 only when the data is non-negative.
*/
export function computeYBounds(
values: Array<number | null | undefined>,
kind: MetricKind,
): { min?: number; max?: number } {
const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
if (nums.length === 0) return {};
const lo = Math.min(...nums);
const hi = Math.max(...nums);
if (kind === 'progress') {
const reach = Math.max(2, Math.ceil(Math.max(Math.abs(lo), Math.abs(hi)) + 0.5));
return { min: -reach, max: reach };
}
if (kind === 'percentage') {
const pad = Math.max(5, Math.round((hi - lo) * 0.2));
const min = Math.floor((lo - pad) / 5) * 5;
const max = Math.min(100, Math.ceil((hi + pad) / 5) * 5);
return { min: lo >= 0 ? Math.max(0, min) : min, max };
}
// score
const pad = Math.max(2, (hi - lo) * 0.2);
const min = Math.floor(lo - pad);
return { min: lo >= 0 ? Math.max(0, min) : min, max: Math.ceil(hi + pad) };
}
// ============================================================================
// Local Storage Utilities
// ============================================================================
/**
* Safely get item from localStorage
*/
export function getFromLocalStorage<T>(key: string, defaultValue: T): T {
if (typeof window === 'undefined') return defaultValue;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.error(`Error reading from localStorage key "${key}":`, error);
return defaultValue;
}
}
/**
* Safely set item in localStorage
*/
export function setToLocalStorage<T>(key: string, value: T): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error(`Error writing to localStorage key "${key}":`, error);
}
}
/**
* Remove item from localStorage
*/
export function removeFromLocalStorage(key: string): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(key);
} catch (error) {
console.error(`Error removing from localStorage key "${key}":`, error);
}
}
// ============================================================================
// URL Utilities
// ============================================================================
/**
* Build URL with query parameters
*/
export function buildUrl(base: string, params: Record<string, any>): string {
const url = new URL(base, window.location.origin);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, String(value));
}
});
return url.pathname + url.search;
}
/**
* Parse query string into object
*/
export function parseQueryString(search: string): Record<string, string> {
const params = new URLSearchParams(search);
const result: Record<string, string> = {};
params.forEach((value, key) => {
result[key] = value;
});
return result;
}
// ============================================================================
// Date Utilities
// ============================================================================
/**
* Format academic year.
* Handles both 4-digit start years (2023 → "2023/24") and
* 6-digit EES codes (202526 → "2025/26").
*/
export function getPhaseStyle(phase?: string | null): { key: string; label: string } {
switch (phase?.toLowerCase()) {
case 'primary':
case 'middle deemed primary':
return { key: 'Primary', label: 'Primary' };
case 'secondary':
case 'middle deemed secondary':
return { key: 'Secondary', label: 'Secondary' };
case 'all-through':
return { key: 'AllThrough', label: 'All-through' };
case '16 plus':
return { key: 'Post16', label: 'Post-16' };
case 'nursery':
return { key: 'Nursery', label: 'Nursery' };
default:
return { key: '', label: '' };
}
}
export function formatAcademicYear(year: number | null | undefined): string {
if (year == null) return '';
const s = year.toString();
if (s.length === 6) {
return `${s.slice(0, 4)}/${s.slice(4)}`;
}
const nextYear = (year + 1).toString().slice(-2);
return `${year}/${nextYear}`;
}
/**
* Get current academic year
*/
export function getCurrentAcademicYear(): number {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
// Academic year starts in September (month 8)
return month >= 8 ? year : year - 1;
}
// ============================================================================
// School Detail Hero Helpers
// ============================================================================
const OFSTED_OEIF_WORDS: Record<number, string> = {
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
};
/**
* Format an Ofsted inspection date as "Month YYYY" (e.g. "November 2023").
*/
function formatOfstedMonth(date: string | null | undefined): string {
if (!date) return '';
const d = new Date(date);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
}
export type HeroTone = 'teal' | 'green' | 'gold' | 'coral' | 'neutral';
export interface OfstedHeroChip {
state: 'oeif' | 'reportCard' | 'none';
title: string; // Main label (e.g. "Ofsted Outstanding", "Ofsted Report Card")
subtitle: string; // Context line (e.g. "Inspected November 2023")
detail?: string; // Optional extra line (e.g. "Safeguarding: Met")
tone: HeroTone; // Maps to dedicated hero tone classes (not badge classes)
}
/**
* Build the hero-strip Ofsted chip, branching on the inspection framework.
* Never synthesises a single overall grade for ReportCard schools.
*
* Note: the API may return ``framework`` as a literal string ``"NULL"`` for
* older inspections, so we explicitly only branch into the ReportCard layout
* when the value is exactly ``"ReportCard"``. Anything else with an
* ``overall_effectiveness`` score is treated as OEIF.
*/
export function buildOfstedHeroChip(ofsted: OfstedInspection | null | undefined): OfstedHeroChip {
if (!ofsted) {
return {
state: 'none',
title: 'Ofsted pending',
subtitle: 'No inspection on record',
tone: 'neutral',
};
}
const when = formatOfstedMonth(ofsted.inspection_date);
// ReportCard branch — only if the API explicitly says so
if (ofsted.framework === 'ReportCard') {
const safeguarding = ofsted.rc_safeguarding_met;
return {
state: 'reportCard',
title: 'Ofsted Report Card',
subtitle: when ? `Inspected ${when}` : 'New framework inspection',
detail:
safeguarding == null
? undefined
: safeguarding ? 'Safeguarding: Met' : 'Safeguarding: Not met',
tone: safeguarding === false ? 'coral' : 'green',
};
}
// Otherwise treat as OEIF (covers framework === 'OEIF', null, "NULL", etc.)
const grade = ofsted.overall_effectiveness;
if (grade && OFSTED_OEIF_WORDS[grade]) {
const oeifTone: HeroTone =
grade === 1 ? 'teal' :
grade === 2 ? 'green' :
grade === 3 ? 'gold' :
'coral';
return {
state: 'oeif',
title: `Ofsted ${OFSTED_OEIF_WORDS[grade]}`,
subtitle: when ? `Inspected ${when}` : 'Inspected',
tone: oeifTone,
};
}
return {
state: 'oeif',
title: 'Ofsted inspected',
subtitle: when ? `Inspected ${when}` : 'Inspection on record',
tone: 'neutral',
};
}
/**
* Build a one-sentence editorial summary for the school detail hero.
* Branches on Ofsted framework so Report Card schools are never described
* with an overall grade they do not have.
*/
export function buildSchoolSummary(
schoolInfo: School,
ofsted: OfstedInspection | null | undefined,
admissions: SchoolAdmissions | null | undefined,
latestResults: SchoolResult | null | undefined,
): string {
const parts: string[] = [];
// Size descriptor
const pupils = latestResults?.total_pupils ?? schoolInfo.total_pupils ?? null;
const sizeWord =
pupils == null ? '' :
pupils < 200 ? 'Small' :
pupils < 500 ? 'Mid-sized' :
'Large';
// Phase descriptor — avoid the raw code
const phase = (schoolInfo.phase ?? '').toLowerCase();
const phaseWord =
phase.includes('secondary') ? 'secondary' :
phase === 'all-through' ? 'all-through' :
phase.includes('primary') ? 'primary' :
'school';
// Religious character
const religion = schoolInfo.religious_denomination;
const religionWord =
!religion || /none|does not apply/i.test(religion) ? '' :
/roman catholic|catholic/i.test(religion) ? 'Catholic ' :
/church of england|ce|anglican/i.test(religion) ? 'Church of England ' :
/jewish/i.test(religion) ? 'Jewish ' :
/muslim|islam/i.test(religion) ? 'Muslim ' :
/hindu/i.test(religion) ? 'Hindu ' :
/sikh/i.test(religion) ? 'Sikh ' :
'';
// Locality — prefer town from address parsing (fallback to LA)
const locality = schoolInfo.town || schoolInfo.local_authority || '';
const lead = [sizeWord, religionWord + phaseWord].filter(Boolean).join(' ');
let opening = lead || 'School';
if (locality) opening += ` in ${locality}`;
parts.push(opening);
// Ofsted clause (framework-aware)
if (ofsted?.framework === 'OEIF' && ofsted.overall_effectiveness) {
parts.push(`rated ${OFSTED_OEIF_WORDS[ofsted.overall_effectiveness]} by Ofsted`);
} else if (ofsted?.framework === 'ReportCard') {
const when = formatOfstedMonth(ofsted.inspection_date);
parts.push(
when
? `most recently inspected under Ofsted's Report Card framework in ${when}`
: "recently inspected under Ofsted's new Report Card framework",
);
}
// Admissions clause
if (admissions?.oversubscribed) {
if (admissions.first_preference_offer_pct != null) {
const pct = Math.round(admissions.first_preference_offer_pct);
parts.push(
`oversubscribed — ${pct}% of first-choice applicants are offered a place`,
);
} else {
parts.push('oversubscribed');
}
} else if (admissions?.first_preference_offer_pct != null && admissions.first_preference_offer_pct >= 90) {
parts.push('most families get their first-choice offer');
}
return parts.join(', ') + '.';
}
// ─── List-level Ofsted badge ──────────────────────────────────────────────────
export interface OfstedListBadge {
/** Display text for the badge (e.g. "Outstanding · 2023", "Report Card · 2025") */
label: string;
/** CSS module class key — one of: ofsted1 | ofsted2 | ofsted3 | ofsted4 | ofstedRc | ofstedPending */
cssClass: string;
}
/**
* Build the Ofsted badge for a school card in the list/map view.
* Three states:
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed
* - ReportCard school (ofsted_framework === 'ReportCard'): "Report Card · YYYY" in purple
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
* longer issues an overall judgement): "Inspected · YYYY" — mirrors the
* detail page's hero chip so a school never reads as both inspected and
* "Not yet inspected"
* - No inspection on record: "Not yet inspected" in grey
*/
export function buildOfstedListBadge(school: {
ofsted_grade?: 1 | 2 | 3 | 4 | null;
ofsted_date?: string | null;
ofsted_framework?: string | null;
}): OfstedListBadge {
const year = school.ofsted_date
? new Date(school.ofsted_date).getFullYear()
: null;
const yearStr = year ? ` · ${year}` : '';
if (school.ofsted_grade) {
const labels: Record<number, string> = {
1: 'Outstanding',
2: 'Good',
3: 'Req. Improvement',
4: 'Inadequate',
};
return {
label: `${labels[school.ofsted_grade]}${yearStr}`,
cssClass: `ofsted${school.ofsted_grade}`,
};
}
if (school.ofsted_framework === 'ReportCard') {
return { label: `Report Card${yearStr}`, cssClass: 'ofstedRc' };
}
// An inspection is on record (date or framework present) but carries no
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
// that has genuinely never been inspected.
if (school.ofsted_date != null || school.ofsted_framework != null) {
return { label: `Inspected${yearStr}`, cssClass: 'ofstedInspected' };
}
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
}