2026-02-02 20:34:35 +00:00
|
|
|
/**
|
|
|
|
|
* Utility functions for SchoolCompare
|
|
|
|
|
*/
|
|
|
|
|
|
2026-04-08 10:32:33 +01:00
|
|
|
import type { School, MetricDefinition, OfstedInspection, SchoolAdmissions, SchoolResult } from './types';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 12:41:28 +01:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
/**
|
2026-03-27 18:30:37 +00:00
|
|
|
* Format academic year.
|
|
|
|
|
* Handles both 4-digit start years (2023 → "2023/24") and
|
|
|
|
|
* 6-digit EES codes (202526 → "2025/26").
|
2026-02-02 20:34:35 +00:00
|
|
|
*/
|
2026-04-01 15:47:51 +01:00
|
|
|
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: '' };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 16:07:28 +01:00
|
|
|
export function formatAcademicYear(year: number | null | undefined): string {
|
|
|
|
|
if (year == null) return '';
|
2026-03-27 18:30:37 +00:00
|
|
|
const s = year.toString();
|
|
|
|
|
if (s.length === 6) {
|
|
|
|
|
return `${s.slice(0, 4)}/${s.slice(4)}`;
|
|
|
|
|
}
|
2026-02-02 20:34:35 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-08 10:32:33 +01:00
|
|
|
|
|
|
|
|
// ============================================================================
|
|
|
|
|
// School Detail Hero Helpers
|
|
|
|
|
// ============================================================================
|
|
|
|
|
|
|
|
|
|
const OFSTED_OEIF_WORDS: Record<number, string> = {
|
|
|
|
|
1: 'Outstanding', 2: 'Good', 3: 'Requires Improvement', 4: 'Inadequate',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format an Ofsted inspection date as "Month YYYY" (e.g. "November 2023").
|
|
|
|
|
*/
|
|
|
|
|
function formatOfstedMonth(date: string | null | undefined): string {
|
|
|
|
|
if (!date) return '';
|
|
|
|
|
const d = new Date(date);
|
|
|
|
|
if (Number.isNaN(d.getTime())) return '';
|
|
|
|
|
return d.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 10:44:37 +01:00
|
|
|
export type HeroTone = 'teal' | 'green' | 'gold' | 'coral' | 'neutral';
|
|
|
|
|
|
2026-04-08 10:32:33 +01:00
|
|
|
export interface OfstedHeroChip {
|
|
|
|
|
state: 'oeif' | 'reportCard' | 'none';
|
|
|
|
|
title: string; // Main label (e.g. "Ofsted Outstanding", "Ofsted Report Card")
|
|
|
|
|
subtitle: string; // Context line (e.g. "Inspected November 2023")
|
|
|
|
|
detail?: string; // Optional extra line (e.g. "Safeguarding: Met")
|
2026-04-08 10:44:37 +01:00
|
|
|
tone: HeroTone; // Maps to dedicated hero tone classes (not badge classes)
|
2026-04-08 10:32:33 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build the hero-strip Ofsted chip, branching on the inspection framework.
|
|
|
|
|
* Never synthesises a single overall grade for ReportCard schools.
|
2026-04-08 10:44:37 +01:00
|
|
|
*
|
|
|
|
|
* Note: the API may return ``framework`` as a literal string ``"NULL"`` for
|
|
|
|
|
* older inspections, so we explicitly only branch into the ReportCard layout
|
|
|
|
|
* when the value is exactly ``"ReportCard"``. Anything else with an
|
|
|
|
|
* ``overall_effectiveness`` score is treated as OEIF.
|
2026-04-08 10:32:33 +01:00
|
|
|
*/
|
|
|
|
|
export function buildOfstedHeroChip(ofsted: OfstedInspection | null | undefined): OfstedHeroChip {
|
2026-04-08 10:44:37 +01:00
|
|
|
if (!ofsted) {
|
2026-04-08 10:32:33 +01:00
|
|
|
return {
|
|
|
|
|
state: 'none',
|
|
|
|
|
title: 'Ofsted pending',
|
|
|
|
|
subtitle: 'No inspection on record',
|
2026-04-08 10:44:37 +01:00
|
|
|
tone: 'neutral',
|
2026-04-08 10:32:33 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const when = formatOfstedMonth(ofsted.inspection_date);
|
|
|
|
|
|
2026-04-08 10:44:37 +01:00
|
|
|
// ReportCard branch — only if the API explicitly says so
|
|
|
|
|
if (ofsted.framework === 'ReportCard') {
|
|
|
|
|
const safeguarding = ofsted.rc_safeguarding_met;
|
|
|
|
|
return {
|
|
|
|
|
state: 'reportCard',
|
|
|
|
|
title: 'Ofsted Report Card',
|
|
|
|
|
subtitle: when ? `Inspected ${when}` : 'New framework inspection',
|
|
|
|
|
detail:
|
|
|
|
|
safeguarding == null
|
|
|
|
|
? undefined
|
|
|
|
|
: safeguarding ? 'Safeguarding: Met' : 'Safeguarding: Not met',
|
|
|
|
|
tone: safeguarding === false ? 'coral' : 'green',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Otherwise treat as OEIF (covers framework === 'OEIF', null, "NULL", etc.)
|
|
|
|
|
const grade = ofsted.overall_effectiveness;
|
|
|
|
|
if (grade && OFSTED_OEIF_WORDS[grade]) {
|
|
|
|
|
const oeifTone: HeroTone =
|
|
|
|
|
grade === 1 ? 'teal' :
|
|
|
|
|
grade === 2 ? 'green' :
|
|
|
|
|
grade === 3 ? 'gold' :
|
|
|
|
|
'coral';
|
2026-04-08 10:32:33 +01:00
|
|
|
return {
|
|
|
|
|
state: 'oeif',
|
2026-04-08 10:44:37 +01:00
|
|
|
title: `Ofsted ${OFSTED_OEIF_WORDS[grade]}`,
|
|
|
|
|
subtitle: when ? `Inspected ${when}` : 'Inspected',
|
|
|
|
|
tone: oeifTone,
|
2026-04-08 10:32:33 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-08 10:44:37 +01:00
|
|
|
state: 'oeif',
|
|
|
|
|
title: 'Ofsted inspected',
|
|
|
|
|
subtitle: when ? `Inspected ${when}` : 'Inspection on record',
|
|
|
|
|
tone: 'neutral',
|
2026-04-08 10:32:33 +01:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build a one-sentence editorial summary for the school detail hero.
|
|
|
|
|
* Branches on Ofsted framework so Report Card schools are never described
|
|
|
|
|
* with an overall grade they do not have.
|
|
|
|
|
*/
|
|
|
|
|
export function buildSchoolSummary(
|
|
|
|
|
schoolInfo: School,
|
|
|
|
|
ofsted: OfstedInspection | null | undefined,
|
|
|
|
|
admissions: SchoolAdmissions | null | undefined,
|
|
|
|
|
latestResults: SchoolResult | null | undefined,
|
|
|
|
|
): string {
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
|
|
|
|
|
// Size descriptor
|
|
|
|
|
const pupils = latestResults?.total_pupils ?? schoolInfo.total_pupils ?? null;
|
|
|
|
|
const sizeWord =
|
|
|
|
|
pupils == null ? '' :
|
|
|
|
|
pupils < 200 ? 'Small' :
|
|
|
|
|
pupils < 500 ? 'Mid-sized' :
|
|
|
|
|
'Large';
|
|
|
|
|
|
|
|
|
|
// Phase descriptor — avoid the raw code
|
|
|
|
|
const phase = (schoolInfo.phase ?? '').toLowerCase();
|
|
|
|
|
const phaseWord =
|
|
|
|
|
phase.includes('secondary') ? 'secondary' :
|
|
|
|
|
phase === 'all-through' ? 'all-through' :
|
|
|
|
|
phase.includes('primary') ? 'primary' :
|
|
|
|
|
'school';
|
|
|
|
|
|
|
|
|
|
// Religious character
|
|
|
|
|
const religion = schoolInfo.religious_denomination;
|
|
|
|
|
const religionWord =
|
|
|
|
|
!religion || /none|does not apply/i.test(religion) ? '' :
|
|
|
|
|
/roman catholic|catholic/i.test(religion) ? 'Catholic ' :
|
|
|
|
|
/church of england|ce|anglican/i.test(religion) ? 'Church of England ' :
|
|
|
|
|
/jewish/i.test(religion) ? 'Jewish ' :
|
|
|
|
|
/muslim|islam/i.test(religion) ? 'Muslim ' :
|
|
|
|
|
/hindu/i.test(religion) ? 'Hindu ' :
|
|
|
|
|
/sikh/i.test(religion) ? 'Sikh ' :
|
|
|
|
|
'';
|
|
|
|
|
|
|
|
|
|
// Locality — prefer town from address parsing (fallback to LA)
|
|
|
|
|
const locality = schoolInfo.town || schoolInfo.local_authority || '';
|
|
|
|
|
|
|
|
|
|
const lead = [sizeWord, religionWord + phaseWord].filter(Boolean).join(' ');
|
|
|
|
|
let opening = lead || 'School';
|
|
|
|
|
if (locality) opening += ` in ${locality}`;
|
|
|
|
|
parts.push(opening);
|
|
|
|
|
|
|
|
|
|
// Ofsted clause (framework-aware)
|
|
|
|
|
if (ofsted?.framework === 'OEIF' && ofsted.overall_effectiveness) {
|
|
|
|
|
parts.push(`rated ${OFSTED_OEIF_WORDS[ofsted.overall_effectiveness]} by Ofsted`);
|
|
|
|
|
} else if (ofsted?.framework === 'ReportCard') {
|
|
|
|
|
const when = formatOfstedMonth(ofsted.inspection_date);
|
|
|
|
|
parts.push(
|
|
|
|
|
when
|
|
|
|
|
? `most recently inspected under Ofsted's Report Card framework in ${when}`
|
|
|
|
|
: "recently inspected under Ofsted's new Report Card framework",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Admissions clause
|
|
|
|
|
if (admissions?.oversubscribed) {
|
|
|
|
|
if (admissions.first_preference_offer_pct != null) {
|
2026-04-08 11:29:40 +01:00
|
|
|
const pct = Math.round(admissions.first_preference_offer_pct);
|
2026-04-08 10:32:33 +01:00
|
|
|
parts.push(
|
2026-04-08 11:29:40 +01:00
|
|
|
`oversubscribed — ${pct}% of first-choice applicants are offered a place`,
|
2026-04-08 10:32:33 +01:00
|
|
|
);
|
|
|
|
|
} else {
|
2026-04-08 11:29:40 +01:00
|
|
|
parts.push('oversubscribed');
|
2026-04-08 10:32:33 +01:00
|
|
|
}
|
|
|
|
|
} else if (admissions?.first_preference_offer_pct != null && admissions.first_preference_offer_pct >= 90) {
|
|
|
|
|
parts.push('most families get their first-choice offer');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parts.join(', ') + '.';
|
|
|
|
|
}
|
2026-04-13 13:52:05 +01:00
|
|
|
|
|
|
|
|
// ─── List-level Ofsted badge ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export interface OfstedListBadge {
|
|
|
|
|
/** Display text for the badge (e.g. "Outstanding · 2023", "Report Card · 2025") */
|
|
|
|
|
label: string;
|
|
|
|
|
/** CSS module class key — one of: ofsted1 | ofsted2 | ofsted3 | ofsted4 | ofstedRc | ofstedPending */
|
|
|
|
|
cssClass: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build the Ofsted badge for a school card in the list/map view.
|
|
|
|
|
* Three states:
|
|
|
|
|
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed
|
|
|
|
|
* - ReportCard school (ofsted_framework === 'ReportCard'): "Report Card · YYYY" in purple
|
|
|
|
|
* - No inspection: "Not yet inspected" in grey
|
|
|
|
|
*/
|
|
|
|
|
export function buildOfstedListBadge(school: {
|
|
|
|
|
ofsted_grade?: number | null;
|
|
|
|
|
ofsted_date?: string | null;
|
|
|
|
|
ofsted_framework?: string | null;
|
|
|
|
|
}): OfstedListBadge {
|
|
|
|
|
const year = school.ofsted_date
|
|
|
|
|
? new Date(school.ofsted_date).getFullYear()
|
|
|
|
|
: null;
|
|
|
|
|
const yearStr = year ? ` · ${year}` : '';
|
|
|
|
|
|
|
|
|
|
if (school.ofsted_grade) {
|
|
|
|
|
const labels: Record<number, string> = {
|
|
|
|
|
1: 'Outstanding',
|
|
|
|
|
2: 'Good',
|
|
|
|
|
3: 'Req. Improvement',
|
|
|
|
|
4: 'Inadequate',
|
|
|
|
|
};
|
|
|
|
|
return {
|
|
|
|
|
label: `${labels[school.ofsted_grade]}${yearStr}`,
|
|
|
|
|
cssClass: `ofsted${school.ofsted_grade}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (school.ofsted_framework === 'ReportCard') {
|
|
|
|
|
return { label: `Report Card${yearStr}`, cssClass: 'ofstedRc' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
|
|
|
|
|
}
|