Complete Next.js migration with SSR and Docker deployment
- 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>
This commit is contained in:
335
nextjs-app/lib/api.ts
Normal file
335
nextjs-app/lib/api.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* API Client for SchoolCompare FastAPI Backend
|
||||
* Handles all data fetching from the API with proper error handling and caching
|
||||
*/
|
||||
|
||||
import type {
|
||||
SchoolsResponse,
|
||||
SchoolDetailsResponse,
|
||||
ComparisonResponse,
|
||||
RankingsResponse,
|
||||
FiltersResponse,
|
||||
MetricsResponse,
|
||||
DataInfoResponse,
|
||||
SchoolSearchParams,
|
||||
RankingsParams,
|
||||
APIError,
|
||||
} from './types';
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
|
||||
|
||||
// Cache configuration for server-side fetching (Next.js revalidate)
|
||||
export const CACHE_DURATION = {
|
||||
FILTERS: 3600, // 1 hour
|
||||
METRICS: 3600, // 1 hour
|
||||
SCHOOLS_LIST: 60, // 1 minute
|
||||
SCHOOL_DETAILS: 300, // 5 minutes
|
||||
RANKINGS: 300, // 5 minutes
|
||||
COMPARISON: 60, // 1 minute
|
||||
DATA_INFO: 600, // 10 minutes
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Error Handling
|
||||
// ============================================================================
|
||||
|
||||
export class APIFetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status?: number,
|
||||
public detail?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'APIFetchError';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let errorDetail = `HTTP ${response.status}: ${response.statusText}`;
|
||||
|
||||
try {
|
||||
const errorData: APIError = await response.json();
|
||||
errorDetail = errorData.detail || errorDetail;
|
||||
} catch {
|
||||
// If parsing JSON fails, use the default error
|
||||
}
|
||||
|
||||
throw new APIFetchError(
|
||||
`API request failed: ${errorDetail}`,
|
||||
response.status,
|
||||
errorDetail
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function buildQueryString(params: Record<string, any>): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// School APIs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch schools with optional search and filters
|
||||
* Supports both server-side (SSR) and client-side fetching
|
||||
*/
|
||||
export async function fetchSchools(
|
||||
params: SchoolSearchParams = {},
|
||||
options: RequestInit = {}
|
||||
): Promise<SchoolsResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${API_BASE_URL}/schools${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.SCHOOLS_LIST,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<SchoolsResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch detailed information for a specific school by URN
|
||||
*/
|
||||
export async function fetchSchoolDetails(
|
||||
urn: number,
|
||||
options: RequestInit = {}
|
||||
): Promise<SchoolDetailsResponse> {
|
||||
const url = `${API_BASE_URL}/schools/${urn}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.SCHOOL_DETAILS,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<SchoolDetailsResponse>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Comparison APIs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch comparison data for multiple schools
|
||||
* @param urns - Comma-separated URNs or array of URNs
|
||||
*/
|
||||
export async function fetchComparison(
|
||||
urns: string | number[],
|
||||
options: RequestInit = {}
|
||||
): Promise<ComparisonResponse> {
|
||||
const urnsString = Array.isArray(urns) ? urns.join(',') : urns;
|
||||
const url = `${API_BASE_URL}/compare?urns=${urnsString}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.COMPARISON,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<ComparisonResponse>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rankings APIs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch school rankings by metric
|
||||
*/
|
||||
export async function fetchRankings(
|
||||
params: RankingsParams,
|
||||
options: RequestInit = {}
|
||||
): Promise<RankingsResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${API_BASE_URL}/rankings?${queryString}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.RANKINGS,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<RankingsResponse>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filter & Metadata APIs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch available filter options (local authorities, school types, years)
|
||||
*/
|
||||
export async function fetchFilters(
|
||||
options: RequestInit = {}
|
||||
): Promise<FiltersResponse> {
|
||||
const url = `${API_BASE_URL}/filters`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.FILTERS,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<FiltersResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch metric definitions (labels, descriptions, formats)
|
||||
*/
|
||||
export async function fetchMetrics(
|
||||
options: RequestInit = {}
|
||||
): Promise<MetricsResponse> {
|
||||
const url = `${API_BASE_URL}/metrics`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.METRICS,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<MetricsResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch database statistics and info
|
||||
*/
|
||||
export async function fetchDataInfo(
|
||||
options: RequestInit = {}
|
||||
): Promise<DataInfoResponse> {
|
||||
const url = `${API_BASE_URL}/data-info`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.DATA_INFO,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<DataInfoResponse>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Client-Side Fetcher (for SWR)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generic fetcher function for use with SWR
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data, error } = useSWR('/api/schools', fetcher);
|
||||
* ```
|
||||
*/
|
||||
export async function fetcher<T>(url: string): Promise<T> {
|
||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url.startsWith('/') ? url.slice(4) : url}`;
|
||||
|
||||
const response = await fetch(fullUrl);
|
||||
return handleResponse<T>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Geocoding API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Geocode a UK postcode using postcodes.io
|
||||
*/
|
||||
export async function geocodePostcode(postcode: string): Promise<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
} | null> {
|
||||
try {
|
||||
const cleanPostcode = postcode.trim().toUpperCase();
|
||||
const response = await fetch(
|
||||
`https://api.postcodes.io/postcodes/${encodeURIComponent(cleanPostcode)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.result) {
|
||||
return {
|
||||
latitude: data.result.latitude,
|
||||
longitude: data.result.longitude,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Geocoding error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate distance between two coordinates using Haversine formula
|
||||
* @returns Distance in kilometers
|
||||
*/
|
||||
export function calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in kilometers
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert kilometers to miles
|
||||
*/
|
||||
export function kmToMiles(km: number): number {
|
||||
return km * 0.621371;
|
||||
}
|
||||
297
nextjs-app/lib/types.ts
Normal file
297
nextjs-app/lib/types.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* TypeScript type definitions for SchoolCompare API
|
||||
* Generated from backend/models.py and backend/schemas.py
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// School Types
|
||||
// ============================================================================
|
||||
|
||||
export interface School {
|
||||
id?: number;
|
||||
urn: number;
|
||||
school_name: string;
|
||||
local_authority: string | null;
|
||||
local_authority_code: number | null;
|
||||
school_type: string | null;
|
||||
school_type_code: string | null;
|
||||
religious_denomination: string | null;
|
||||
age_range: string | null;
|
||||
|
||||
// Address
|
||||
address1: string | null;
|
||||
address2: string | null;
|
||||
town: string | null;
|
||||
postcode: string | null;
|
||||
address?: string; // Computed full address
|
||||
|
||||
// Geocoding
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
|
||||
// Latest year metrics (for search/list views)
|
||||
rwm_expected_pct?: number | null;
|
||||
reading_expected_pct?: number | null;
|
||||
writing_expected_pct?: number | null;
|
||||
maths_expected_pct?: number | null;
|
||||
reading_progress?: number | null;
|
||||
writing_progress?: number | null;
|
||||
maths_progress?: number | null;
|
||||
|
||||
// Trend indicators (for list views)
|
||||
prev_rwm_expected_pct?: number | null;
|
||||
trend?: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// School Result Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SchoolResult {
|
||||
id?: number;
|
||||
school_id: number;
|
||||
year: number;
|
||||
|
||||
// Pupil numbers
|
||||
total_pupils: number | null;
|
||||
eligible_pupils: number | null;
|
||||
|
||||
// Core KS2 metrics - Expected Standard
|
||||
rwm_expected_pct: number | null;
|
||||
reading_expected_pct: number | null;
|
||||
writing_expected_pct: number | null;
|
||||
maths_expected_pct: number | null;
|
||||
gps_expected_pct: number | null;
|
||||
science_expected_pct: number | null;
|
||||
|
||||
// Higher Standard
|
||||
rwm_high_pct: number | null;
|
||||
reading_high_pct: number | null;
|
||||
writing_high_pct: number | null;
|
||||
maths_high_pct: number | null;
|
||||
gps_high_pct: number | null;
|
||||
|
||||
// Progress Scores
|
||||
reading_progress: number | null;
|
||||
writing_progress: number | null;
|
||||
maths_progress: number | null;
|
||||
|
||||
// Average Scores
|
||||
reading_avg_score: number | null;
|
||||
maths_avg_score: number | null;
|
||||
gps_avg_score: number | null;
|
||||
|
||||
// School Context
|
||||
disadvantaged_pct: number | null;
|
||||
eal_pct: number | null;
|
||||
sen_support_pct: number | null;
|
||||
sen_ehcp_pct: number | null;
|
||||
stability_pct: number | null;
|
||||
|
||||
// Pupil Absence from Tests
|
||||
reading_absence_pct: number | null;
|
||||
gps_absence_pct: number | null;
|
||||
maths_absence_pct: number | null;
|
||||
writing_absence_pct: number | null;
|
||||
science_absence_pct: number | null;
|
||||
|
||||
// Gender Breakdown
|
||||
rwm_expected_boys_pct: number | null;
|
||||
rwm_expected_girls_pct: number | null;
|
||||
rwm_high_boys_pct: number | null;
|
||||
rwm_high_girls_pct: number | null;
|
||||
|
||||
// Disadvantaged Performance
|
||||
rwm_expected_disadvantaged_pct: number | null;
|
||||
rwm_expected_non_disadvantaged_pct: number | null;
|
||||
disadvantaged_gap: number | null;
|
||||
|
||||
// 3-Year Averages
|
||||
rwm_expected_3yr_pct: number | null;
|
||||
reading_avg_3yr: number | null;
|
||||
maths_avg_3yr: number | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Response Types
|
||||
// ============================================================================
|
||||
|
||||
export interface AbsenceData {
|
||||
overall_absence_rate: number | null;
|
||||
persistent_absence_rate: number | null;
|
||||
}
|
||||
|
||||
export interface PaginationInfo {
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface SchoolsResponse {
|
||||
schools: School[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
search_mode?: 'name' | 'location';
|
||||
location_info?: {
|
||||
postcode: string;
|
||||
radius: number;
|
||||
coordinates: [number, number];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SchoolDetailsResponse {
|
||||
school_info: School;
|
||||
yearly_data: SchoolResult[];
|
||||
absence_data: AbsenceData | null;
|
||||
}
|
||||
|
||||
export interface ComparisonData {
|
||||
school_info: School;
|
||||
yearly_data: SchoolResult[];
|
||||
}
|
||||
|
||||
export interface ComparisonResponse {
|
||||
comparison: Record<string, ComparisonData>;
|
||||
}
|
||||
|
||||
export interface RankingItem {
|
||||
urn: number;
|
||||
school_name: string;
|
||||
local_authority: string;
|
||||
school_type?: string;
|
||||
value: number;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
// Alias for backwards compatibility
|
||||
export type RankingEntry = RankingItem;
|
||||
|
||||
export interface RankingsResponse {
|
||||
rankings: RankingItem[];
|
||||
metric: string;
|
||||
year?: number;
|
||||
local_authority?: string;
|
||||
}
|
||||
|
||||
export interface Filters {
|
||||
local_authorities: string[];
|
||||
school_types: string[];
|
||||
years: number[];
|
||||
}
|
||||
|
||||
export interface FiltersResponse {
|
||||
filters: Filters;
|
||||
}
|
||||
|
||||
export interface MetricDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: 'expected' | 'higher' | 'progress' | 'average' | 'context' | 'absence' | 'gender' | 'disadvantaged' | '3yr';
|
||||
format: 'percentage' | 'score' | 'progress';
|
||||
hasNationalAverage?: boolean;
|
||||
}
|
||||
|
||||
export interface MetricsResponse {
|
||||
metrics: Record<string, MetricDefinition>;
|
||||
}
|
||||
|
||||
export interface DataInfoResponse {
|
||||
total_schools: number;
|
||||
years_available: number[];
|
||||
latest_year: number;
|
||||
total_records: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Request Parameter Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SchoolSearchParams {
|
||||
search?: string;
|
||||
local_authority?: string;
|
||||
school_type?: string;
|
||||
postcode?: string;
|
||||
radius?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface RankingsParams {
|
||||
metric: string;
|
||||
year?: number;
|
||||
local_authority?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ComparisonParams {
|
||||
urns: string | number[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UI State Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ComparisonState {
|
||||
selectedSchools: School[];
|
||||
selectedMetric: string;
|
||||
}
|
||||
|
||||
export interface SearchState {
|
||||
mode: 'name' | 'location';
|
||||
query: string;
|
||||
filters: {
|
||||
local_authority: string;
|
||||
school_type: string;
|
||||
};
|
||||
postcode?: string;
|
||||
radius?: number;
|
||||
resultsView: 'list' | 'map';
|
||||
}
|
||||
|
||||
export interface MapState {
|
||||
center: [number, number];
|
||||
zoom: number;
|
||||
bounds?: [[number, number], [number, number]];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Chart Data Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ChartDataset {
|
||||
label: string;
|
||||
data: (number | null)[];
|
||||
borderColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
labels: (string | number)[];
|
||||
datasets: ChartDataset[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Error Types
|
||||
// ============================================================================
|
||||
|
||||
export interface APIError {
|
||||
detail: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Types
|
||||
// ============================================================================
|
||||
|
||||
export type MetricKey = keyof Omit<SchoolResult, 'id' | 'school_id' | 'year'>;
|
||||
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface SortConfig {
|
||||
key: string;
|
||||
direction: SortDirection;
|
||||
}
|
||||
358
nextjs-app/lib/utils.ts
Normal file
358
nextjs-app/lib/utils.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user