Files
school_compare/nextjs-app/lib/utils.ts
T
TudorandClaude Opus 4.8 ce2bfea91b
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 13s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 54s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
fix(search): badge inspected-but-ungraded schools as "Inspected", not "Not yet inspected"
A school inspected under the OEIF framework after September 2024 has an
inspection on record (ofsted_date set) but no overall_effectiveness grade,
since Ofsted no longer issues an overall judgement. buildOfstedListBadge
had no branch for this and fell through to "Not yet inspected", while the
detail page's hero chip correctly reported it as inspected — so the same
school read two contradictory ways.

Add an "Inspected · YYYY" branch that fires when an inspection is on record
(date or framework present) but no grade and not a Report Card, mirroring
the hero chip's fallback. Genuinely un-inspected schools (all Ofsted fields
null) still show "Not yet inspected". Add an .ofstedInspected badge style
(neutral slate) distinct from the grey pending state, and cover both cases
with tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:36:23 +01:00

637 lines
19 KiB
TypeScript

/**
* 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() + '...';
}
// ============================================================================
// 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];
}
/**
* 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
}
}
// ============================================================================
// 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' };
}