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
This commit is contained in:
2026-09-20 21:18:41 +02:00
co-authored by Claude Opus 5
parent f21268a9f6
commit ea945b50d6
3 changed files with 408 additions and 53 deletions
+109
View File
@@ -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> = {}): 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');
});
});