feat: sync Geneva public holidays and let the shop choose which close it
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
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { runHolidaySync, setHolidayClosure } from './actions';
|
||||
|
||||
export type HolidayRow = {
|
||||
id: string;
|
||||
date: string;
|
||||
label: string;
|
||||
nameFr: string;
|
||||
nameEn: string;
|
||||
nationwide: boolean;
|
||||
isAutoClosed: boolean;
|
||||
};
|
||||
|
||||
export default function HolidayManager({
|
||||
canEdit,
|
||||
holidays,
|
||||
lastSuccessAt,
|
||||
lastError,
|
||||
subdivisionCode,
|
||||
}: {
|
||||
canEdit: boolean;
|
||||
holidays: HolidayRow[];
|
||||
lastSuccessAt: string | null;
|
||||
lastError: string | null;
|
||||
subdivisionCode: string;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
|
||||
const [closures, setClosures] = useState<Record<string, boolean>>(
|
||||
Object.fromEntries(holidays.map((holiday) => [holiday.id, holiday.isAutoClosed])),
|
||||
);
|
||||
|
||||
function toggle(id: string, next: boolean) {
|
||||
setClosures((current) => ({ ...current, [id]: next }));
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
const result = await setHolidayClosure(id, next);
|
||||
if (!result.ok) {
|
||||
// Put the switch back: the database is the truth, not the checkbox.
|
||||
setClosures((current) => ({ ...current, [id]: !next }));
|
||||
setMessage({ kind: 'error', text: result.error });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sync() {
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
const result = await runHolidaySync();
|
||||
setMessage(
|
||||
result.ok
|
||||
? { kind: 'ok', text: `Calendrier à jour. ${result.value.summary}` }
|
||||
: { kind: 'error', text: result.error },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 space-y-6">
|
||||
{message ? (
|
||||
<p
|
||||
role="status"
|
||||
className={`rounded-[var(--radius-md)] px-4 py-3 text-sm ${
|
||||
message.kind === 'ok'
|
||||
? 'bg-[var(--success-tint)] text-[var(--success)]'
|
||||
: 'bg-[var(--danger-tint)] text-[var(--danger)]'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] px-4 py-3 text-sm">
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
Canton <span className="font-mono">{subdivisionCode}</span>
|
||||
</span>
|
||||
<span className="text-[var(--ink-muted)]">
|
||||
{lastSuccessAt
|
||||
? `Dernière synchronisation réussie : ${lastSuccessAt}`
|
||||
: 'Jamais synchronisé.'}
|
||||
</span>
|
||||
{lastError ? (
|
||||
<span className="text-[var(--danger)]">Dernière tentative en échec : {lastError}</span>
|
||||
) : null}
|
||||
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={sync}
|
||||
className="ml-auto rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-3 py-1.5 hover:bg-[var(--surface-muted)] disabled:opacity-40"
|
||||
>
|
||||
{pending ? 'Synchronisation…' : 'Synchroniser maintenant'}
|
||||
</button>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{holidays.length === 0 ? (
|
||||
<p className="text-sm text-[var(--ink-muted)]">
|
||||
Aucun jour férié en cache. Lancez une synchronisation.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-[var(--line)] overflow-hidden rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)]">
|
||||
{holidays.map((holiday) => (
|
||||
<li key={holiday.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
|
||||
<span className="min-w-44 font-medium">{holiday.label}</span>
|
||||
<span>{holiday.nameFr}</span>
|
||||
<span className="text-sm text-[var(--ink-muted)]">{holiday.nameEn}</span>
|
||||
{!holiday.nationwide ? (
|
||||
<span className="rounded-[var(--radius-sm)] bg-[var(--surface-muted)] px-2 py-0.5 text-xs text-[var(--ink-muted)]">
|
||||
cantonal
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<label className="ml-auto flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-[var(--accent)]"
|
||||
checked={closures[holiday.id] ?? holiday.isAutoClosed}
|
||||
disabled={!canEdit || pending}
|
||||
onChange={(event) => toggle(holiday.id, event.target.checked)}
|
||||
/>
|
||||
boutique fermée
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { diffOf, recordAudit } from '@/lib/audit';
|
||||
import { requireAdmin } from '@/lib/auth/actions';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { syncHolidays } from '@/lib/holidays/sync';
|
||||
|
||||
export type ActionResult<T = undefined> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
/** Whether the shop closes for a given public holiday. Some are trading days. */
|
||||
export async function setHolidayClosure(id: string, isAutoClosed: boolean): Promise<ActionResult> {
|
||||
const gate = await requireAdmin();
|
||||
if (!gate.ok) {
|
||||
return { ok: false, error: gate.error };
|
||||
}
|
||||
|
||||
const existing = await prisma.publicHoliday.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return { ok: false, error: 'Ce jour férié n’est plus dans le calendrier.' };
|
||||
}
|
||||
|
||||
await prisma.publicHoliday.update({ where: { id }, data: { isAutoClosed } });
|
||||
|
||||
await recordAudit({
|
||||
userEmail: gate.value.email,
|
||||
action: 'holiday.set_closure',
|
||||
entity: 'PublicHoliday',
|
||||
entityId: id,
|
||||
diff: diffOf(
|
||||
{ férié: existing.nameFr, fermé: existing.isAutoClosed },
|
||||
{ férié: existing.nameFr, fermé: isAutoClosed },
|
||||
),
|
||||
});
|
||||
|
||||
revalidatePath('/admin');
|
||||
revalidatePath('/admin/feries');
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
|
||||
export async function runHolidaySync(): Promise<ActionResult<{ summary: string }>> {
|
||||
const gate = await requireAdmin();
|
||||
if (!gate.ok) {
|
||||
return { ok: false, error: gate.error };
|
||||
}
|
||||
|
||||
const result = await syncHolidays();
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: `Synchronisation impossible : ${result.error}` };
|
||||
}
|
||||
|
||||
await recordAudit({
|
||||
userEmail: gate.value.email,
|
||||
action: 'holiday.sync',
|
||||
entity: 'PublicHoliday',
|
||||
diff: {
|
||||
ajoutés: { before: null, after: result.created },
|
||||
corrigés: { before: null, after: result.updated },
|
||||
retirés: { before: null, after: result.removed },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath('/admin');
|
||||
revalidatePath('/admin/feries');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
summary: `${result.created} ajouté(s), ${result.updated} corrigé(s), ${result.removed} retiré(s).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { SYNC_KEY } from '@/lib/holidays/sync';
|
||||
import { toCivilDate } from '@/lib/schedule/civil';
|
||||
import { loadSettings } from '@/lib/schedule/context';
|
||||
import { formatDateFr } from '@/lib/schedule/format';
|
||||
|
||||
import HolidayManager, { type HolidayRow } from './HolidayManager';
|
||||
|
||||
export const metadata = { title: 'Jours fériés — ITA ITO' };
|
||||
|
||||
export default async function HolidaysPage() {
|
||||
const [session, settings] = await Promise.all([auth(), loadSettings()]);
|
||||
const today = toCivilDate(new Date(), settings.timezone);
|
||||
|
||||
const [rows, state] = await Promise.all([
|
||||
prisma.publicHoliday.findMany({
|
||||
where: {
|
||||
subdivisionCode: settings.subdivisionCode,
|
||||
date: { gte: new Date(`${today}T00:00:00.000Z`) },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
}),
|
||||
prisma.syncState.findUnique({ where: { key: SYNC_KEY } }),
|
||||
]);
|
||||
|
||||
const holidays: HolidayRow[] = rows.map((row) => {
|
||||
const date = row.date.toISOString().slice(0, 10);
|
||||
return {
|
||||
id: row.id,
|
||||
date,
|
||||
label: formatDateFr(date),
|
||||
nameFr: row.nameFr,
|
||||
nameEn: row.nameEn,
|
||||
nationwide: row.nationwide,
|
||||
isAutoClosed: row.isAutoClosed,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||
<h1 className="text-2xl">Jours fériés</h1>
|
||||
<p className="mt-2 max-w-prose text-[var(--ink-muted)]">
|
||||
Calendrier glissant sur douze mois, synchronisé chaque nuit depuis openholidaysapi.org. Tous
|
||||
les fériés ne ferment pas la boutique : la case décide, pas l’API.
|
||||
</p>
|
||||
|
||||
<HolidayManager
|
||||
canEdit={session?.user?.role === 'admin'}
|
||||
holidays={holidays}
|
||||
subdivisionCode={settings.subdivisionCode}
|
||||
lastSuccessAt={state?.lastSuccessAt ? state.lastSuccessAt.toLocaleString('fr-CH') : null}
|
||||
lastError={state?.lastErrorAt ? (state.lastError ?? 'raison inconnue') : null}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const NAV = [
|
||||
{ href: '/admin/exceptions', label: 'Exceptions' },
|
||||
{ href: '/admin/vacances', label: 'Vacances' },
|
||||
{ href: '/admin/messages', label: 'Messages' },
|
||||
{ href: '/admin/feries', label: 'Fériés' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { guard } from '@/lib/auth/guard';
|
||||
import { syncHolidays } from '@/lib/holidays/sync';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Manual trigger for the holiday sync, as the spec asks for.
|
||||
*
|
||||
* Behind /api/admin, so the middleware requires a session and refuses a write
|
||||
* from a read-only account; the guard repeats both checks inside the handler.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const gate = await guard(request);
|
||||
if ('response' in gate) {
|
||||
return gate.response;
|
||||
}
|
||||
|
||||
const result = await syncHolidays();
|
||||
return Response.json(result, { status: result.ok ? 200 : 502 });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Background jobs.
|
||||
*
|
||||
* node-cron inside the application process, rather than an external cron
|
||||
* hitting an HTTP endpoint. The choice is deliberate and worth recording: this
|
||||
* runs as a single container for a single shop, so there is no second instance
|
||||
* to coordinate with, and an endpoint that triggers work is an endpoint that
|
||||
* has to be protected, rate-limited and documented. The trade-off is that the
|
||||
* jobs die with the process — acceptable for work that is idempotent and runs
|
||||
* nightly, and the UI shows the last successful sync either way.
|
||||
*/
|
||||
|
||||
export async function register(): Promise<void> {
|
||||
// Runs in the edge runtime and during the build too; neither wants a cron.
|
||||
if (process.env.NEXT_RUNTIME !== 'nodejs') {
|
||||
return;
|
||||
}
|
||||
|
||||
const [{ default: cron }, { syncHolidays }, { prisma }, { loadSettings }] = await Promise.all([
|
||||
import('node-cron'),
|
||||
import('@/lib/holidays/sync'),
|
||||
import('@/lib/db'),
|
||||
import('@/lib/schedule/context'),
|
||||
]);
|
||||
|
||||
const settings = await loadSettings().catch(() => null);
|
||||
const timezone = settings?.timezone ?? 'Europe/Zurich';
|
||||
|
||||
// 03:00 local: the rolling twelve-month holiday window.
|
||||
cron.schedule(
|
||||
'0 3 * * *',
|
||||
() => {
|
||||
void syncHolidays()
|
||||
.then((result) => {
|
||||
if (result.ok) {
|
||||
console.info(
|
||||
`[cron] fériés synchronisés : ${result.created} ajoutés, ${result.updated} corrigés, ${result.removed} retirés`,
|
||||
);
|
||||
} else {
|
||||
// Not fatal: the local cache stands and the UI shows how stale it is.
|
||||
console.warn(`[cron] synchronisation des fériés en échec : ${result.error}`);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('[cron] synchronisation des fériés interrompue', error);
|
||||
});
|
||||
},
|
||||
{ timezone },
|
||||
);
|
||||
|
||||
// 03:15 local: firmware logs are diagnostics, not data.
|
||||
cron.schedule(
|
||||
'15 3 * * *',
|
||||
() => {
|
||||
const cutoff = new Date(Date.now() - 30 * 86_400_000);
|
||||
void prisma.deviceLog
|
||||
.deleteMany({ where: { createdAt: { lt: cutoff } } })
|
||||
.then(({ count }) => {
|
||||
if (count > 0) {
|
||||
console.info(`[cron] ${count} entrées de journal appareil purgées`);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('[cron] purge des journaux interrompue', error);
|
||||
});
|
||||
},
|
||||
{ timezone },
|
||||
);
|
||||
|
||||
console.info(`[cron] tâches planifiées (fuseau ${timezone})`);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { parseHolidays, withRetries, type ApiHoliday } from './openholidays';
|
||||
|
||||
const FR: ApiHoliday[] = [
|
||||
{
|
||||
id: 'a',
|
||||
startDate: '2026-09-10',
|
||||
endDate: '2026-09-10',
|
||||
nationwide: false,
|
||||
name: [{ language: 'FR', text: 'Jeûne genevois' }],
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
startDate: '2026-12-25',
|
||||
endDate: '2026-12-25',
|
||||
nationwide: true,
|
||||
name: [{ language: 'FR', text: 'Noël' }],
|
||||
},
|
||||
];
|
||||
|
||||
const EN: ApiHoliday[] = [
|
||||
{ id: 'a', startDate: '2026-09-10', endDate: '2026-09-10', name: [{ language: 'EN', text: 'Geneva Fast' }] },
|
||||
{ id: 'b', startDate: '2026-12-25', endDate: '2026-12-25', name: [{ language: 'EN', text: 'Christmas Day' }] },
|
||||
];
|
||||
|
||||
describe('parseHolidays', () => {
|
||||
it('joins the two language calls on the entry id', () => {
|
||||
// Holiday names are proper nouns with established English forms; joining
|
||||
// beats sending them through a translation model.
|
||||
expect(parseHolidays(FR, EN)).toEqual([
|
||||
{ date: '2026-09-10', nameFr: 'Jeûne genevois', nameEn: 'Geneva Fast', nationwide: false },
|
||||
{ date: '2026-12-25', nameFr: 'Noël', nameEn: 'Christmas Day', nationwide: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the French name when the English call comes back short', () => {
|
||||
expect(parseHolidays(FR, [])[0]).toMatchObject({ nameFr: 'Jeûne genevois', nameEn: 'Jeûne genevois' });
|
||||
});
|
||||
|
||||
it('expands an entry that spans several days', () => {
|
||||
const spanning: ApiHoliday[] = [
|
||||
{
|
||||
id: 'c',
|
||||
startDate: '2026-12-24',
|
||||
endDate: '2026-12-26',
|
||||
name: [{ language: 'FR', text: 'Fêtes' }],
|
||||
},
|
||||
];
|
||||
expect(parseHolidays(spanning, []).map((holiday) => holiday.date)).toEqual([
|
||||
'2026-12-24',
|
||||
'2026-12-25',
|
||||
'2026-12-26',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a missing end date as a single day', () => {
|
||||
const single: ApiHoliday[] = [
|
||||
{ id: 'd', startDate: '2026-08-01', endDate: '', name: [{ language: 'FR', text: 'Fête nationale' }] },
|
||||
];
|
||||
expect(parseHolidays(single, [])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores a reversed range rather than spinning', () => {
|
||||
const reversed: ApiHoliday[] = [
|
||||
{ id: 'e', startDate: '2026-08-05', endDate: '2026-08-01', name: [{ language: 'FR', text: 'X' }] },
|
||||
];
|
||||
expect(parseHolidays(reversed, [])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops an entry with no usable name', () => {
|
||||
expect(parseHolidays([{ id: 'f', startDate: '2026-08-01', endDate: '2026-08-01' }], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults an unstated scope to nationwide', () => {
|
||||
const vague: ApiHoliday[] = [
|
||||
{ id: 'g', startDate: '2026-08-01', endDate: '2026-08-01', name: [{ language: 'FR', text: 'X' }] },
|
||||
];
|
||||
expect(parseHolidays(vague, [])[0]?.nationwide).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an empty calendar', () => {
|
||||
expect(parseHolidays([], [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withRetries', () => {
|
||||
it('returns the first success without waiting', async () => {
|
||||
const sleep = vi.fn(async (_ms: number) => undefined);
|
||||
expect(await withRetries(async () => 'ok', 3, 100, sleep)).toBe('ok');
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries and widens the gap', async () => {
|
||||
const sleep = vi.fn(async (_ms: number) => undefined);
|
||||
let attempts = 0;
|
||||
|
||||
const result = await withRetries(
|
||||
async () => {
|
||||
attempts += 1;
|
||||
if (attempts < 3) {
|
||||
throw new Error('transient');
|
||||
}
|
||||
return 'ok';
|
||||
},
|
||||
3,
|
||||
100,
|
||||
sleep,
|
||||
);
|
||||
|
||||
expect(result).toBe('ok');
|
||||
expect(sleep.mock.calls.map((call) => call[0])).toEqual([100, 200]);
|
||||
});
|
||||
|
||||
it('gives up after the last attempt and reports the final error', async () => {
|
||||
const sleep = vi.fn(async (_ms: number) => undefined);
|
||||
await expect(
|
||||
withRetries(
|
||||
async () => {
|
||||
throw new Error('toujours en panne');
|
||||
},
|
||||
3,
|
||||
10,
|
||||
sleep,
|
||||
),
|
||||
).rejects.toThrow('toujours en panne');
|
||||
expect(sleep).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Keeps a rolling twelve-month window of public holidays in the local cache.
|
||||
*
|
||||
* Idempotent by construction: running it twice leaves exactly what running it
|
||||
* once did. The one field it never touches on an existing row is
|
||||
* `isAutoClosed` — that is the shop's decision, not the API's, and a nightly
|
||||
* job quietly reopening a day the owner had closed would be the worst kind of
|
||||
* bug: invisible until someone finds the door locked.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import { addCivilDays, toCivilDate, type CivilDate } from '@/lib/schedule/civil';
|
||||
import { loadSettings } from '@/lib/schedule/context';
|
||||
|
||||
import { fetchHolidays, withRetries, type ParsedHoliday } from './openholidays';
|
||||
|
||||
export const SYNC_KEY = 'holidays';
|
||||
|
||||
export type SyncOutcome =
|
||||
| { ok: true; created: number; updated: number; removed: number; from: CivilDate; to: CivilDate }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type SyncDeps = {
|
||||
fetcher?: (options: {
|
||||
baseUrl: string;
|
||||
countryIsoCode: string;
|
||||
subdivisionCode: string;
|
||||
validFrom: CivilDate;
|
||||
validTo: CivilDate;
|
||||
}) => Promise<ParsedHoliday[]>;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
export async function syncHolidays(deps: SyncDeps = {}): Promise<SyncOutcome> {
|
||||
const now = deps.now ?? new Date();
|
||||
const settings = await loadSettings();
|
||||
const today = toCivilDate(now, settings.timezone);
|
||||
const from = today;
|
||||
const to = addCivilDays(today, 365);
|
||||
|
||||
const options = {
|
||||
baseUrl: process.env.HOLIDAYS_API_URL ?? 'https://openholidaysapi.org',
|
||||
countryIsoCode: settings.countryIsoCode,
|
||||
subdivisionCode: settings.subdivisionCode,
|
||||
validFrom: from,
|
||||
validTo: to,
|
||||
};
|
||||
|
||||
let holidays: ParsedHoliday[];
|
||||
try {
|
||||
const fetcher = deps.fetcher ?? ((input) => fetchHolidays(input));
|
||||
holidays = await withRetries(() => fetcher(options));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await prisma.syncState.upsert({
|
||||
where: { key: SYNC_KEY },
|
||||
update: { lastErrorAt: now, lastError: message.slice(0, 500) },
|
||||
create: { key: SYNC_KEY, lastErrorAt: now, lastError: message.slice(0, 500) },
|
||||
});
|
||||
// The local cache is left exactly as it was: a stale calendar beats none.
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
const existing = await prisma.publicHoliday.findMany({
|
||||
where: {
|
||||
subdivisionCode: settings.subdivisionCode,
|
||||
date: { gte: asDate(from), lte: asDate(to) },
|
||||
},
|
||||
});
|
||||
const existingByDate = new Map(existing.map((row) => [asCivil(row.date), row]));
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const holiday of holidays) {
|
||||
const current = existingByDate.get(holiday.date);
|
||||
if (current) {
|
||||
const changed =
|
||||
current.nameFr !== holiday.nameFr ||
|
||||
current.nameEn !== holiday.nameEn ||
|
||||
current.nationwide !== holiday.nationwide;
|
||||
if (changed) {
|
||||
updated += 1;
|
||||
}
|
||||
await prisma.publicHoliday.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
nameFr: holiday.nameFr,
|
||||
nameEn: holiday.nameEn,
|
||||
nationwide: holiday.nationwide,
|
||||
syncedAt: now,
|
||||
// isAutoClosed deliberately absent: the shop owns that choice.
|
||||
},
|
||||
});
|
||||
} else {
|
||||
created += 1;
|
||||
await prisma.publicHoliday.create({
|
||||
data: {
|
||||
date: asDate(holiday.date),
|
||||
nameFr: holiday.nameFr,
|
||||
nameEn: holiday.nameEn,
|
||||
nationwide: holiday.nationwide,
|
||||
subdivisionCode: settings.subdivisionCode,
|
||||
syncedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Days the API no longer lists — a cancelled or corrected holiday.
|
||||
const fetchedDates = new Set(holidays.map((holiday) => holiday.date));
|
||||
const stale = existing.filter((row) => !fetchedDates.has(asCivil(row.date)));
|
||||
if (stale.length > 0) {
|
||||
await prisma.publicHoliday.deleteMany({ where: { id: { in: stale.map((row) => row.id) } } });
|
||||
}
|
||||
|
||||
await prisma.syncState.upsert({
|
||||
where: { key: SYNC_KEY },
|
||||
update: { lastSuccessAt: now, lastErrorAt: null, lastError: null },
|
||||
create: { key: SYNC_KEY, lastSuccessAt: now },
|
||||
});
|
||||
|
||||
return { ok: true, created, updated, removed: stale.length, from, to };
|
||||
}
|
||||
|
||||
function asCivil(value: Date): CivilDate {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function asDate(value: CivilDate): Date {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
+2
-1
@@ -18,7 +18,8 @@
|
||||
"seed": "tsx prisma/seed.ts",
|
||||
"screen:preview": "tsx scripts/preview.ts",
|
||||
"brand": "tsx scripts/brand.ts",
|
||||
"e2e": "playwright test"
|
||||
"e2e": "playwright test",
|
||||
"holidays:sync": "tsx scripts/sync-holidays.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Synchronises the public-holiday cache from the command line.
|
||||
*
|
||||
* npm run holidays:sync
|
||||
*
|
||||
* The nightly job does this on its own; this exists for the first run after a
|
||||
* deployment, and for checking the API is reachable from a new host without
|
||||
* having to wait until three in the morning to find out.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
|
||||
import { prisma } from '../lib/db';
|
||||
import { syncHolidays } from '../lib/holidays/sync';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const result = await syncHolidays();
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`Échec : ${result.error}`);
|
||||
console.error('Le cache local est intact.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Fenêtre ${result.from} → ${result.to} : ` +
|
||||
`${result.created} ajouté(s), ${result.updated} corrigé(s), ${result.removed} retiré(s).`,
|
||||
);
|
||||
|
||||
const upcoming = await prisma.publicHoliday.findMany({ orderBy: { date: 'asc' }, take: 6 });
|
||||
for (const row of upcoming) {
|
||||
console.log(
|
||||
` ${row.date.toISOString().slice(0, 10)} ${row.nameFr.padEnd(24)} ${row.nameEn.padEnd(24)} ` +
|
||||
`${row.isAutoClosed ? 'fermé' : 'ouvert'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { prisma } from '@/lib/db';
|
||||
import type { ParsedHoliday } from '@/lib/holidays/openholidays';
|
||||
import { SYNC_KEY, syncHolidays } from '@/lib/holidays/sync';
|
||||
|
||||
import { hasDatabase, resetDatabase } from './helpers';
|
||||
|
||||
const NOW = new Date('2026-09-22T03:00:00Z');
|
||||
|
||||
const CALENDAR: ParsedHoliday[] = [
|
||||
{ date: '2026-09-25', nameFr: 'Jeûne genevois', nameEn: 'Geneva Fast', nationwide: false },
|
||||
{ date: '2026-12-25', nameFr: 'Noël', nameEn: 'Christmas Day', nationwide: true },
|
||||
];
|
||||
|
||||
function fetcher(holidays: ParsedHoliday[]) {
|
||||
return async () => holidays;
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDatabase)('syncHolidays', () => {
|
||||
beforeEach(async () => {
|
||||
await resetDatabase();
|
||||
});
|
||||
|
||||
it('fills an empty cache and records the success', async () => {
|
||||
const result = await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, created: 2, updated: 0, removed: 0 });
|
||||
expect(await prisma.publicHoliday.count()).toBe(2);
|
||||
|
||||
const state = await prisma.syncState.findUnique({ where: { key: SYNC_KEY } });
|
||||
expect(state?.lastSuccessAt).toEqual(NOW);
|
||||
expect(state?.lastError).toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent: twice leaves what once did', async () => {
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
const first = await prisma.publicHoliday.findMany({ orderBy: { date: 'asc' } });
|
||||
|
||||
const second = await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
expect(second).toMatchObject({ ok: true, created: 0, updated: 0, removed: 0 });
|
||||
|
||||
const after = await prisma.publicHoliday.findMany({ orderBy: { date: 'asc' } });
|
||||
expect(after.map((row) => row.id)).toEqual(first.map((row) => row.id));
|
||||
expect(after.map((row) => row.nameFr)).toEqual(first.map((row) => row.nameFr));
|
||||
});
|
||||
|
||||
it('never reopens a day the shop chose to close', async () => {
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
// The owner decides to trade through Christmas.
|
||||
await prisma.publicHoliday.updateMany({
|
||||
where: { nameFr: 'Noël' },
|
||||
data: { isAutoClosed: false },
|
||||
});
|
||||
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
// A nightly job quietly undoing that would be invisible until someone
|
||||
// found the door locked.
|
||||
const christmas = await prisma.publicHoliday.findFirstOrThrow({ where: { nameFr: 'Noël' } });
|
||||
expect(christmas.isAutoClosed).toBe(false);
|
||||
});
|
||||
|
||||
it('corrects a name the API has changed', async () => {
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
const corrected = CALENDAR.map((holiday) =>
|
||||
holiday.nameFr === 'Noël' ? { ...holiday, nameEn: 'Christmas' } : holiday,
|
||||
);
|
||||
const result = await syncHolidays({ now: NOW, fetcher: fetcher(corrected) });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, updated: 1 });
|
||||
const christmas = await prisma.publicHoliday.findFirstOrThrow({ where: { nameFr: 'Noël' } });
|
||||
expect(christmas.nameEn).toBe('Christmas');
|
||||
});
|
||||
|
||||
it('removes a holiday the API no longer lists', async () => {
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
const result = await syncHolidays({ now: NOW, fetcher: fetcher([CALENDAR[0]!]) });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, removed: 1 });
|
||||
expect(await prisma.publicHoliday.count()).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the cache and records the failure when the API is down', async () => {
|
||||
await syncHolidays({ now: NOW, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
const later = new Date('2026-09-23T03:00:00Z');
|
||||
const result = await syncHolidays({
|
||||
now: later,
|
||||
fetcher: async () => {
|
||||
throw new Error('503 Service Unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ ok: false });
|
||||
// A stale calendar beats no calendar.
|
||||
expect(await prisma.publicHoliday.count()).toBe(2);
|
||||
|
||||
const state = await prisma.syncState.findUnique({ where: { key: SYNC_KEY } });
|
||||
expect(state?.lastErrorAt).toEqual(later);
|
||||
expect(state?.lastError).toContain('503');
|
||||
// The last success is still on record, so the UI can say how stale it is.
|
||||
expect(state?.lastSuccessAt).toEqual(NOW);
|
||||
});
|
||||
|
||||
it('clears the error once the API comes back', async () => {
|
||||
await syncHolidays({
|
||||
now: NOW,
|
||||
fetcher: async () => {
|
||||
throw new Error('panne');
|
||||
},
|
||||
});
|
||||
|
||||
const later = new Date('2026-09-23T03:00:00Z');
|
||||
await syncHolidays({ now: later, fetcher: fetcher(CALENDAR) });
|
||||
|
||||
const state = await prisma.syncState.findUnique({ where: { key: SYNC_KEY } });
|
||||
expect(state?.lastError).toBeNull();
|
||||
expect(state?.lastErrorAt).toBeNull();
|
||||
expect(state?.lastSuccessAt).toEqual(later);
|
||||
});
|
||||
|
||||
it('accepts an empty calendar without emptying the window twice', async () => {
|
||||
const result = await syncHolidays({ now: NOW, fetcher: fetcher([]) });
|
||||
expect(result).toMatchObject({ ok: true, created: 0, removed: 0 });
|
||||
expect(await prisma.publicHoliday.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user