/** * Client for openholidaysapi.org. * * The API answers in one language at a time, so the calendar is fetched twice * and the two answers are joined on the entry id. That is cheaper and far more * reliable than sending holiday names through the translation service: these * are proper nouns with established English forms, and "Jeûne genevois" is not * something a model should be improvising. */ import { addCivilDays, compareCivil, type CivilDate } from '@/lib/schedule/civil'; export type ApiHoliday = { id: string; startDate: string; endDate: string; nationwide?: boolean; name?: { language: string; text: string }[]; }; export type ParsedHoliday = { date: CivilDate; nameFr: string; nameEn: string; nationwide: boolean; }; export type FetchOptions = { baseUrl: string; countryIsoCode: string; subdivisionCode: string; validFrom: CivilDate; validTo: CivilDate; signal?: AbortSignal; }; /** A single entry can span days; each day becomes its own row. */ export function parseHolidays(french: ApiHoliday[], english: ApiHoliday[]): ParsedHoliday[] { const englishById = new Map(english.map((entry) => [entry.id, textOf(entry, 'EN')])); const parsed: ParsedHoliday[] = []; for (const entry of french) { const nameFr = textOf(entry, 'FR'); if (!nameFr || !entry.startDate) { continue; } // Falling back to the French name keeps the screen bilingual-ish rather // than blank if the English call ever comes back short. const nameEn = englishById.get(entry.id) || nameFr; const end = entry.endDate && compareCivil(entry.endDate, entry.startDate) >= 0 ? entry.endDate : entry.startDate; let cursor = entry.startDate; // Bounded: a malformed range must not spin. for (let guard = 0; compareCivil(cursor, end) <= 0 && guard < 31; guard += 1) { parsed.push({ date: cursor, nameFr, nameEn, nationwide: entry.nationwide ?? true }); cursor = addCivilDays(cursor, 1); } } return parsed; } export async function fetchHolidays(options: FetchOptions): Promise { const [french, english] = await Promise.all([ request(options, 'FR'), request(options, 'EN'), ]); return parseHolidays(french, english); } async function request(options: FetchOptions, language: 'FR' | 'EN'): Promise { const url = new URL('/PublicHolidays', options.baseUrl); url.searchParams.set('countryIsoCode', options.countryIsoCode); url.searchParams.set('subdivisionCode', options.subdivisionCode); url.searchParams.set('languageIsoCode', language); url.searchParams.set('validFrom', options.validFrom); url.searchParams.set('validTo', options.validTo); const response = await fetch(url, { signal: options.signal ?? AbortSignal.timeout(15_000), headers: { accept: 'application/json' }, }); if (!response.ok) { throw new Error(`openholidaysapi a répondu ${response.status}`); } const body: unknown = await response.json(); return Array.isArray(body) ? (body as ApiHoliday[]) : []; } function textOf(entry: ApiHoliday, language: string): string { const names = entry.name ?? []; const match = names.find((name) => name.language?.toUpperCase() === language); return (match?.text ?? names[0]?.text ?? '').trim(); } /** * Retries with a widening gap. * * The daily sync can afford to wait; failing on a transient blip and leaving * the shop with a stale calendar for a day cannot be afforded. */ export async function withRetries( work: () => Promise, attempts = 3, delayMs = 500, sleep: (ms: number) => Promise = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), ): Promise { let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt += 1) { try { return await work(); } catch (error) { lastError = error; if (attempt < attempts - 1) { await sleep(delayMs * 2 ** attempt); } } } throw lastError instanceof Error ? lastError : new Error(String(lastError)); }