diff --git a/app/admin/ScreenPreview.tsx b/app/admin/ScreenPreview.tsx index e281a87..bdb4db7 100644 --- a/app/admin/ScreenPreview.tsx +++ b/app/admin/ScreenPreview.tsx @@ -6,27 +6,90 @@ import { useState } from 'react'; * The panel, at a readable size. * * Rendered by the same code path that serves the device, so this is not an - * impression of the screen: it is the screen. `key` is bumped to defeat the - * browser cache when the data behind it has just changed. + * impression of the screen: it is the screen. + * + * With `timeTravel`, it also answers the question the shop actually asks + * before a holiday — "what will the window say while I'm away?" — without + * waiting until someone is standing in front of a locked door to find out. */ -export default function ScreenPreview({ className = '' }: { className?: string }) { +export default function ScreenPreview({ + className = '', + timeTravel = false, +}: { + className?: string; + timeTravel?: boolean; +}) { + const [at, setAt] = useState(''); const [nonce, setNonce] = useState(() => Date.now()); + const source = at + ? `/api/admin/preview?at=${encodeURIComponent(at)}&t=${nonce}` + : `/api/admin/preview?t=${nonce}`; + + function jumpTo(date: Date) { + setAt(toLocalInput(date)); + setNonce(Date.now()); + } + return (
{/* eslint-disable-next-line @next/next/no-img-element */} Aperçu de l’écran de la vitrine
+ + {timeTravel ? ( +
+ + +
+ jumpTo(tomorrowAt(10))} /> + jumpTo(nextWeekdayAt(6, 10))} /> + jumpTo(inDays(30, 10))} /> + {at ? ( + + ) : null} +
+
+ ) : null} +
- 800 × 480, noir et blanc — rendu réellement envoyé à l’écran. + + {at + ? 'Projection : l’écran tel qu’il sera à cette date, avec les horaires et fermetures enregistrés.' + : '800 × 480, noir et blanc — rendu réellement envoyé à l’écran.'} +
); } + +function Jump({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} + +/** `datetime-local` speaks local wall-clock time with no zone, so build one. */ +function toLocalInput(date: Date): string { + const pad = (value: number) => value.toString().padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad( + date.getHours(), + )}:${pad(date.getMinutes())}`; +} + +function tomorrowAt(hour: number): Date { + return inDays(1, hour); +} + +function inDays(days: number, hour: number): Date { + const date = new Date(); + date.setDate(date.getDate() + days); + date.setHours(hour, 0, 0, 0); + return date; +} + +/** The next occurrence of a weekday, 0 = Sunday. */ +function nextWeekdayAt(weekday: number, hour: number): Date { + const date = new Date(); + const ahead = (weekday - date.getDay() + 7) % 7 || 7; + return inDays(ahead, hour); +} diff --git a/app/admin/components/TemplatePicker.tsx b/app/admin/components/TemplatePicker.tsx new file mode 100644 index 0000000..53d6d7d --- /dev/null +++ b/app/admin/components/TemplatePicker.tsx @@ -0,0 +1,57 @@ +'use client'; + +/** + * Offers the shop's saved phrases wherever a message is composed. + * + * A picker rather than a free-text-only field, because the same handful of + * notices get written over and over: choosing one keeps the wording + * consistent and — more importantly — reuses a phrase that has already been + * translated, instead of paying for a fresh translation of the same sentence. + * + * The chosen text lands in the field and stays editable. A template is a + * starting point, not a constraint. + */ +export type Template = { id: string; textFr: string }; + +export default function TemplatePicker({ + templates, + onPick, + disabled = false, + label = 'Modèle', +}: { + templates: Template[]; + onPick: (textFr: string) => void; + disabled?: boolean; + label?: string; +}) { + if (templates.length === 0) { + return null; + } + + return ( + + ); +} diff --git a/app/admin/exceptions/ExceptionsManager.tsx b/app/admin/exceptions/ExceptionsManager.tsx index 71f5548..7022a83 100644 --- a/app/admin/exceptions/ExceptionsManager.tsx +++ b/app/admin/exceptions/ExceptionsManager.tsx @@ -2,10 +2,18 @@ import { useState, useTransition } from 'react'; +import TemplatePicker, { type Template } from '@/app/admin/components/TemplatePicker'; import { earlyClosing, lateOpening, usualBounds } from '@/lib/schedule/exceptions'; import type { ExceptionKind, Slot } from '@/lib/schedule/types'; -import { deleteException, saveException, saveExceptionRange } from './actions'; +import { MESSAGE_MAX_CHARS } from '@/lib/screen/contract'; + +import { + deleteException, + saveException, + saveExceptionRange, + translateExceptionNote, +} from './actions'; export type UpcomingDay = { date: string; @@ -34,6 +42,7 @@ type Props = { today: string; days: SelectableDay[]; upcoming: UpcomingDay[]; + templates: Template[]; }; const KIND_LABELS: Record = { @@ -42,7 +51,7 @@ const KIND_LABELS: Record = { VACATION: { text: 'vacances', className: 'bg-[var(--surface-muted)] text-[var(--ink-muted)]' }, }; -export default function ExceptionsManager({ canEdit, today, days, upcoming }: Props) { +export default function ExceptionsManager({ canEdit, today, days, upcoming, templates }: Props) { const [pending, startTransition] = useTransition(); const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); @@ -69,9 +78,11 @@ export default function ExceptionsManager({ canEdit, today, days, upcoming }: Pr

) : null} - {canEdit ? : null} + {canEdit ? ( + + ) : null} - {canEdit ? : null} + {canEdit ? : null}

À venir

@@ -135,35 +146,49 @@ export default function ExceptionsManager({ canEdit, today, days, upcoming }: Pr function QuickPanel({ today, days, + templates, pending, run, }: { today: string; days: SelectableDay[]; + templates: Template[]; pending: boolean; run: (work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) => void; }) { const [date, setDate] = useState(today); + const [note, setNote] = useState(''); const day = days.find((candidate) => candidate.date === date) ?? days[0]; const isToday = day?.date === today; const isOpenThatDay = (day?.usualSlots.length ?? 0) > 0; - function apply(slots: Slot[], note: string, success: string) { + function apply(slots: Slot[], success: string) { if (!day) { return; } - run( - () => - saveException({ - date: day.date, - isClosed: slots.length === 0, - slots, - reason: 'TEMPORARY', - noteFr: note, - }), - success, - ); + const text = note.trim(); + + run(async () => { + const saved = await saveException({ + date: day.date, + isClosed: slots.length === 0, + slots, + reason: 'TEMPORARY', + // Empty means no notice: the message now drives what the panel shows, + // so it has to be something the shop chose to say rather than a + // sentence the form filled in on its behalf. + noteFr: text || null, + }); + + if (saved.ok && text) { + // Separate from the save: a message is worth having, but not at the + // price of making the two-click path wait for the translation + // service. A failure simply leaves the French alone on screen. + await translateExceptionNote(day.date); + } + return saved; + }, success); } const when = isToday ? 'aujourd’hui' : `le ${day?.label.toLowerCase() ?? ''}`; @@ -196,13 +221,29 @@ function QuickPanel({ {day?.isException ? ` Actuellement : ${day.effectiveText}.` : ''}

+
+ + +
+

+ Affiché sur l’écran tant que la boutique est fermée ce jour-là, et retiré dès qu’elle ouvre. +

+
+
+ ) : 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 ? ( +
    + + + + +
    + ) : 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); });