Complete Next.js migration with SSR and Docker deployment
Some checks failed
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 1m26s
Build and Push Docker Images / Build Frontend (Next.js) (push) Failing after 1m48s
Build and Push Docker Images / Trigger Portainer Update (push) Has been skipped

- 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:
Tudor
2026-02-02 20:34:35 +00:00
parent f4919db3b9
commit ff7f5487e6
72 changed files with 18636 additions and 20 deletions

335
nextjs-app/lib/api.ts Normal file
View 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
View 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
View 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;
}