The rolling twelve-month window is fetched from openholidaysapi.org each night at 03:00 local, and can be triggered from the page, from POST /api/admin/holidays/sync, or from `npm run holidays:sync` for the first run after a deployment. The calendar is fetched twice, once per language, and the two answers joined on the entry id. Holiday names are proper nouns with established English forms — "Jeûne genevois" is not something a translation model should be improvising, and this costs one extra HTTP call. Two properties are load-bearing and tested against a real database. The sync is idempotent: running it twice leaves exactly what running it once did, verified live as well as against a mock. And it never touches `isAutoClosed` on an existing row — that is the shop's decision, not the API's, and a nightly job quietly reopening a day the owner had closed would be invisible until someone found the door locked. When the API is down the local cache is left untouched and the failure is recorded with its timestamp, so the page can say how stale the calendar is rather than showing nothing. Retries widen the gap between attempts; the nightly job can afford to wait, the shop cannot afford a stale calendar for a day. node-cron runs inside the application process rather than an external cron hitting an endpoint: one container, one shop, no second instance to coordinate with, and no trigger endpoint to protect and document. The reasoning is recorded next to the schedule. Verified against the live API: nine Geneva holidays, both languages, including the cantonal Restauration de la République. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
125 lines
3.9 KiB
TypeScript
125 lines
3.9 KiB
TypeScript
/**
|
|
* 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<ParsedHoliday[]> {
|
|
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<ApiHoliday[]> {
|
|
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<T>(
|
|
work: () => Promise<T>,
|
|
attempts = 3,
|
|
delayMs = 500,
|
|
sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
): Promise<T> {
|
|
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));
|
|
}
|