perfect-postcode/frontend/src/lib/api.ts
Andras Schmelczer 05a1f316e1
Some checks failed
CI / Check (push) Failing after 2m14s
Build and publish Docker image / build-and-push (push) Failing after 2m38s
More
2026-05-04 17:21:26 +01:00

120 lines
3.9 KiB
TypeScript

import type { FeatureMeta, FeatureFilters } from '../types';
import { INITIAL_RETRY_MS, MAX_RETRY_MS } from './consts';
import pb from './pocketbase';
import { getSchoolBackendFeatureName } from './school-filter';
export function logNonAbortError(label: string, error: unknown): void {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
console.error(`${label}:`, error);
}
export function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
/** Throw if response is not 2xx. Call before `.json()`. */
export function assertOk(res: Response, label: string): void {
if (!res.ok) {
throw new Error(`${label}: HTTP ${res.status} ${res.statusText}`);
}
}
export function authHeaders(init?: RequestInit): RequestInit {
const headers: Record<string, string> = {};
if (pb.authStore.isValid && pb.authStore.token) {
headers['Authorization'] = `Bearer ${pb.authStore.token}`;
}
if (!init) return { headers };
const existing = init.headers as Record<string, string> | undefined;
return { ...init, headers: { ...existing, ...headers } };
}
export function apiUrl(endpoint: string, params?: URLSearchParams): string {
const path = endpoint.startsWith('/') ? endpoint : `/api/${endpoint}`;
const query = params?.toString();
return query ? `${path}?${query}` : path;
}
export async function fetchWithRetry<T>(
url: string,
onSuccess: (data: T) => void,
signal: AbortSignal
): Promise<void> {
let delay = INITIAL_RETRY_MS;
while (!signal.aborted) {
try {
const res = await fetch(url, authHeaders({ signal }));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
onSuccess(json);
return;
} catch (err) {
if (signal.aborted) return;
console.error(`Failed to fetch ${url}, retrying in ${delay}ms:`, err);
await new Promise((resolve) => setTimeout(resolve, delay));
delay = Math.min(delay * 2, MAX_RETRY_MS);
}
}
}
/** Fire-and-forget request to pre-warm the screenshot cache for OG images. */
export function prewarmScreenshot(params: string): void {
fetch(apiUrl('screenshot', new URLSearchParams(`og=1&${params}`)), authHeaders()).catch(() => {}); // best-effort, don't care if it fails
}
export async function shortenUrl(params: string): Promise<string> {
const res = await fetch(apiUrl('shorten'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return `${window.location.origin}${data.url}`;
}
export function buildFilterString(
filters: FeatureFilters,
features: FeatureMeta[],
exclude?: string
): string {
const entries = Object.entries(filters);
if (entries.length === 0) return '';
const merged = new Map<string, [number, number] | string[]>();
for (const [name, value] of entries) {
if (name === exclude) continue;
const backendName = getSchoolBackendFeatureName(name) ?? name;
const prev = merged.get(backendName);
if (
prev &&
Array.isArray(prev) &&
Array.isArray(value) &&
typeof prev[0] === 'number' &&
typeof value[0] === 'number'
) {
merged.set(backendName, [
Math.max(prev[0] as number, value[0] as number),
Math.min(prev[1] as number, value[1] as number),
]);
} else if (!prev) {
merged.set(backendName, value);
}
}
return [...merged.entries()]
.map(([name, value]) => {
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
return `${name}:${(value as string[]).join('|')}`;
}
const [min, max] = value as [number, number];
const isAtMax = meta?.histogram ? max >= meta.histogram.max : max === meta?.max;
const maxStr = meta?.absolute && isAtMax ? 'inf' : String(max);
return `${name}:${min}:${maxStr}`;
})
.join(';;');
}