Files
school_compare/nextjs-app/lib/api.ts
T
Tudor Sitaru 7e182e88b2
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
feat(analytics): typed Umami event taxonomy across the funnel
Add lib/analytics.ts with a single typed track() wrapper. SSR-safe,
never throws, no-ops when Umami isn't loaded. Event names form a
fixed union so refactors stay safe.

14 events wired:

  Discovery (3)
    search_submitted          FilterBar submit + near_me path
    near_me_used              all geolocation outcomes
    empty_results             search returns 0 schools

  Engagement (5)
    school_viewed             SchoolDetail + Secondary on mount, with
                              urn / phase / local_authority / from
    section_nav_used          section-nav links on both detail views
    chart_metric_changed      mobile chart chip switch
    metric_compared_in_rankings rankings metric dropdown
    external_link_clicked     Ofsted / school website / DfE (declarative
                              data-umami-event attributes)

  Conversion (5)
    compare_school_added      search/rankings/detail/compare sources
    compare_school_removed    detail toggle and compare page
    compare_viewed            once per session when there's a selection
                              (school_count, phase_mix)
    compare_metric_changed    compare page metric dropdown
    compare_shared            native sheet vs clipboard distinguished

  Operational (1)
    api_error                 caught in handleResponse, includes
                              endpoint / status / route

Suggested Goals to configure in the Umami dashboard for the funnel
report: search_submitted → school_viewed → compare_school_added →
compare_viewed → compare_shared.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:04:22 +01:00

406 lines
11 KiB
TypeScript

/**
* 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,
LAaveragesResponse,
NationalAverages,
} from './types';
// ============================================================================
// Configuration
// ============================================================================
// Use FASTAPI_URL for server-side requests (internal Docker network)
// Use NEXT_PUBLIC_API_URL for client-side requests (browser)
const API_BASE_URL = typeof window === 'undefined'
? (process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || '/api')
: (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
}
// Client-side: report to analytics so we can spot silent failures.
// No-ops on SSR (track guards against missing window).
if (typeof window !== 'undefined') {
const { track } = await import('./analytics');
try {
const endpoint = new URL(response.url).pathname;
track('api_error', { endpoint, status: response.status, route: window.location.pathname });
} catch { /* never */ }
}
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,
},
});
const data = await handleResponse<any>(response);
// Transform backend response to match our TypeScript types
// Backend uses 'name' and 'type', we use 'label' and 'format'
return {
metrics: data.metrics.map((metric: any) => ({
key: metric.key,
label: metric.name, // Map 'name' to 'label'
description: metric.description,
category: metric.category,
format: metric.type, // Map 'type' to 'format'
hasNationalAverage: metric.hasNationalAverage,
})),
};
}
/**
* Fetch per-LA average Attainment 8 score for secondary schools
*/
export async function fetchLAaverages(
options: RequestInit = {}
): Promise<LAaveragesResponse> {
const url = `${API_BASE_URL}/la-averages`;
const response = await fetch(url, {
...options,
next: {
revalidate: 3600,
...options.next,
},
});
return handleResponse<LAaveragesResponse>(response);
}
/**
* Fetch official DfE KS2 national averages (primary) and computed KS4 secondary averages.
* Returns latest year snapshot plus per-year history for chart reference lines.
*/
export async function fetchNationalAverages(
options: RequestInit = {}
): Promise<NationalAverages> {
const url = `${API_BASE_URL}/national-averages`;
const response = await fetch(url, {
...options,
next: {
revalidate: 3600,
...options.next,
},
});
return handleResponse<NationalAverages>(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> {
// If it's already a full URL, use it directly
// Otherwise, prepend the API_BASE_URL
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url.startsWith('/') ? url : `/${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;
}