PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 21s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 16s
New postcode searches, the near-me button and URLs without a radius now use 0.5 miles. A postcode URL with no radius used to show "1 mile" in the Distance control while the API applied its own 5-mile default; the page and the map fetch now send the same default the control displays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
928 lines
31 KiB
TypeScript
928 lines
31 KiB
TypeScript
/**
|
||
* Utility functions for SchoolCompare
|
||
*/
|
||
|
||
import type { School, MetricDefinition, OfstedInspection, SchoolAdmissions, SchoolResult } from './types';
|
||
|
||
// ============================================================================
|
||
// String Utilities
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Create a URL-friendly slug from a string
|
||
*/
|
||
export function slugify(text: string): string {
|
||
return text
|
||
.toLowerCase()
|
||
.replace(/[^\w\s-]/g, '')
|
||
.replace(/\s+/g, '-')
|
||
.replace(/-+/g, '-')
|
||
.trim();
|
||
}
|
||
|
||
/**
|
||
* Build a school URL path: /school/123456-school-name (capped at ~80 chars total)
|
||
*/
|
||
const MAX_SLUG_LENGTH = 60;
|
||
|
||
export function schoolUrl(urn: number, schoolName?: string): string {
|
||
if (!schoolName) return `/school/${urn}`;
|
||
let slug = slugify(schoolName);
|
||
if (slug.length > MAX_SLUG_LENGTH) {
|
||
slug = slug.slice(0, MAX_SLUG_LENGTH).replace(/-+$/, '');
|
||
}
|
||
return `/school/${urn}-${slug}`;
|
||
}
|
||
|
||
/**
|
||
* Extract the URN from a school slug (e.g. "138267-some-school-name" → 138267)
|
||
*/
|
||
export function parseSchoolSlug(slug: string): number | null {
|
||
const match = slug.match(/^(\d{6})/);
|
||
return match ? parseInt(match[1], 10) : null;
|
||
}
|
||
|
||
/**
|
||
* Escape HTML to prevent XSS
|
||
*/
|
||
export function escapeHtml(text: string): string {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* Truncate text to a maximum length
|
||
*/
|
||
export function truncate(text: string, maxLength: number): string {
|
||
if (text.length <= maxLength) return text;
|
||
return text.slice(0, maxLength).trim() + '...';
|
||
}
|
||
|
||
/**
|
||
* A compact school label for tight spaces (mobile compare rows, chip bars):
|
||
* drop the trailing establishment-type words so "Barclay Primary School" →
|
||
* "Barclay", "St Mary's Catholic Primary School" → "St Mary's". Falls back to
|
||
* a length-capped truncation for names that don't carry a type suffix.
|
||
*/
|
||
export function shortName(name: string, maxLength = 32): string {
|
||
let s = name
|
||
.replace(
|
||
/\s+(primary|junior|infant|nursery|community|foundation|catholic|academy|school|college)\b.*$/i,
|
||
'',
|
||
)
|
||
.trim();
|
||
if (!s) s = name;
|
||
if (s.length > maxLength) s = s.slice(0, maxLength - 1).trim() + '…';
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* Format a school's age range for display, e.g. "3-11" → "Ages 3–11".
|
||
* Display-only — leaves the raw `age_range` field (used for sixth-form
|
||
* detection) untouched. Falls back to the raw value if it's not a plain range.
|
||
*/
|
||
export function formatAgeSpan(ageRange: string | null | undefined): string {
|
||
if (!ageRange) return '';
|
||
const match = ageRange.match(/^\s*(\d+)\s*[-–]\s*(\d+)\s*$/);
|
||
if (!match) return ageRange;
|
||
return `${match[1]}–${match[2]}`;
|
||
}
|
||
|
||
/**
|
||
* The same span, labelled — for the places that show it with no column
|
||
* heading to carry the word "Ages". Delegates so the en-dash normalisation
|
||
* lives in one place.
|
||
*/
|
||
export function formatAgeRange(ageRange: string | null | undefined): string {
|
||
const span = formatAgeSpan(ageRange);
|
||
return /^\d+–\d+$/.test(span) ? `Ages ${span}` : span;
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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)}%`;
|
||
}
|
||
|
||
export function formatWithSuppression(value: number | null | undefined): { display: string; suppressed: boolean } {
|
||
if (value == null) return { display: '—', suppressed: true };
|
||
return { display: formatPercentage(value), suppressed: false };
|
||
}
|
||
|
||
export const SUPPRESSED_TOOLTIP = 'Data not available. It may be suppressed to protect small cohorts.';
|
||
|
||
/**
|
||
* 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
|
||
// ============================================================================
|
||
|
||
/** Radius a postcode search uses until the user picks another. */
|
||
export const DEFAULT_RADIUS_MILES = 0.5;
|
||
|
||
/**
|
||
* 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
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Series palette for comparing up to eight schools at once.
|
||
*
|
||
* These were Chart.js's stock demo colours, which is why the comparison view
|
||
* never looked like the rest of the site. They now point at the --series-*
|
||
* tokens, so the palette lives in one place and follows the theme.
|
||
*
|
||
* Every step clears WCAG AA on both grounds, so the same value works as a
|
||
* chart line and as the legend text keyed to it — there is no longer a
|
||
* separate text ramp to keep in sync.
|
||
*
|
||
* These are var() strings: correct anywhere the value lands in the DOM
|
||
* (inline styles, CSS custom properties). Canvas can't resolve var(), so
|
||
* Chart.js datasets must use `useSeriesColors()` from lib/theme instead.
|
||
*/
|
||
export const CHART_COLORS = [
|
||
'var(--series-1)',
|
||
'var(--series-2)',
|
||
'var(--series-3)',
|
||
'var(--series-4)',
|
||
'var(--series-5)',
|
||
'var(--series-6)',
|
||
'var(--series-7)',
|
||
'var(--series-8)',
|
||
];
|
||
|
||
/**
|
||
* Get a color from the palette by index
|
||
*/
|
||
export function getChartColor(index: number): string {
|
||
return CHART_COLORS[index % CHART_COLORS.length];
|
||
}
|
||
|
||
/**
|
||
* @deprecated The series palette is now AA on both grounds, so line and text
|
||
* share one value. Kept as an alias so call sites can migrate incrementally.
|
||
*/
|
||
export const CHART_TEXT_COLORS = CHART_COLORS;
|
||
|
||
export function getChartTextColor(index: number): string {
|
||
return CHART_TEXT_COLORS[index % CHART_TEXT_COLORS.length];
|
||
}
|
||
|
||
/**
|
||
* Add opacity to a colour. Accepts `rgb(...)` or `#rrggbb` — the series
|
||
* palette resolves to hex now that it comes from the token layer, but the
|
||
* rgb() form is still used by callers passing Chart.js literals.
|
||
*/
|
||
export function rgbToRgba(color: string, alpha: number): string {
|
||
const hex = /^#([0-9a-f]{6})$/i.exec(color.trim());
|
||
if (hex) {
|
||
const n = parseInt(hex[1], 16);
|
||
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
||
}
|
||
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
|
||
}
|
||
|
||
/**
|
||
* Trend direction colour. Teal/amber rather than green/red so the signal
|
||
* survives red–green colour blindness; `at` is deliberately neutral so a
|
||
* school sitting on the average doesn't read as a verdict.
|
||
*/
|
||
export function getTrendColor(trend: 'up' | 'down' | 'stable'): string {
|
||
switch (trend) {
|
||
case 'up':
|
||
return 'var(--status-above)';
|
||
case 'down':
|
||
return 'var(--status-below)';
|
||
case 'stable':
|
||
return 'var(--status-at)';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Broad shape of a KS2/KS4 metric, used to scale chart axes and format values.
|
||
*/
|
||
export type MetricKind = 'percentage' | 'progress' | 'score';
|
||
|
||
export function metricKind(metric: string): MetricKind {
|
||
if (metric.includes('progress')) return 'progress';
|
||
if (metric.includes('pct') || metric.includes('rate')) return 'percentage';
|
||
return 'score';
|
||
}
|
||
|
||
/**
|
||
* Fit a chart y-axis to the data instead of a fixed frame, so clustered
|
||
* series remain distinguishable. Padding keeps a minimum span so noise is
|
||
* not magnified into drama.
|
||
*
|
||
* - percentage: pad and snap to 5s; cap at 100; floor at 0 only when the
|
||
* data is non-negative (some trend metrics have `pct` in the key but hold
|
||
* negative year-over-year deltas).
|
||
* - progress: symmetric around 0 so the zero line always shows.
|
||
* - score (Attainment 8, scaled scores): pad and snap to integers; floor at
|
||
* 0 only when the data is non-negative.
|
||
*/
|
||
export function computeYBounds(
|
||
values: Array<number | null | undefined>,
|
||
kind: MetricKind,
|
||
): { min?: number; max?: number } {
|
||
const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v));
|
||
if (nums.length === 0) return {};
|
||
|
||
const lo = Math.min(...nums);
|
||
const hi = Math.max(...nums);
|
||
|
||
if (kind === 'progress') {
|
||
const reach = Math.max(2, Math.ceil(Math.max(Math.abs(lo), Math.abs(hi)) + 0.5));
|
||
return { min: -reach, max: reach };
|
||
}
|
||
|
||
if (kind === 'percentage') {
|
||
const pad = Math.max(5, Math.round((hi - lo) * 0.2));
|
||
const min = Math.floor((lo - pad) / 5) * 5;
|
||
const max = Math.min(100, Math.ceil((hi + pad) / 5) * 5);
|
||
return { min: lo >= 0 ? Math.max(0, min) : min, max };
|
||
}
|
||
|
||
// score
|
||
const pad = Math.max(2, (hi - lo) * 0.2);
|
||
const min = Math.floor(lo - pad);
|
||
return { min: lo >= 0 ? Math.max(0, min) : min, max: Math.ceil(hi + pad) };
|
||
}
|
||
|
||
// ============================================================================
|
||
// Local Storage Utilities
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Safely get item from localStorage
|
||
*/
|
||
export function getFromLocalStorage<T>(key: string, defaultValue: T): T {
|
||
if (typeof window === 'undefined') return defaultValue;
|
||
|
||
try {
|
||
const item = window.localStorage.getItem(key);
|
||
return item ? JSON.parse(item) : defaultValue;
|
||
} catch (error) {
|
||
console.error(`Error reading from localStorage key "${key}":`, error);
|
||
return defaultValue;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Safely set item in localStorage
|
||
*/
|
||
export function setToLocalStorage<T>(key: string, value: T): void {
|
||
if (typeof window === 'undefined') return;
|
||
|
||
try {
|
||
window.localStorage.setItem(key, JSON.stringify(value));
|
||
} catch (error) {
|
||
console.error(`Error writing to localStorage key "${key}":`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remove item from localStorage
|
||
*/
|
||
export function removeFromLocalStorage(key: string): void {
|
||
if (typeof window === 'undefined') return;
|
||
|
||
try {
|
||
window.localStorage.removeItem(key);
|
||
} catch (error) {
|
||
console.error(`Error removing from localStorage key "${key}":`, error);
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// URL Utilities
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Build URL with query parameters
|
||
*/
|
||
export function buildUrl(base: string, params: Record<string, any>): string {
|
||
const url = new URL(base, window.location.origin);
|
||
|
||
Object.entries(params).forEach(([key, value]) => {
|
||
if (value !== undefined && value !== null && value !== '') {
|
||
url.searchParams.set(key, String(value));
|
||
}
|
||
});
|
||
|
||
return url.pathname + url.search;
|
||
}
|
||
|
||
/**
|
||
* Parse query string into object
|
||
*/
|
||
export function parseQueryString(search: string): Record<string, string> {
|
||
const params = new URLSearchParams(search);
|
||
const result: Record<string, string> = {};
|
||
|
||
params.forEach((value, key) => {
|
||
result[key] = value;
|
||
});
|
||
|
||
return result;
|
||
}
|
||
|
||
// ============================================================================
|
||
// Date Utilities
|
||
// ============================================================================
|
||
|
||
/**
|
||
* Format academic year.
|
||
* Handles both 4-digit start years (2023 → "2023/24") and
|
||
* 6-digit EES codes (202526 → "2025/26").
|
||
*/
|
||
export function getPhaseStyle(phase?: string | null): { key: string; label: string } {
|
||
switch (phase?.toLowerCase()) {
|
||
case 'primary':
|
||
case 'middle deemed primary':
|
||
return { key: 'Primary', label: 'Primary' };
|
||
case 'secondary':
|
||
case 'middle deemed secondary':
|
||
return { key: 'Secondary', label: 'Secondary' };
|
||
case 'all-through':
|
||
return { key: 'AllThrough', label: 'All-through' };
|
||
case '16 plus':
|
||
return { key: 'Post16', label: 'Post-16' };
|
||
case 'nursery':
|
||
return { key: 'Nursery', label: 'Nursery' };
|
||
default:
|
||
return { key: '', label: '' };
|
||
}
|
||
}
|
||
|
||
const METRES_PER_MILE = 1609.344;
|
||
|
||
/**
|
||
* A distance in miles, the unit UK school admissions is conducted in.
|
||
*
|
||
* Councils publish cut-offs in miles — 90% of the collected source rows — and
|
||
* it is the unit a parent has already been quoted in their council's booklet
|
||
* and offer letter. Everything a reader is asked to compare therefore renders
|
||
* through this one function, so two figures in the same sentence can never
|
||
* arrive in different units.
|
||
*
|
||
* That was not previously true. This formatter used to swap to metres below
|
||
* 100m, on the reasoning that "0.04 miles" carries less for a reader than
|
||
* "69 m". Taken one figure at a time that holds; taken in a sentence it
|
||
* produced "69 m away — inside the September 2026 cut-off of 0.17 miles",
|
||
* which asks the reader to convert between units to understand a comparison we
|
||
* had already made for them. Legibility of a single number lost to coherence
|
||
* of the pair.
|
||
*
|
||
* Below 0.01 miles the two decimal places run out rather than the unit being
|
||
* wrong, so the figure is described instead of rounded to a flat "0.00 miles".
|
||
*/
|
||
export function formatMiles(metres: number): string {
|
||
const miles = metres / METRES_PER_MILE;
|
||
if (miles < 0.01) return 'under 0.01 miles';
|
||
return `${miles.toFixed(2)} miles`;
|
||
}
|
||
|
||
/**
|
||
* Format an admission cut-off distance: the headline figure in miles, and a
|
||
* metric equivalent to support it.
|
||
*
|
||
* The metric figure is support, not an alternative — it appears beside the
|
||
* miles figure, never instead of it, so nothing a reader compares is ever in
|
||
* two units at once.
|
||
*/
|
||
export function formatCutoffDistance(
|
||
metres: number | null | undefined
|
||
): { primary: string; secondary: string } | null {
|
||
if (metres == null || !Number.isFinite(metres) || metres <= 0) return null;
|
||
|
||
return {
|
||
primary: formatMiles(metres),
|
||
secondary: metres < 1000
|
||
? `${Math.round(metres / 10) * 10} m`
|
||
: `${(metres / 1000).toFixed(1)} km`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The entry point a cut-off belongs to, phrased the way councils phrase it.
|
||
*
|
||
* The distance year is a plain entry year (2025 = the September 2025 intake),
|
||
* not the six-digit academic year EES uses, so formatAcademicYear would render
|
||
* it as "2025/26" and invite the reader to think of it as a school year rather
|
||
* than an application round.
|
||
*/
|
||
export function formatEntryYear(year: number | null | undefined): string {
|
||
if (year == null) return '';
|
||
return `September ${year}`;
|
||
}
|
||
|
||
export function formatAcademicYear(year: number | null | undefined): string {
|
||
if (year == null) return '';
|
||
const s = year.toString();
|
||
if (s.length === 6) {
|
||
return `${s.slice(0, 4)}/${s.slice(4)}`;
|
||
}
|
||
const nextYear = (year + 1).toString().slice(-2);
|
||
return `${year}/${nextYear}`;
|
||
}
|
||
|
||
/**
|
||
* Get current academic year
|
||
*/
|
||
export function getCurrentAcademicYear(): number {
|
||
const now = new Date();
|
||
const year = now.getFullYear();
|
||
const month = now.getMonth();
|
||
|
||
// Academic year starts in September (month 8)
|
||
return month >= 8 ? year : year - 1;
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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' });
|
||
}
|
||
|
||
export type HeroTone = 'teal' | 'green' | 'gold' | 'coral' | 'neutral';
|
||
|
||
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")
|
||
tone: HeroTone; // Maps to dedicated hero tone classes (not badge classes)
|
||
}
|
||
|
||
/**
|
||
* Build the hero-strip Ofsted chip, branching on the inspection framework.
|
||
* Never synthesises a single overall grade for ReportCard schools.
|
||
*
|
||
* 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.
|
||
*/
|
||
export function buildOfstedHeroChip(ofsted: OfstedInspection | null | undefined): OfstedHeroChip {
|
||
if (!ofsted) {
|
||
return {
|
||
state: 'none',
|
||
title: 'Ofsted pending',
|
||
subtitle: 'No inspection on record',
|
||
tone: 'neutral',
|
||
};
|
||
}
|
||
|
||
const when = formatOfstedMonth(ofsted.inspection_date);
|
||
|
||
// 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';
|
||
return {
|
||
state: 'oeif',
|
||
title: `Ofsted ${OFSTED_OEIF_WORDS[grade]}`,
|
||
subtitle: when ? `Inspected ${when}` : 'Inspected',
|
||
tone: oeifTone,
|
||
};
|
||
}
|
||
|
||
return {
|
||
state: 'oeif',
|
||
title: 'Ofsted inspected',
|
||
subtitle: when ? `Inspected ${when}` : 'Inspection on record',
|
||
tone: 'neutral',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
const pct = Math.round(admissions.first_preference_offer_pct);
|
||
parts.push(
|
||
`oversubscribed (${pct}% of first-choice applicants are offered a place)`,
|
||
);
|
||
} else {
|
||
parts.push('oversubscribed');
|
||
}
|
||
} 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(', ') + '.';
|
||
}
|
||
|
||
// ─── Legacy (OEIF) sub-judgement areas ────────────────────────────────────────
|
||
|
||
export interface OfstedLegacyArea {
|
||
label: string;
|
||
value: number;
|
||
}
|
||
|
||
/**
|
||
* The published OEIF sub-judgement areas for the legacy Ofsted layout, in
|
||
* display order. Only real grades (1–4) are returned: Ofsted's sentinel
|
||
* codes for "not applicable / no judgement" (9, and any 0/8 variants) and
|
||
* nulls are filtered out, so a cryptic "9" never renders as a rating.
|
||
* Sixth Form provision is included where a school has one — it was
|
||
* previously dropped from the detail grid entirely.
|
||
*/
|
||
export function ofstedLegacyAreas(ofsted: {
|
||
quality_of_education?: number | null;
|
||
behaviour_attitudes?: number | null;
|
||
personal_development?: number | null;
|
||
leadership_management?: number | null;
|
||
early_years_provision?: number | null;
|
||
sixth_form_provision?: number | null;
|
||
}): OfstedLegacyArea[] {
|
||
const candidates: Array<[string, number | null | undefined]> = [
|
||
['Quality of Teaching', ofsted.quality_of_education],
|
||
['Behaviour in School', ofsted.behaviour_attitudes],
|
||
["Pupils' Wider Development", ofsted.personal_development],
|
||
['School Leadership', ofsted.leadership_management],
|
||
['Early Years (Reception)', ofsted.early_years_provision],
|
||
['Sixth Form', ofsted.sixth_form_provision],
|
||
];
|
||
return candidates
|
||
.filter((c): c is [string, number] => c[1] != null && c[1] >= 1 && c[1] <= 4)
|
||
.map(([label, value]) => ({ label, value }));
|
||
}
|
||
|
||
// ─── 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.
|
||
* States, in priority order:
|
||
* - Report Card school (ofsted_rc_date set): "Report Card · YYYY" in purple.
|
||
* Checked FIRST so it wins over any carried-forward legacy grade — the
|
||
* list has no full report_card object, and ofsted_framework is the raw
|
||
* event grouping ("Schools - S5"), never "ReportCard".
|
||
* - OEIF school (ofsted_grade set): grade word + year, colour-keyed
|
||
* - Inspected without an overall grade (OEIF post-Sept-2024, where Ofsted no
|
||
* longer issues an overall judgement): "Inspected · YYYY" — mirrors the
|
||
* detail page's hero chip so a school never reads as both inspected and
|
||
* "Not yet inspected"
|
||
* - No inspection on record: "Not yet inspected" in grey
|
||
*/
|
||
export function buildOfstedListBadge(school: {
|
||
ofsted_grade?: 1 | 2 | 3 | 4 | null;
|
||
ofsted_date?: string | null;
|
||
ofsted_framework?: string | null;
|
||
ofsted_rc_date?: string | null;
|
||
}): OfstedListBadge {
|
||
// A report card wins over any carried-forward legacy grade — signalled by
|
||
// ofsted_rc_date. ofsted_framework is the raw event grouping ("Schools -
|
||
// S5"), never "ReportCard", so it can't detect report cards.
|
||
if (school.ofsted_rc_date) {
|
||
const rcYear = new Date(school.ofsted_rc_date).getFullYear();
|
||
return { label: `Report Card · ${rcYear}`, cssClass: 'ofstedRc' };
|
||
}
|
||
|
||
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}`,
|
||
};
|
||
}
|
||
|
||
// An inspection is on record (date or framework present) but carries no
|
||
// overall grade — a post-Sept-2024 OEIF inspection. Distinct from a school
|
||
// that has genuinely never been inspected.
|
||
if (school.ofsted_date != null || school.ofsted_framework != null) {
|
||
return { label: `Inspected${yearStr}`, cssClass: 'ofstedInspected' };
|
||
}
|
||
|
||
return { label: 'Not yet inspected', cssClass: 'ofstedPending' };
|
||
}
|
||
|
||
// ============================================================================
|
||
// Establishment status
|
||
// ============================================================================
|
||
|
||
export const PROPOSED_TO_CLOSE_STATUS = 'Open, but proposed to close';
|
||
|
||
/**
|
||
* GIAS lists some operating schools as "Open, but proposed to close".
|
||
* They remain open (and may stay open if the proposal is withdrawn), but the
|
||
* UI marks them so families check with the local authority before applying.
|
||
*/
|
||
export function isProposedToClose(school: { status?: string | null }): boolean {
|
||
return school.status === PROPOSED_TO_CLOSE_STATUS;
|
||
}
|
||
|
||
/**
|
||
* Special schools, pupil referral units and alternative provision teach pupils
|
||
* with SEND or outside mainstream settings. Their pupils sit the same KS2/KS4
|
||
* assessments but very few reach the mainstream "expected standard", so the
|
||
* headline attainment measures — and any comparison to the England average —
|
||
* are not a fair judgement of the school. Callers use this to drop the
|
||
* mainstream-benchmark framing (deltas, "below England", national markers)
|
||
* rather than portray these schools as failing.
|
||
*
|
||
* Detection is by establishment type: every DfE special-school type contains
|
||
* "special" (e.g. "Community special school", "Academy special converter/sponsor
|
||
* led", "Foundation special school", "Non-maintained special school", "Free
|
||
* schools special", "Other independent special school"); PRUs and alternative
|
||
* provision are matched by name. Special schools carry a mainstream `phase`
|
||
* (Primary/Secondary/All-through), so `phase` alone can't identify them.
|
||
*/
|
||
export function isSpecialSchool(school: { school_type?: string | null }): boolean {
|
||
const t = (school.school_type ?? '').toLowerCase();
|
||
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
|
||
}
|
||
|
||
/**
|
||
* The school's combined Reading, Writing & Maths figure, or null when there is
|
||
* no real one to show.
|
||
*
|
||
* A placeholder all-zero row (every subject 0, the special/suppressed
|
||
* signature that SchoolDetailView calls ks2Placeholder) is not a score. A
|
||
* genuine 0% combined, where some pupils met individual subjects but not all
|
||
* three, is not all-zero and stays shown.
|
||
*/
|
||
export function listRwmValue(school: {
|
||
rwm_expected_pct?: number | null;
|
||
reading_expected_pct?: number | null;
|
||
writing_expected_pct?: number | null;
|
||
maths_expected_pct?: number | null;
|
||
}): number | null {
|
||
if (school.rwm_expected_pct == null) return null;
|
||
const placeholder =
|
||
school.rwm_expected_pct === 0 &&
|
||
(school.reading_expected_pct ?? 0) === 0 &&
|
||
(school.writing_expected_pct ?? 0) === 0 &&
|
||
(school.maths_expected_pct ?? 0) === 0;
|
||
return placeholder ? null : school.rwm_expected_pct;
|
||
}
|