(null);
@@ -119,6 +122,10 @@ export default function MessageManager({
/>
+
+
+
+
-
+
);
}
diff --git a/app/admin/modeles/TemplateManager.tsx b/app/admin/modeles/TemplateManager.tsx
new file mode 100644
index 0000000..75a7e16
--- /dev/null
+++ b/app/admin/modeles/TemplateManager.tsx
@@ -0,0 +1,223 @@
+'use client';
+
+import { useState, useTransition } from 'react';
+
+import { MESSAGE_MAX_CHARS } from '@/lib/screen/contract';
+
+import {
+ deleteTemplate,
+ saveTemplate,
+ setTemplateTranslation,
+ translateTemplate,
+} from './actions';
+
+export type TemplateRow = {
+ id: string;
+ textFr: string;
+ textEn: string | null;
+ translationStatus: 'PENDING' | 'DONE' | 'MANUAL' | 'ERROR';
+ sortOrder: number;
+};
+
+const STATUS: Record = {
+ PENDING: { label: 'traduction en attente', className: 'bg-[var(--surface-muted)] text-[var(--ink-muted)]' },
+ DONE: { label: 'traduit', className: 'bg-[var(--success-tint)] text-[var(--success)]' },
+ MANUAL: { label: 'traduction manuelle', className: 'bg-[var(--accent-tint)] text-[var(--accent-ink)]' },
+ ERROR: { label: 'traduction en échec', className: 'bg-[var(--danger-tint)] text-[var(--danger)]' },
+};
+
+export default function TemplateManager({
+ canEdit,
+ templates,
+}: {
+ canEdit: boolean;
+ templates: TemplateRow[];
+}) {
+ const [textFr, setTextFr] = useState('');
+ const [pending, startTransition] = useTransition();
+ const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
+
+ const remaining = MESSAGE_MAX_CHARS - textFr.length;
+
+ function run(work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) {
+ setMessage(null);
+ startTransition(async () => {
+ const result = await work();
+ setMessage(result.ok ? { kind: 'ok', text: success } : { kind: 'error', text: result.error });
+ });
+ }
+
+ function create() {
+ setMessage(null);
+ startTransition(async () => {
+ const saved = await saveTemplate({ textFr, sortOrder: templates.length });
+ if (!saved.ok) {
+ setMessage({ kind: 'error', text: saved.error });
+ return;
+ }
+ setTextFr('');
+ setMessage({ kind: 'ok', text: 'Modèle enregistré. Traduction en cours…' });
+
+ const translated = await translateTemplate(saved.value.id);
+ setMessage(
+ translated.ok
+ ? { kind: 'ok', text: 'Modèle enregistré et traduit.' }
+ : { kind: 'error', text: `Modèle enregistré, mais la traduction a échoué : ${translated.error}` },
+ );
+ });
+ }
+
+ return (
+
+ {message ? (
+
+ {message.text}
+
+ ) : null}
+
+ {canEdit ? (
+
+ ) : null}
+
+
+ Modèles enregistrés
+ {templates.length === 0 ? (
+
+ Aucun modèle. Ceux que vous ajoutez ici seront proposés partout où l’on compose un
+ message.
+
+ ) : (
+
+ {templates.map((row) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function TemplateCard({
+ row,
+ canEdit,
+ pending,
+ run,
+ startTransition,
+ setMessage,
+}: {
+ row: TemplateRow;
+ canEdit: boolean;
+ pending: boolean;
+ run: (work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) => void;
+ startTransition: (work: () => void) => void;
+ setMessage: (value: { kind: 'ok' | 'error'; text: string } | null) => void;
+}) {
+ const [draftEn, setDraftEn] = useState(row.textEn ?? '');
+ const badge = STATUS[row.translationStatus];
+
+ return (
+
+
+
{row.textFr}
+
+ {badge.label}
+
+
+
+ {canEdit ? (
+
+
+ Traduction anglaise
+ setDraftEn(event.target.value)}
+ className="mt-1 w-full rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-1.5 text-base"
+ />
+
+
+ run(() => setTemplateTranslation(row.id, draftEn), 'Traduction enregistrée.')
+ }
+ className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-3 py-1.5 text-sm hover:bg-[var(--surface-muted)] disabled:opacity-40"
+ >
+ Enregistrer
+
+ {
+ setMessage(null);
+ startTransition(async () => {
+ const result = await translateTemplate(row.id);
+ setMessage(
+ result.ok
+ ? { kind: 'ok', text: 'Traduction mise à jour.' }
+ : { kind: 'error', text: result.error },
+ );
+ });
+ }}
+ className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-3 py-1.5 text-sm hover:bg-[var(--surface-muted)] disabled:opacity-40"
+ >
+ {row.translationStatus === 'ERROR' ? 'Réessayer' : 'Retraduire'}
+
+ run(() => deleteTemplate(row.id), 'Modèle supprimé.')}
+ className="ml-auto text-sm text-[var(--ink-muted)] underline-offset-2 hover:text-[var(--danger)] hover:underline disabled:opacity-40"
+ >
+ Supprimer
+
+
+ ) : row.textEn ? (
+ {row.textEn}
+ ) : null}
+
+ );
+}
diff --git a/app/admin/modeles/actions.ts b/app/admin/modeles/actions.ts
new file mode 100644
index 0000000..a725a4a
--- /dev/null
+++ b/app/admin/modeles/actions.ts
@@ -0,0 +1,164 @@
+'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 { MESSAGE_MAX_CHARS } from '@/lib/screen/contract';
+import { translateToEnglish } from '@/lib/translation/service';
+
+export type ActionResult = { ok: true; value: T } | { ok: false; error: string };
+
+/**
+ * Saves a reusable phrase.
+ *
+ * Templates are translated like any other message, but once rather than at
+ * every use: that is most of the point of having them.
+ */
+export async function saveTemplate(input: {
+ id?: string;
+ textFr: string;
+ sortOrder: number;
+}): Promise> {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const textFr = input.textFr.trim();
+ if (textFr.length === 0) {
+ return { ok: false, error: 'Le modèle ne peut pas être vide.' };
+ }
+ if (textFr.length > MESSAGE_MAX_CHARS) {
+ return {
+ ok: false,
+ error: `Le modèle dépasse ${MESSAGE_MAX_CHARS} caractères et ne tiendrait pas sur l’écran.`,
+ };
+ }
+
+ const existing = input.id
+ ? await prisma.messageTemplate.findUnique({ where: { id: input.id } })
+ : null;
+
+ // The French text is the identity of a template: two identical phrases are
+ // one phrase, and a duplicate would only ever be half-corrected later.
+ const clash = await prisma.messageTemplate.findUnique({ where: { textFr } });
+ if (clash && clash.id !== existing?.id) {
+ return { ok: false, error: 'Ce modèle existe déjà.' };
+ }
+
+ const textChanged = existing ? existing.textFr !== textFr : true;
+ const data = {
+ textFr,
+ sortOrder: input.sortOrder,
+ ...(textChanged ? { textEn: null, translationStatus: 'PENDING' as const } : {}),
+ };
+
+ const saved = existing
+ ? await prisma.messageTemplate.update({ where: { id: existing.id }, data })
+ : await prisma.messageTemplate.create({ data: { ...data, createdBy: gate.value.email } });
+
+ await recordAudit({
+ userEmail: gate.value.email,
+ action: existing ? 'template.update' : 'template.create',
+ entity: 'MessageTemplate',
+ entityId: saved.id,
+ diff: diffOf(existing ? { texte: existing.textFr } : null, { texte: textFr }),
+ });
+
+ revalidateAll();
+ return { ok: true, value: { id: saved.id } };
+}
+
+export async function translateTemplate(id: string): Promise> {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const template = await prisma.messageTemplate.findUnique({ where: { id } });
+ if (!template) {
+ return { ok: false, error: 'Ce modèle a été supprimé.' };
+ }
+ if (template.translationStatus === 'MANUAL') {
+ return { ok: false, error: 'La traduction a été saisie à la main; elle n’est pas écrasée.' };
+ }
+
+ const outcome = await translateToEnglish(template.textFr);
+ if (!outcome.ok) {
+ await prisma.messageTemplate.update({ where: { id }, data: { translationStatus: 'ERROR' } });
+ revalidateAll();
+ return { ok: false, error: outcome.error };
+ }
+
+ await prisma.messageTemplate.update({
+ where: { id },
+ data: { textEn: outcome.text, translationStatus: 'DONE' },
+ });
+
+ revalidateAll();
+ return { ok: true, value: { textEn: outcome.text } };
+}
+
+export async function setTemplateTranslation(id: string, textEn: string): Promise {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const trimmed = textEn.trim();
+ const existing = await prisma.messageTemplate.findUnique({ where: { id } });
+ if (!existing) {
+ return { ok: false, error: 'Ce modèle a été supprimé.' };
+ }
+
+ await prisma.messageTemplate.update({
+ where: { id },
+ data: { textEn: trimmed || null, translationStatus: trimmed ? 'MANUAL' : 'PENDING' },
+ });
+
+ await recordAudit({
+ userEmail: gate.value.email,
+ action: 'template.translate_manual',
+ entity: 'MessageTemplate',
+ entityId: id,
+ diff: diffOf({ anglais: existing.textEn }, { anglais: trimmed || null }),
+ });
+
+ revalidateAll();
+ return { ok: true, value: undefined };
+}
+
+export async function deleteTemplate(id: string): Promise {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const existing = await prisma.messageTemplate.findUnique({ where: { id } });
+ if (!existing) {
+ return { ok: false, error: 'Ce modèle a déjà été supprimé.' };
+ }
+
+ await prisma.messageTemplate.delete({ where: { id } });
+
+ await recordAudit({
+ userEmail: gate.value.email,
+ action: 'template.delete',
+ entity: 'MessageTemplate',
+ entityId: id,
+ diff: diffOf({ texte: existing.textFr }, null),
+ });
+
+ revalidateAll();
+ return { ok: true, value: undefined };
+}
+
+function revalidateAll(): void {
+ // Templates appear in the pickers on every page that composes a message.
+ revalidatePath('/admin/modeles');
+ revalidatePath('/admin/exceptions');
+ revalidatePath('/admin/vacances');
+ revalidatePath('/admin/messages');
+}
diff --git a/app/admin/modeles/page.tsx b/app/admin/modeles/page.tsx
new file mode 100644
index 0000000..40e68cd
--- /dev/null
+++ b/app/admin/modeles/page.tsx
@@ -0,0 +1,33 @@
+import { auth } from '@/lib/auth';
+import { prisma } from '@/lib/db';
+
+import TemplateManager, { type TemplateRow } from './TemplateManager';
+
+export const metadata = { title: 'Modèles de message — ITA ITO' };
+
+export default async function TemplatesPage() {
+ const [session, rows] = await Promise.all([
+ auth(),
+ prisma.messageTemplate.findMany({ orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }),
+ ]);
+
+ const templates: TemplateRow[] = rows.map((row) => ({
+ id: row.id,
+ textFr: row.textFr,
+ textEn: row.textEn,
+ translationStatus: row.translationStatus,
+ sortOrder: row.sortOrder,
+ }));
+
+ return (
+
+ Modèles de message
+
+ Les phrases que la boutique écrit souvent. Enregistrées ici, elles sont traduites une fois
+ et proposées partout où l’on compose un message — exceptions, vacances, bandeau.
+
+
+
+
+ );
+}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index 8d681a0..e2b2d1b 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -112,8 +112,8 @@ export default async function AdminHome() {
- L’écran en ce moment
-
+ L’écran
+
diff --git a/app/admin/vacances/VacationManager.tsx b/app/admin/vacances/VacationManager.tsx
index 5d29cfc..720dbd4 100644
--- a/app/admin/vacances/VacationManager.tsx
+++ b/app/admin/vacances/VacationManager.tsx
@@ -2,9 +2,10 @@
import { useState, useTransition } from 'react';
+import TemplatePicker, { type Template } from '@/app/admin/components/TemplatePicker';
import { countDays } from '@/lib/schedule/vacations';
-import { createVacation, deleteVacation } from './actions';
+import { createVacation, deleteVacation, translateVacationLabel } from './actions';
export type VacationRow = {
id: string;
@@ -18,9 +19,11 @@ export type VacationRow = {
export default function VacationManager({
canEdit,
periods,
+ templates,
}: {
canEdit: boolean;
periods: VacationRow[];
+ templates: Template[];
}) {
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
@@ -97,6 +100,7 @@ export default function VacationManager({
className="mt-1 w-full rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base"
/>
+
@@ -104,10 +108,15 @@ export default function VacationManager({
type="button"
disabled={pending || !startDate || !endDate || labelFr.trim().length === 0}
onClick={() =>
- run(
- () => createVacation({ startDate, endDate, labelFr }),
- 'Période de fermeture enregistrée.',
- )
+ run(async () => {
+ const created = await createVacation({ startDate, endDate, labelFr });
+ if (created.ok) {
+ // After the fact: the dates matter more than the English,
+ // and the translation service is measured in seconds.
+ await translateVacationLabel(created.id);
+ }
+ return created;
+ }, 'Période de fermeture enregistrée.')
}
className="rounded-[var(--radius-md)] bg-[var(--accent)] px-4 py-2.5 font-medium text-[var(--on-accent)] transition-colors hover:bg-[var(--accent-hover)] disabled:opacity-40"
>
diff --git a/app/admin/vacances/actions.ts b/app/admin/vacances/actions.ts
index e33b249..27da492 100644
--- a/app/admin/vacances/actions.ts
+++ b/app/admin/vacances/actions.ts
@@ -7,6 +7,7 @@ import { requireAdmin } from '@/lib/auth/actions';
import { prisma } from '@/lib/db';
import type { CivilDate } from '@/lib/schedule/civil';
import { countDays, findOverlap, validatePeriod } from '@/lib/schedule/vacations';
+import { translateToEnglish } from '@/lib/translation/service';
export type ActionResult = { ok: true } | { ok: false; error: string };
@@ -14,7 +15,9 @@ export async function createVacation(input: {
startDate: CivilDate;
endDate: CivilDate;
labelFr: string;
-}): Promise
{
+ // Returns the new id so the caller can translate it without having to ask
+ // which row was "the last one", which would be a race.
+}): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
const gate = await requireAdmin();
if (!gate.ok) {
return { ok: false, error: gate.error };
@@ -45,11 +48,17 @@ export async function createVacation(input: {
};
}
+ // A phrase saved as a template is already translated; reusing it is instant
+ // where the service takes seconds. The label reaches the panel during the
+ // closure, so it needs its English.
+ const fromTemplate = await prisma.messageTemplate.findUnique({ where: { textFr: labelFr } });
+
const created = await prisma.vacationPeriod.create({
data: {
startDate: asDate(input.startDate),
endDate: asDate(input.endDate),
labelFr,
+ labelEn: fromTemplate?.textEn ?? null,
createdBy: gate.value.email,
},
});
@@ -68,7 +77,7 @@ export async function createVacation(input: {
});
revalidateAll();
- return { ok: true };
+ return { ok: true, id: created.id };
}
export async function deleteVacation(id: string): Promise {
@@ -116,3 +125,30 @@ function revalidateAll(): void {
revalidatePath('/admin/vacances');
revalidatePath('/admin/exceptions');
}
+
+/**
+ * Translates a period's label, if it still needs one.
+ *
+ * Separate from the creation, so a slow translation service never costs the
+ * shop its holiday dates. A failure leaves the French alone on screen.
+ */
+export async function translateVacationLabel(id: string): Promise {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const existing = await prisma.vacationPeriod.findUnique({ where: { id } });
+ if (!existing || existing.labelEn) {
+ return { ok: true };
+ }
+
+ const outcome = await translateToEnglish(existing.labelFr);
+ if (!outcome.ok) {
+ return { ok: false, error: outcome.error };
+ }
+
+ await prisma.vacationPeriod.update({ where: { id }, data: { labelEn: outcome.text } });
+ revalidateAll();
+ return { ok: true };
+}
diff --git a/app/admin/vacances/page.tsx b/app/admin/vacances/page.tsx
index 724b5d6..b91b289 100644
--- a/app/admin/vacances/page.tsx
+++ b/app/admin/vacances/page.tsx
@@ -3,6 +3,7 @@ import { prisma } from '@/lib/db';
import { toCivilDate } from '@/lib/schedule/civil';
import { loadSettings } from '@/lib/schedule/context';
import { periodsOverlap, upcomingPeriods } from '@/lib/schedule/vacations';
+import { loadTemplates } from '@/lib/templates';
import VacationManager, { type VacationRow } from './VacationManager';
import YearCalendar from './YearCalendar';
@@ -10,7 +11,11 @@ import YearCalendar from './YearCalendar';
export const metadata = { title: 'Vacances — ITA ITO' };
export default async function VacationsPage() {
- const [session, settings] = await Promise.all([auth(), loadSettings()]);
+ const [session, settings, templates] = await Promise.all([
+ auth(),
+ loadSettings(),
+ loadTemplates(),
+ ]);
const today = toCivilDate(new Date(), settings.timezone);
const rows = await prisma.vacationPeriod.findMany({ orderBy: { startDate: 'asc' } });
@@ -39,6 +44,7 @@ export default async function VacationsPage() {
diff --git a/app/api/admin/preview/route.ts b/app/api/admin/preview/route.ts
index ad5fb7d..2b17bed 100644
--- a/app/api/admin/preview/route.ts
+++ b/app/api/admin/preview/route.ts
@@ -1,22 +1,30 @@
import { publicBaseUrl } from '@/lib/config';
import { encodeScreen } from '@/lib/screen/encode';
+import { parsePreviewTime } from '@/lib/screen/preview-time';
import { renderScreenSvg } from '@/lib/screen/render';
import { buildCurrentScreen } from '@/lib/screen/service';
export const dynamic = 'force-dynamic';
/**
- * The panel as it stands right now, as a PNG for the browser.
+ * The panel as it stands, or as it will stand at a given moment.
*
* Deliberately the same pipeline the device is served from — payload, SVG,
* threshold — so what the admin sees is what the shop window shows, down to
* the last thresholded pixel. A preview drawn any other way would eventually
* disagree with reality, and quietly.
*
+ * `?at=` moves the clock. The whole pipeline is already a function of "now",
+ * so showing next Tuesday costs nothing but passing a different instant —
+ * which is how a holiday closure can be checked before anyone is standing in
+ * front of a locked door.
+ *
* Behind /api/admin, so the middleware requires a session.
*/
export async function GET(request: Request) {
- const { payload } = await buildCurrentScreen(new Date(), publicBaseUrl(request));
+ const { at } = parsePreviewTime(new URL(request.url).searchParams.get('at'), new Date());
+
+ const { payload } = await buildCurrentScreen(at, publicBaseUrl(request));
const png = encodeScreen(await renderScreenSvg(payload), 'png');
return new Response(new Uint8Array(png), {
diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts
new file mode 100644
index 0000000..98f7f81
--- /dev/null
+++ b/e2e/dashboard.spec.ts
@@ -0,0 +1,24 @@
+import { expect, resetDatabase, signIn, test } from './fixtures';
+
+test.beforeEach(async () => {
+ await resetDatabase();
+});
+
+test('the panel can be previewed at a future moment', async ({ page }) => {
+ await signIn(page, 'admin');
+ await page.goto('/admin');
+
+ const preview = page.getByRole('img', { name: /Aperçu de l’écran/ });
+ await expect(preview).toBeVisible();
+
+ // The question the shop asks before a holiday is "what will the window say
+ // while I'm away?" — which is worth answering before someone is standing in
+ // front of a locked door.
+ await page.getByRole('button', { name: 'Demain 10:00' }).click();
+
+ await expect(preview).toHaveAttribute('src', /at=/);
+ await expect(page.getByText(/Projection/)).toBeVisible();
+
+ await page.getByRole('button', { name: 'Revenir à maintenant' }).click();
+ await expect(preview).not.toHaveAttribute('src', /at=/);
+});
diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts
index cec14a4..bcf7a19 100644
--- a/e2e/fixtures.ts
+++ b/e2e/fixtures.ts
@@ -78,8 +78,8 @@ const WEEK: [number, boolean, string][] = [
export async function resetDatabase(): Promise {
await query(`
TRUNCATE device_logs, devices, screen_images, schedule_exceptions, vacation_periods,
- public_holidays, messages, translation_cache, audit_logs, weekly_schedules,
- settings, sync_states RESTART IDENTITY CASCADE
+ public_holidays, messages, message_templates, translation_cache, audit_logs,
+ weekly_schedules, settings, sync_states RESTART IDENTITY CASCADE
`);
await query(`INSERT INTO settings (id, "updatedAt") VALUES ('singleton', now())`);
diff --git a/e2e/messages.spec.ts b/e2e/messages.spec.ts
index cbf0ce2..b9e8ce0 100644
--- a/e2e/messages.spec.ts
+++ b/e2e/messages.spec.ts
@@ -51,6 +51,22 @@ test('a translation typed by hand is kept and shown', async ({ page }) => {
expect(stored?.translationStatus).toBe('MANUAL');
});
+test('a saved phrase can be reused wherever a message is composed', async ({ page }) => {
+ await signIn(page, 'admin');
+
+ await page.goto('/admin/modeles');
+ await page.getByLabel(/Texte en français/).fill('Fermeture exceptionnelle, retour demain');
+ await page.getByRole('button', { name: 'Ajouter' }).click();
+ await expect(page.getByRole('status')).toContainText(/Modèle enregistré/);
+
+ // The point of a template is that it turns up elsewhere without retyping.
+ await page.goto('/admin/exceptions');
+ await page.getByLabel('Modèle de message').first().selectOption({ label: 'Fermeture exceptionnelle, retour demain' });
+ await expect(page.getByLabel(/Message affiché/).first()).toHaveValue(
+ 'Fermeture exceptionnelle, retour demain',
+ );
+});
+
test('the counter warns before a message stops fitting', async ({ page }) => {
await signIn(page, 'admin');
await page.goto('/admin/messages');
diff --git a/lib/screen/contract.ts b/lib/screen/contract.ts
index 686cc55..417ba33 100644
--- a/lib/screen/contract.ts
+++ b/lib/screen/contract.ts
@@ -21,10 +21,11 @@ export const MESSAGE_MAX_CHARS = 120;
export type ScreenStatus = 'OPEN' | 'CLOSED' | 'CLOSING_SOON' | 'OPENING_SOON';
/**
- * Where the banner text came from: something a person wrote, or the automatic
- * notice raised when the week departs from the reference schedule.
+ * Where the banner text came from: the reason written on today's exception,
+ * a free message somebody composed, or the automatic notice raised when the
+ * week departs from the reference schedule.
*/
-export type ScreenBannerKind = 'MESSAGE' | 'SCHEDULE_TODAY' | 'SCHEDULE_WEEK';
+export type ScreenBannerKind = 'EXCEPTION' | 'MESSAGE' | 'SCHEDULE_TODAY' | 'SCHEDULE_WEEK';
export type ScreenToday = {
date_fr: string;
diff --git a/lib/screen/preview-time.test.ts b/lib/screen/preview-time.test.ts
new file mode 100644
index 0000000..3ca78cd
--- /dev/null
+++ b/lib/screen/preview-time.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+
+import { parsePreviewTime, PREVIEW_RANGE_DAYS } from './preview-time';
+
+const NOW = new Date('2026-09-23T08:00:00Z');
+
+describe('parsePreviewTime', () => {
+ it('defaults to now when nothing is asked for', () => {
+ expect(parsePreviewTime(null, NOW)).toEqual({ at: NOW, isNow: true });
+ });
+
+ it('accepts a moment in the future', () => {
+ const result = parsePreviewTime('2026-09-24T08:00:00Z', NOW);
+ expect(result.at.toISOString()).toBe('2026-09-24T08:00:00.000Z');
+ expect(result.isNow).toBe(false);
+ });
+
+ it('accepts a local datetime as a browser sends it', () => {
+ // has no timezone; it is read as local time.
+ expect(parsePreviewTime('2026-12-24T10:00', NOW).at.getFullYear()).toBe(2026);
+ });
+
+ it('accepts a moment in the past', () => {
+ expect(parsePreviewTime('2026-09-20T08:00:00Z', NOW).isNow).toBe(false);
+ });
+
+ it('falls back to now on an unparseable value', () => {
+ // Better a correct screen than a confident rendering of nonsense.
+ expect(parsePreviewTime('demain matin', NOW)).toEqual({ at: NOW, isNow: true });
+ expect(parsePreviewTime('', NOW)).toEqual({ at: NOW, isNow: true });
+ });
+
+ it('refuses a moment beyond the planning horizon', () => {
+ const far = new Date(NOW.getTime() + (PREVIEW_RANGE_DAYS + 1) * 86_400_000);
+ expect(parsePreviewTime(far.toISOString(), NOW).isNow).toBe(true);
+ });
+
+ it('accepts a moment just inside the horizon', () => {
+ const edge = new Date(NOW.getTime() + (PREVIEW_RANGE_DAYS - 1) * 86_400_000);
+ expect(parsePreviewTime(edge.toISOString(), NOW).isNow).toBe(false);
+ });
+
+ it('treats a moment within the minute as now', () => {
+ const almost = new Date(NOW.getTime() + 30_000);
+ expect(parsePreviewTime(almost.toISOString(), NOW).isNow).toBe(true);
+ });
+});
diff --git a/lib/screen/preview-time.ts b/lib/screen/preview-time.ts
new file mode 100644
index 0000000..f9ab4c9
--- /dev/null
+++ b/lib/screen/preview-time.ts
@@ -0,0 +1,32 @@
+/**
+ * Reading the moment a preview is asked for.
+ *
+ * The whole display pipeline is already a function of "now", so showing the
+ * panel as it will be next Tuesday costs nothing but passing a different
+ * instant. What it does need is a bound: an unparseable or absurd value
+ * should not render a screen for the year 3000 and leave someone believing it.
+ */
+
+/** As far ahead or behind as a shop plausibly plans. */
+export const PREVIEW_RANGE_DAYS = 400;
+
+export type PreviewTime = { at: Date; isNow: boolean };
+
+export function parsePreviewTime(raw: string | null, now: Date): PreviewTime {
+ if (!raw) {
+ return { at: now, isNow: true };
+ }
+
+ const parsed = new Date(raw);
+ if (Number.isNaN(parsed.getTime())) {
+ return { at: now, isNow: true };
+ }
+
+ const limit = PREVIEW_RANGE_DAYS * 86_400_000;
+ const distance = parsed.getTime() - now.getTime();
+ if (Math.abs(distance) > limit) {
+ return { at: now, isNow: true };
+ }
+
+ return { at: parsed, isNow: Math.abs(distance) < 60_000 };
+}
diff --git a/lib/screen/viewmodel.test.ts b/lib/screen/viewmodel.test.ts
index 86c34de..0a2c44b 100644
--- a/lib/screen/viewmodel.test.ts
+++ b/lib/screen/viewmodel.test.ts
@@ -212,11 +212,140 @@ describe('buildScreenPayload', () => {
expect(payload.week[3]?.is_closed).toBe(true);
});
+ it('shows why today’s hours changed, while the door is shut', () => {
+ // Monday 2026-09-21, closed all day, with a reason written on it.
+ const payload = buildScreenPayload(
+ input({
+ now: new Date('2026-09-21T10:00:00Z'),
+ ctx: context({
+ exceptions: [
+ {
+ date: '2026-09-21',
+ isClosed: true,
+ slots: null,
+ reason: 'TEMPORARY',
+ noteFr: 'Fermé pour inventaire, retour mardi',
+ noteEn: 'Closed for stocktaking, back on Tuesday',
+ source: 'MANUAL',
+ },
+ ],
+ }),
+ }),
+ );
+
+ expect(payload.banner).toEqual({
+ kind: 'EXCEPTION',
+ text_fr: 'Fermé pour inventaire, retour mardi',
+ text_en: 'Closed for stocktaking, back on Tuesday',
+ });
+ });
+
+ it('drops the reason the moment the shop opens', () => {
+ // A late opening, seen from after it: the notice explaining it is worse
+ // than useless once the door is open.
+ const lateOpening = context({
+ exceptions: [
+ {
+ date: '2026-09-22',
+ isClosed: false,
+ slots: [{ open: '14:00', close: '18:30' }],
+ reason: 'TEMPORARY',
+ noteFr: 'Ouverture à 14:00',
+ noteEn: 'Opening at 2:00 pm',
+ source: 'MANUAL',
+ },
+ ],
+ });
+
+ // 12:00 local, still shut: the reason is shown.
+ expect(
+ buildScreenPayload(input({ now: new Date('2026-09-22T10:00:00Z'), ctx: lateOpening }))
+ .banner?.kind,
+ ).toBe('EXCEPTION');
+
+ // 15:00 local, open: it is gone, and the automatic notice takes over.
+ const afterOpening = buildScreenPayload(
+ input({ now: new Date('2026-09-22T13:00:00Z'), ctx: lateOpening }),
+ );
+ expect(afterOpening.banner?.kind).not.toBe('EXCEPTION');
+ expect(afterOpening.banner?.text_fr).not.toContain('Ouverture à 14:00');
+ });
+
+ it('puts the reason ahead of a free message while the shop is shut', () => {
+ // "Closed this afternoon" is what someone at the door needs; "new
+ // collection in store" can wait.
+ const payload = buildScreenPayload(
+ input({
+ now: new Date('2026-09-21T10:00:00Z'),
+ ctx: context({
+ exceptions: [
+ {
+ date: '2026-09-21',
+ isClosed: true,
+ slots: null,
+ reason: 'TEMPORARY',
+ noteFr: 'Fermeture exceptionnelle',
+ noteEn: 'Exceptionally closed',
+ source: 'MANUAL',
+ },
+ ],
+ }),
+ messages: [
+ {
+ textFr: 'Nouvelle collection en boutique',
+ textEn: 'New collection in store',
+ startsAt: null,
+ endsAt: null,
+ priority: 9,
+ isActive: true,
+ },
+ ],
+ }),
+ );
+
+ expect(payload.banner?.text_fr).toBe('Fermeture exceptionnelle');
+ });
+
+ it('falls back to the free message when the exception has no reason', () => {
+ const payload = buildScreenPayload(
+ input({
+ now: new Date('2026-09-21T10:00:00Z'),
+ ctx: context({
+ exceptions: [
+ {
+ date: '2026-09-21',
+ isClosed: true,
+ slots: null,
+ reason: 'TEMPORARY',
+ noteFr: null,
+ noteEn: null,
+ source: 'MANUAL',
+ },
+ ],
+ }),
+ messages: [
+ {
+ textFr: 'Nouvelle collection',
+ textEn: 'New collection',
+ startsAt: null,
+ endsAt: null,
+ priority: 0,
+ isActive: true,
+ },
+ ],
+ }),
+ );
+
+ expect(payload.banner).toMatchObject({ kind: 'MESSAGE', text_fr: 'Nouvelle collection' });
+ });
+
it('carries no banner when the shop has nothing to say', () => {
expect(buildScreenPayload(input()).banner).toBeNull();
});
it('raises the automatic banner when only today departs from the reference week', () => {
+ // No reason written on the exception: that is what leaves the automatic
+ // notice to speak. With one, the reason would take the banner instead.
const payload = buildScreenPayload(
input({
ctx: context({
@@ -226,8 +355,8 @@ describe('buildScreenPayload', () => {
isClosed: false,
slots: [{ open: '14:00', close: '18:30' }],
reason: 'TEMPORARY',
- noteFr: 'Ouverture retardée',
- noteEn: 'Late opening',
+ noteFr: null,
+ noteEn: null,
source: 'MANUAL',
},
],
diff --git a/lib/screen/viewmodel.ts b/lib/screen/viewmodel.ts
index 3b10b75..85b5a98 100644
--- a/lib/screen/viewmodel.ts
+++ b/lib/screen/viewmodel.ts
@@ -26,7 +26,7 @@ import {
type NextChange,
} from '@/lib/schedule/format';
import { detectDivergence, getCurrentStatus, resolveCivilDay } from '@/lib/schedule/resolver';
-import type { ScheduleContext } from '@/lib/schedule/types';
+import type { ScheduleContext, ShopStatus } from '@/lib/schedule/types';
import {
AUTO_BANNERS,
@@ -112,7 +112,7 @@ export function buildScreenPayload({
is_exception: status.today.isException,
},
week: buildWeek(today, ctx),
- banner: buildBanner(now, ctx, messages),
+ banner: buildBanner(now, ctx, messages, status, isOpenNow),
};
}
@@ -138,8 +138,29 @@ function buildBanner(
now: Date,
ctx: ScheduleContext,
messages: MessageCandidate[],
+ status: ShopStatus,
+ isOpenNow: boolean,
): ScreenBanner | null {
- // A message someone took the trouble to write always wins the space.
+ // The reason today's hours changed, while the door is shut.
+ //
+ // It outranks a free message on purpose: "closed this afternoon, back
+ // tomorrow" is what someone standing outside needs, and "new collection in
+ // store" can wait. And it disappears the moment the shop opens — a notice
+ // explaining a late opening is worse than useless once the door is open.
+ //
+ // `isOpenNow` is the state at this minute, not the day's: a day that opens
+ // at 14:00 has opening hours all morning while its door is shut, and that
+ // morning is exactly when the reason needs to be on screen.
+ const exceptionNote = status.today.isException && !isOpenNow ? status.today : null;
+ if (exceptionNote?.noteFr) {
+ return {
+ kind: 'EXCEPTION',
+ text_fr: clamp(exceptionNote.noteFr),
+ text_en: clamp(exceptionNote.noteEn ?? ''),
+ };
+ }
+
+ // Otherwise a message someone took the trouble to write wins the space.
const message = selectActiveMessage(messages, now);
if (message) {
return {
diff --git a/lib/templates.ts b/lib/templates.ts
new file mode 100644
index 0000000..72c9788
--- /dev/null
+++ b/lib/templates.ts
@@ -0,0 +1,16 @@
+import type { Template } from '@/app/admin/components/TemplatePicker';
+import { prisma } from '@/lib/db';
+
+/**
+ * The saved phrases, in the order the picker should offer them.
+ *
+ * Loaded by every page that composes a message, which is the point: a phrase
+ * written once should be reachable everywhere without being retyped.
+ */
+export async function loadTemplates(): Promise {
+ const rows = await prisma.messageTemplate.findMany({
+ orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
+ select: { id: true, textFr: true },
+ });
+ return rows;
+}
diff --git a/prisma/migrations/20260923072416_message_templates/migration.sql b/prisma/migrations/20260923072416_message_templates/migration.sql
new file mode 100644
index 0000000..93af82e
--- /dev/null
+++ b/prisma/migrations/20260923072416_message_templates/migration.sql
@@ -0,0 +1,19 @@
+-- CreateTable
+CREATE TABLE "message_templates" (
+ "id" TEXT NOT NULL,
+ "textFr" TEXT NOT NULL,
+ "textEn" TEXT,
+ "translationStatus" "TranslationStatus" NOT NULL DEFAULT 'PENDING',
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "createdBy" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "message_templates_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "message_templates_sortOrder_idx" ON "message_templates"("sortOrder");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "message_templates_textFr_key" ON "message_templates"("textFr");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f1b6d05..64d31a8 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -149,6 +149,28 @@ model Message {
@@map("messages")
}
+/// A reusable phrase, written once and offered wherever a message is composed.
+///
+/// The shop writes the same handful of notices over and over — "fermeture
+/// exceptionnelle", "congés d'été", "ouverture retardée". Keeping them here
+/// means they are translated once, worded consistently, and correctable in one
+/// place rather than in whichever exception happens to carry a typo.
+model MessageTemplate {
+ id String @id @default(cuid())
+ textFr String
+ textEn String?
+ translationStatus TranslationStatus @default(PENDING)
+ /// Ordering in the picker; the most-used phrases belong at the top.
+ sortOrder Int @default(0)
+ createdBy String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([textFr])
+ @@index([sortOrder])
+ @@map("message_templates")
+}
+
/// Keyed by sha256 of the French source, so the same text is never paid for
/// twice. The model is part of the key: a model change invalidates nothing,
/// it simply misses and re-translates.
diff --git a/tests/integration/helpers.ts b/tests/integration/helpers.ts
index 0e74c0b..b9eea46 100644
--- a/tests/integration/helpers.ts
+++ b/tests/integration/helpers.ts
@@ -29,6 +29,7 @@ export async function resetDatabase(): Promise {
await prisma.vacationPeriod.deleteMany();
await prisma.publicHoliday.deleteMany();
await prisma.message.deleteMany();
+ await prisma.messageTemplate.deleteMany();
await prisma.weeklySchedule.deleteMany();
await prisma.settings.deleteMany();
diff --git a/tests/integration/vacations-action.test.ts b/tests/integration/vacations-action.test.ts
index f936aaf..963e870 100644
--- a/tests/integration/vacations-action.test.ts
+++ b/tests/integration/vacations-action.test.ts
@@ -28,7 +28,13 @@ describe.skipIf(!hasDatabase)('vacation actions', () => {
labelFr: ' Congés d’été ',
});
- expect(result).toEqual({ ok: true });
+ // The id comes back so the caller can translate the label without having
+ // to ask which row was "the last one".
+ expect(result).toMatchObject({ ok: true });
+ if (result.ok) {
+ expect(result.id).toEqual(expect.any(String));
+ }
+
const stored = await prisma.vacationPeriod.findMany();
expect(stored).toHaveLength(1);
// The label is trimmed: it goes straight onto the shop window.
@@ -66,7 +72,7 @@ describe.skipIf(!hasDatabase)('vacation actions', () => {
labelFr: 'Suite',
});
- expect(result).toEqual({ ok: true });
+ expect(result).toMatchObject({ ok: true });
expect(await prisma.vacationPeriod.count()).toBe(2);
});