Files
vliaudatandClaude Opus 5 ea945b50d6 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 21:18:41 +02:00

119 lines
3.8 KiB
TypeScript
Raw Permalink 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.
/**
* 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}`;
}