/** * Utility functions for SchoolCompare */ import type { School, MetricDefinition } 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): string { 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; }