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}`; +}