94 lines
3.1 KiB
TypeScript
94 lines
3.1 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, string>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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<T>(url: string, providerLabel: string, options: FetchOptions = {}): Promise<T> {
|
||
|
|
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<T extends { time: string }>(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;
|