From ea945b50d6e4e411c622851055d84e8f2d21e5cf Mon Sep 17 00:00:00 2001 From: vl Date: Sun, 20 Sep 2026 21:18:41 +0200 Subject: [PATCH] feat: add the dashboard Current state at the top, the panel as it stands beside what is known about the device, the next seven days resolved with their exceptions, and alerts when something needs attention. There is no "push now" button, because in BYOS there is nothing to push: the panel sleeps on battery and fetches when it wakes. Pretending otherwise would be the most misleading control on the page. It says so in plain words instead, and shows when the device is next due back. Everything about the device is inference from one timestamp and one interval, so it lives in a tested module rather than in a template. A panel is only called overdue once it has missed its slot by half its interval again: firmware wake-ups drift, and an alert that cries wolf every cycle is an alert nobody reads. Battery falls back to a voltage estimate, labelled as an estimate, because "about a third left" is the only thing anyone acts on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- app/admin/page.tsx | 234 +++++++++++++++++++++++++++++--------- lib/device/health.test.ts | 109 ++++++++++++++++++ lib/device/health.ts | 118 +++++++++++++++++++ 3 files changed, 408 insertions(+), 53 deletions(-) create mode 100644 lib/device/health.test.ts create mode 100644 lib/device/health.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 3a1c88c..615a755 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,70 +1,198 @@ +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 session = await auth(); - const user = session?.user; - const isAdmin = user?.role === 'admin'; + 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 ( -
+

Tableau de bord

-

- Connecté en tant que {user?.email} ({isAdmin ? 'administrateur' : 'lecture seule'}). -

- {!isAdmin ? : null} + {alerts.length > 0 ? ( +
    + {alerts.map((alert) => ( +
  • + {alert.href ? ( + + {alert.text} + + ) : ( + alert.text + )} +
  • + ))} +
+ ) : null} + +
+
+ + {isOpen ? 'Ouvert' : 'Fermé'} + + + {status.today.slots.length > 0 ? formatHoursText(status.today.slots) : 'Fermé aujourd’hui'} + + + {formatNextChangeFr( + status.nextChangeAt + ? { kind: isOpen ? 'CLOSES' : 'OPENS', ...status.nextChangeAt } + : null, + today, + ) || 'Aucune réouverture programmée'} + +
+

{formatDateFr(today)}

+
+ +
+
+

L’écran en ce moment

+ +
+ +
+

Appareil

+ {device && health ? ( +
+ {device.friendlyId} + + {device.lastSeenAt ? humaniseSeconds(secondsSince(device.lastSeenAt, now)) : 'jamais'} + + + {health.secondsUntilWake === null + ? 'inconnu' + : health.secondsUntilWake >= 0 + ? humaniseSeconds(health.secondsUntilWake) + : 'attendu, en retard'} + + + {health.batteryPercent === null + ? 'inconnue' + : `environ ${health.batteryPercent}%${device.percentCharged === null ? ' (estimée)' : ''}`} + + + {health.signal === null + ? 'inconnu' + : { good: 'bon', fair: 'moyen', poor: 'faible' }[health.signal]} + + {device.fwVersion ?? 'inconnu'} +
+ ) : ( +

Aucun écran appairé.

+ )} + +

+ L’écran fonctionne sur batterie et dort entre deux réveils : une modification + n’apparaît qu’au réveil suivant. Rien ne peut lui être poussé. +

+
+
+ +
+

Les sept prochains jours

+
    + {week.map((day, index) => ( +
  • + + {formatDateFr(day.date)} + {index === 0 ? ' (aujourd’hui)' : ''} + + + {day.slots.length > 0 ? formatHoursText(day.slots) : 'Fermé'} + + {day.isException ? ( + + {day.noteFr ?? 'exception'} + + ) : null} +
  • + ))} +
+

+ + Modifier les horaires d’aujourd’hui + +

+
+ + {session?.user?.role !== 'admin' ? ( +

+ Compte en lecture seule : aucune modification n’est possible. +

+ ) : null}
); } -/** - * A read-only account has exactly two causes, fixed in completely different - * places: either the identity provider sent no groups at all, or it sent - * groups that do not include the one that grants write access. Saying which - * turns a support conversation into a thirty-second fix. - */ -function ReadOnlyExplanation({ groups, expected }: { groups: string[]; expected: string }) { - const claimMissing = groups.length === 0; +/** 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 ( -
-

Pourquoi ce compte est-il en lecture seule ?

- -
-
-
Groupe donnant l’accès en écriture
-
{expected || '(non configuré)'}
-
-
-
Groupes reçus d’Authentik
-
- {claimMissing ? 'aucun — le claim « groups » est absent' : groups.join(', ')} -
-
-
- -

- {claimMissing ? ( - <> - Authentik n’envoie aucun groupe. Dans le provider OAuth2/OpenID, vérifiez que le scope{' '} - profile est bien sélectionné et que{' '} - Include claims in id_token est activé. - - ) : ( - <> - Authentik envoie bien des groupes, mais pas celui attendu. Ajoutez ce compte au groupe{' '} - {expected}, ou corrigez{' '} - AUTHENTIK_ADMIN_GROUP pour qu’il corresponde à l’un - des groupes ci-dessus. - - )} -

- -

- Le rôle est fixé à la connexion : après correction, déconnectez-vous et reconnectez-vous. -

-
+
+
{label}
+
{children}
+
); } diff --git a/lib/device/health.test.ts b/lib/device/health.test.ts new file mode 100644 index 0000000..31be8b2 --- /dev/null +++ b/lib/device/health.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { deviceHealth, humaniseSeconds, OVERDUE_FACTOR, type DeviceSnapshot } from './health'; + +const NOW = new Date('2026-09-22T12:00:00Z'); + +function snapshot(overrides: Partial = {}): DeviceSnapshot { + return { + lastSeenAt: new Date('2026-09-22T11:50:00Z'), + lastRefreshRate: 600, + batteryVoltage: null, + percentCharged: null, + rssi: null, + ...overrides, + }; +} + +describe('deviceHealth', () => { + it('reports a device that has never called', () => { + const health = deviceHealth(snapshot({ lastSeenAt: null }), NOW); + expect(health.neverSeen).toBe(true); + expect(health.nextWakeAt).toBeNull(); + expect(health.isOverdue).toBe(false); + }); + + it('predicts the next wake from the interval the device was given', () => { + const health = deviceHealth(snapshot(), NOW); + expect(health.nextWakeAt?.toISOString()).toBe('2026-09-22T12:00:00.000Z'); + expect(health.secondsUntilWake).toBe(0); + }); + + it('counts down to the next wake', () => { + const health = deviceHealth(snapshot({ lastSeenAt: new Date('2026-09-22T11:55:00Z') }), NOW); + expect(health.secondsUntilWake).toBe(300); + expect(health.isOverdue).toBe(false); + }); + + it('tolerates a late wake before calling it overdue', () => { + // Firmware wake-ups drift; an alert that cries wolf every cycle is unread. + const justLate = new Date(NOW.getTime() - 600 * 1.2 * 1000); + expect(deviceHealth(snapshot({ lastSeenAt: justLate }), NOW).isOverdue).toBe(false); + }); + + it('calls a device overdue once it misses by half its interval again', () => { + const wellLate = new Date(NOW.getTime() - 600 * (OVERDUE_FACTOR + 0.1) * 1000); + const health = deviceHealth(snapshot({ lastSeenAt: wellLate }), NOW); + expect(health.isOverdue).toBe(true); + expect(health.secondsUntilWake).toBeLessThan(0); + }); + + it('says nothing about lateness with no interval to go on', () => { + const health = deviceHealth(snapshot({ lastRefreshRate: null }), NOW); + expect(health.nextWakeAt).toBeNull(); + expect(health.isOverdue).toBe(false); + }); + + it('ignores a nonsensical interval', () => { + expect(deviceHealth(snapshot({ lastRefreshRate: 0 }), NOW).nextWakeAt).toBeNull(); + }); + + it('prefers the reported battery percentage to the voltage', () => { + const health = deviceHealth(snapshot({ percentCharged: 42, batteryVoltage: 4.1 }), NOW); + expect(health.batteryPercent).toBe(42); + }); + + it('derives a rough percentage from the voltage when that is all there is', () => { + expect(deviceHealth(snapshot({ batteryVoltage: 4.1 }), NOW).batteryPercent).toBe(100); + expect(deviceHealth(snapshot({ batteryVoltage: 3.3 }), NOW).batteryPercent).toBe(0); + expect(deviceHealth(snapshot({ batteryVoltage: 3.7 }), NOW).batteryPercent).toBe(50); + }); + + it('clamps a voltage outside the usable range', () => { + expect(deviceHealth(snapshot({ batteryVoltage: 4.5 }), NOW).batteryPercent).toBe(100); + expect(deviceHealth(snapshot({ batteryVoltage: 2.9 }), NOW).batteryPercent).toBe(0); + }); + + it('reports nothing about a battery it was never told about', () => { + expect(deviceHealth(snapshot(), NOW).batteryPercent).toBeNull(); + }); + + it('grades the wifi signal', () => { + expect(deviceHealth(snapshot({ rssi: -55 }), NOW).signal).toBe('good'); + expect(deviceHealth(snapshot({ rssi: -72 }), NOW).signal).toBe('fair'); + expect(deviceHealth(snapshot({ rssi: -88 }), NOW).signal).toBe('poor'); + expect(deviceHealth(snapshot(), NOW).signal).toBeNull(); + }); +}); + +describe('humaniseSeconds', () => { + it('looks forward and backward', () => { + expect(humaniseSeconds(300)).toBe('dans 5 minutes'); + expect(humaniseSeconds(-300)).toBe('il y a 5 minutes'); + }); + + it('shortens anything under a minute', () => { + expect(humaniseSeconds(30)).toBe('dans moins d’une minute'); + expect(humaniseSeconds(0)).toBe('dans moins d’une minute'); + }); + + it('switches unit as the distance grows', () => { + expect(humaniseSeconds(3600)).toBe('dans 1 heure'); + expect(humaniseSeconds(7200)).toBe('dans 2 heures'); + expect(humaniseSeconds(172_800)).toBe('dans 2 jours'); + }); + + it('keeps the singular', () => { + expect(humaniseSeconds(60)).toBe('dans 1 minute'); + }); +}); diff --git a/lib/device/health.ts b/lib/device/health.ts new file mode 100644 index 0000000..d57fa92 --- /dev/null +++ b/lib/device/health.ts @@ -0,0 +1,118 @@ +/** + * What can be said about a panel from its last visit. + * + * In BYOS nothing can be pushed and nothing can be asked: the device wakes, + * fetches, and sleeps. Everything below is therefore inference from one + * timestamp and one interval — which is exactly why it is worth writing down + * and testing, rather than improvising in a template. + */ + +export type DeviceSnapshot = { + lastSeenAt: Date | null; + lastRefreshRate: number | null; + batteryVoltage: number | null; + percentCharged: number | null; + rssi: number | null; +}; + +export type DeviceHealth = { + /** Never contacted us at all. */ + neverSeen: boolean; + /** When it is due back, from its last visit plus its own interval. */ + nextWakeAt: Date | null; + /** Seconds until then; negative once it is late. */ + secondsUntilWake: number | null; + /** + * Late by enough that something is probably wrong rather than merely + * imprecise. + */ + isOverdue: boolean; + /** 0-100 when the device reports it, else derived from the voltage. */ + batteryPercent: number | null; + signal: 'good' | 'fair' | 'poor' | null; +}; + +/** + * A panel is only called overdue once it has missed its slot by half again as + * long as its own interval. Firmware wake-ups drift by seconds, and an alert + * that cries wolf on every cycle is an alert nobody reads. + */ +export const OVERDUE_FACTOR = 1.5; + +/** Li-ion, loaded: below this the panel has days rather than weeks left. */ +const EMPTY_VOLTS = 3.3; +const FULL_VOLTS = 4.1; + +export function deviceHealth(device: DeviceSnapshot, now: Date): DeviceHealth { + if (!device.lastSeenAt) { + return { + neverSeen: true, + nextWakeAt: null, + secondsUntilWake: null, + isOverdue: false, + batteryPercent: batteryPercent(device), + signal: signalQuality(device.rssi), + }; + } + + const interval = device.lastRefreshRate && device.lastRefreshRate > 0 ? device.lastRefreshRate : null; + const nextWakeAt = interval ? new Date(device.lastSeenAt.getTime() + interval * 1000) : null; + const secondsUntilWake = nextWakeAt + ? Math.round((nextWakeAt.getTime() - now.getTime()) / 1000) + : null; + + const overdueAfter = interval + ? device.lastSeenAt.getTime() + interval * OVERDUE_FACTOR * 1000 + : null; + + return { + neverSeen: false, + nextWakeAt, + secondsUntilWake, + isOverdue: overdueAfter !== null && now.getTime() > overdueAfter, + batteryPercent: batteryPercent(device), + signal: signalQuality(device.rssi), + }; +} + +function batteryPercent(device: DeviceSnapshot): number | null { + if (device.percentCharged !== null) { + return clamp(device.percentCharged); + } + if (device.batteryVoltage === null) { + return null; + } + // Rough, and labelled as such in the UI: a discharge curve is not linear, + // but "about a third left" is the only thing anyone acts on. + const ratio = (device.batteryVoltage - EMPTY_VOLTS) / (FULL_VOLTS - EMPTY_VOLTS); + return clamp(Math.round(ratio * 100)); +} + +function signalQuality(rssi: number | null): DeviceHealth['signal'] { + if (rssi === null) { + return null; + } + if (rssi >= -67) { + return 'good'; + } + return rssi >= -80 ? 'fair' : 'poor'; +} + +function clamp(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +/** "dans 8 minutes", "il y a 2 heures" — the only two shapes the page needs. */ +export function humaniseSeconds(seconds: number): string { + const absolute = Math.abs(seconds); + const value = + absolute < 60 + ? 'moins d’une minute' + : absolute < 3600 + ? `${Math.round(absolute / 60)} minute${Math.round(absolute / 60) > 1 ? 's' : ''}` + : absolute < 86_400 + ? `${Math.round(absolute / 3600)} heure${Math.round(absolute / 3600) > 1 ? 's' : ''}` + : `${Math.round(absolute / 86_400)} jour${Math.round(absolute / 86_400) > 1 ? 's' : ''}`; + + return seconds >= 0 ? `dans ${value}` : `il y a ${value}`; +}