/** * 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)}%`; } /** * 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 any>( func: T, wait: number ): (...args: Parameters) => void { let timeout: NodeJS.Timeout | null = null; return function executedFunction(...args: Parameters) { 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(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(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 { 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 { const params = new URLSearchParams(search); const result: Record = {}; 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 = { 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(', ') + '.'; }