- Migrate from vanilla JavaScript SPA to Next.js 16 with App Router - Add server-side rendering for all pages (Home, Compare, Rankings) - Create individual school pages with dynamic routing (/school/[urn]) - Implement Chart.js and Leaflet map integrations - Add comprehensive SEO with sitemap, robots.txt, and JSON-LD - Set up Docker multi-service architecture (PostgreSQL, FastAPI, Next.js) - Update CI/CD pipeline to build both backend and frontend images - Fix Dockerfile to include devDependencies for TypeScript compilation - Add Jest testing configuration - Implement performance optimizations (code splitting, caching) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
359 lines
9.5 KiB
TypeScript
359 lines
9.5 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();
|
|
}
|
|
|
|
/**
|
|
* 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 (e.g., 2023 -> "2023/24")
|
|
*/
|
|
export function formatAcademicYear(year: number): string {
|
|
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;
|
|
}
|