/** * Small server-side helpers shared by every provider adapter. */ /** An error that knows which HTTP status the API route should answer with. */ export class ProviderError extends Error { constructor( message: string, public readonly status: number = 502, ) { super(message); this.name = "ProviderError"; } } /** Reads an API key from the environment, or fails with a clear message. */ export function readKey(envKey: string): string { const key = process.env[envKey]?.trim(); if (!key) { throw new ProviderError(`${envKey} is not configured`, 500); } return key; } interface FetchOptions { /** Seconds to cache the response. Weather barely changes in 10 minutes. */ cacheSeconds?: number; headers?: Record; } /** * Fetches JSON with caching (default 10 minutes) to protect free-tier quotas. * The URL often contains an API key, so it is never logged or echoed back. */ export async function fetchJson(url: string, providerLabel: string, options: FetchOptions = {}): Promise { const { cacheSeconds = 600, headers } = options; let response: Response; try { response = await fetch(url, { headers, next: { revalidate: cacheSeconds } }); } catch { // DNS failure, offline, firewall… the request never got an answer. throw new ProviderError(`Couldn't reach ${providerLabel}. Check your connection and try again.`, 503); } if (response.status === 401 || response.status === 403) { throw new ProviderError(`${providerLabel} rejected the API key`, 401); } if (response.status === 429) { throw new ProviderError(`${providerLabel} rate limit reached. Try again shortly.`, 429); } if (!response.ok) { throw new ProviderError(`${providerLabel} responded with HTTP ${response.status}`, 502); } return (await response.json()) as T; } // ---------- Number and time helpers ---------- /** Rounds to one decimal place so the JSON stays tidy. */ export function round1(value: number): number { return Math.round(value * 10) / 10; } /** Same as round1, but passes null/undefined/NaN through as null. */ export function maybe(value: number | null | undefined): number | null { return value === null || value === undefined || !Number.isFinite(value) ? null : round1(value); } /** Metres per second → km/h. */ export function msToKph(ms: number): number { return round1(ms * 3.6); } export function maybeMsToKph(ms: number | null | undefined): number | null { return ms === null || ms === undefined ? null : msToKph(ms); } /** Unix seconds → ISO instant. */ export function fromUnix(seconds: number): string { return new Date(seconds * 1000).toISOString(); } /** Keeps the first `hours` of an hourly list, starting from the current hour. */ export function nextHours(items: T[], hours: number): T[] { const startOfThisHour = Date.now() - 60 * 60 * 1000; return items.filter((item) => Date.parse(item.time) >= startOfThisHour).slice(0, hours); } /** The app shows at most 7 days of hourly data and 14 days of daily data. */ export const MAX_HOURS = 168; export const MAX_DAYS = 14;