Files
school_compare/nextjs-app/lib/utils.ts
Tudor Sitaru 4db36b9099
All checks were successful
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 12s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 49s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 11s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
feat(ui): add phase indicators to school list rows
Add coloured left-border and phase label pill to visually differentiate
school phases (Primary, Secondary, All-through, Post-16, Nursery) in
search result lists. Colours are accessible (WCAG AA) and don't clash
with existing Ofsted/trend semantic colours.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 15:47:51 +01:00

406 lines
11 KiB
TypeScript

/**
* 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<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): 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;
}