Files
ita-ito-horaires/app/admin/page.tsx
T
vliaudatandClaude Opus 5 446021cd75 test: add the end-to-end suite, on desktop and on a phone
Twenty-six tests across two viewports, covering what the spec asks for:
changing today's hours reaches the panel, a message survives the
translation service being unavailable, a closure period closes the days
it covers — plus the authorisation paths and the sign-out regression.

They run against the standalone build served the way the container
serves it, not `next start`, which refuses to work with standalone
output anyway. The suite therefore exercises the artifact that ships
rather than a second arrangement that could drift from it.

Sign-in mints the session cookie Auth.js would have issued rather than
driving Authentik. What is under test is the application's behaviour for
a given role; the handshake itself is verified against the live provider
separately, and standing up an identity provider per run would trade a
lot of machinery for coverage of somebody else's code. The secret lives
in one module imported by both the config and the fixtures — when it
differed, every signed-in test failed at once while looking like an
authorisation bug.

Database access goes through plain SQL rather than the Prisma client,
whose generated module format Playwright's loader and Next's bundler
disagree about. That traded one problem for a subtler one: node-postgres
parses a DATE column into a local-midnight Date, so reading it back
shifted the day at UTC+2. Dates are read as text now.

The mobile profile runs on Chromium: WebKit needs system packages only
root can install, and a suite nobody can run locally is a suite nobody
runs. The config says how to switch to the real engine.

Two real defects surfaced, both found by the tests rather than by
reading. The seven "Ouvert" checkboxes on the hours page were
indistinguishable to a screen reader; each now names its day. And on a
phone the signed-in address appeared nowhere at all — the header hides
it to save room — so nobody could tell which account was about to sign
an audit entry on a device the shop shares. It is on the dashboard now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 23:13:58 +02:00

201 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/db';
import { deviceHealth, humaniseSeconds } from '@/lib/device/health';
import { toCivilDate } from '@/lib/schedule/civil';
import { loadScheduleContext, loadSettings } from '@/lib/schedule/context';
import { formatDateFr, formatHoursText, formatNextChangeFr } from '@/lib/schedule/format';
import { getCurrentStatus, resolveWeek } from '@/lib/schedule/resolver';
import ScreenPreview from './ScreenPreview';
export const metadata = { title: 'Tableau de bord — ITA ITO' };
export default async function AdminHome() {
const now = new Date();
const [session, settings] = await Promise.all([auth(), loadSettings()]);
const context = await loadScheduleContext(now, settings);
const status = getCurrentStatus(now, context);
const week = resolveWeek(now, context);
const today = toCivilDate(now, settings.timezone);
const [device, failedTranslations, holidaySync] = await Promise.all([
prisma.device.findFirst({ where: { isActive: true }, orderBy: { lastSeenAt: 'desc' } }),
prisma.message.count({ where: { translationStatus: 'ERROR' } }),
prisma.syncState.findUnique({ where: { key: 'holidays' } }),
]);
const health = device ? deviceHealth(device, now) : null;
const isOpen = status.status === 'OPEN' || status.status === 'CLOSING_SOON';
const alerts: { text: string; href?: string }[] = [];
if (!device) {
alerts.push({ text: 'Aucun écran appairé. Suivez la procédure d’appairage du README.' });
} else if (health?.neverSeen) {
alerts.push({ text: 'L’écran est enregistré mais ne s’est jamais manifesté.' });
} else if (health?.isOverdue) {
const silentFor = humaniseSeconds(secondsSince(device.lastSeenAt, now));
alerts.push({
text: `L’écran n’a pas donné signe de vie ${silentFor}. Vérifiez la batterie et le Wi-Fi.`,
});
}
if (failedTranslations > 0) {
alerts.push({
text: `${failedTranslations} message${failedTranslations > 1 ? 's' : ''} sans traduction anglaise.`,
href: '/admin/messages',
});
}
if (holidaySync?.lastErrorAt) {
alerts.push({
text: `La synchronisation des jours fériés a échoué : ${holidaySync.lastError ?? 'raison inconnue'}.`,
href: '/admin/feries',
});
}
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<h1 className="text-2xl">Tableau de bord</h1>
<p className="mt-1 text-sm text-[var(--ink-muted)]">
{/* The header hides the address on a narrow screen to save room, so it
belongs here: every change is signed with it in the audit trail,
and several people share the shop's phone. */}
Connecté en tant que {session?.user?.email}
{session?.user?.role === 'admin' ? '' : ' — lecture seule'}.
</p>
{alerts.length > 0 ? (
<ul className="mt-4 space-y-2">
{alerts.map((alert) => (
<li
key={alert.text}
className="rounded-[var(--radius-md)] border border-[var(--danger)] bg-[var(--danger-tint)] px-4 py-3 text-sm text-[var(--danger)]"
>
{alert.href ? (
<Link href={alert.href} className="underline-offset-2 hover:underline">
{alert.text}
</Link>
) : (
alert.text
)}
</li>
))}
</ul>
) : null}
<section className="mt-6 rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] p-5">
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
<span
className={`rounded-[var(--radius-sm)] px-3 py-1 text-lg font-medium ${
isOpen
? 'bg-[var(--success-tint)] text-[var(--success)]'
: 'bg-[var(--surface-muted)] text-[var(--ink-muted)]'
}`}
>
{isOpen ? 'Ouvert' : 'Fermé'}
</span>
<span className="text-lg">
{status.today.slots.length > 0 ? formatHoursText(status.today.slots) : 'Fermé aujourd’hui'}
</span>
<span className="text-[var(--ink-muted)]">
{formatNextChangeFr(
status.nextChangeAt
? { kind: isOpen ? 'CLOSES' : 'OPENS', ...status.nextChangeAt }
: null,
today,
) || 'Aucune réouverture programmée'}
</span>
</div>
<p className="mt-2 text-sm text-[var(--ink-muted)]">{formatDateFr(today)}</p>
</section>
<div className="mt-8 grid gap-8 lg:grid-cols-[1.2fr_1fr]">
<section>
<h2 className="text-lg">L’écran en ce moment</h2>
<ScreenPreview className="mt-3" />
</section>
<section>
<h2 className="text-lg">Appareil</h2>
{device && health ? (
<dl className="mt-3 space-y-2 text-sm">
<Row label="Identifiant">{device.friendlyId}</Row>
<Row label="Dernier contact">
{device.lastSeenAt ? humaniseSeconds(secondsSince(device.lastSeenAt, now)) : 'jamais'}
</Row>
<Row label="Prochain réveil">
{health.secondsUntilWake === null
? 'inconnu'
: health.secondsUntilWake >= 0
? humaniseSeconds(health.secondsUntilWake)
: 'attendu, en retard'}
</Row>
<Row label="Batterie">
{health.batteryPercent === null
? 'inconnue'
: `environ ${health.batteryPercent}%${device.percentCharged === null ? ' (estimée)' : ''}`}
</Row>
<Row label="Wi-Fi">
{health.signal === null
? 'inconnu'
: { good: 'bon', fair: 'moyen', poor: 'faible' }[health.signal]}
</Row>
<Row label="Firmware">{device.fwVersion ?? 'inconnu'}</Row>
</dl>
) : (
<p className="mt-3 text-sm text-[var(--ink-muted)]">Aucun écran appairé.</p>
)}
<p className="mt-4 rounded-[var(--radius-md)] bg-[var(--surface-muted)] px-3 py-2 text-sm text-[var(--ink-muted)]">
L’écran fonctionne sur batterie et dort entre deux réveils&nbsp;: une modification
n’apparaît qu’au réveil suivant. Rien ne peut lui être poussé.
</p>
</section>
</div>
<section className="mt-10">
<h2 className="text-lg">Les sept prochains jours</h2>
<ul className="mt-3 divide-y divide-[var(--line)] overflow-hidden rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)]">
{week.map((day, index) => (
<li key={day.date} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-2.5">
<span className={`min-w-44 ${index === 0 ? 'font-medium' : ''}`}>
{formatDateFr(day.date)}
{index === 0 ? ' (aujourd’hui)' : ''}
</span>
<span className={day.isOpen ? '' : 'text-[var(--ink-muted)]'}>
{day.slots.length > 0 ? formatHoursText(day.slots) : 'Fermé'}
</span>
{day.isException ? (
<span className="rounded-[var(--radius-sm)] bg-[var(--accent-tint)] px-2 py-0.5 text-xs text-[var(--accent-ink)]">
{day.noteFr ?? 'exception'}
</span>
) : null}
</li>
))}
</ul>
<p className="mt-2 text-sm text-[var(--ink-muted)]">
<Link href="/admin/exceptions" className="underline-offset-2 hover:underline">
Modifier les horaires d’aujourd’hui
</Link>
</p>
</section>
</main>
);
}
/** Negative, so `humaniseSeconds` phrases it as "il y a …". */
function secondsSince(moment: Date | null, now: Date): number {
return moment ? Math.round((moment.getTime() - now.getTime()) / 1000) : 0;
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex justify-between gap-4 border-b border-[var(--line)] pb-2">
<dt className="text-[var(--ink-muted)]">{label}</dt>
<dd className="text-right">{children}</dd>
</div>
);
}