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:
2026-09-20 21:23:47 +02:00
co-authored by Claude Opus 5
parent ea945b50d6
commit 830e595393
12 changed files with 921 additions and 1 deletions
+134
View File
@@ -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>
);
}
+73
View File
@@ -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).`,
},
};
}
+57
View File
@@ -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&nbsp;: 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>
);
}
+1
View File
@@ -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' },
];
/**