- Added NightScreen component for displaying night mode information and alerts. - Introduced NextStops component to show upcoming stops during a journey. - Implemented logic for determining night mode activation based on schedule and user settings. - Created journey utility functions to manage onward journey data and stop information. - Updated tests to cover new night mode features and journey logic. - Added fixture for testing night mode scenarios with specific timestamps. - Removed QR code from night screen for a cleaner UI experience. - Enhanced admin settings to manage night mode configurations.
66 lines
5.3 KiB
TypeScript
66 lines
5.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import type { Config, Settings } from './types';
|
|
import { hydrateManagedState, isManagedState, MANAGED_KEY } from './lib/device.mjs';
|
|
export type ManagedState = { state: 'paired'; enabled: true; adminUrl: string; config: Config; device: { id: string; name: string; cityId: number; revision: number; settings: Settings } };
|
|
type Control = { state: string; enabled?: boolean; code?: string; expiresAt?: string; adminUrl?: string };
|
|
function cached(): ManagedState | null { try { const value = hydrateManagedState(JSON.parse(localStorage.getItem(MANAGED_KEY) || 'null')); return isManagedState(value) ? value : null; } catch { return null; } }
|
|
const info = () => ({ width: innerWidth, height: innerHeight, version: 'board-0.1.0' });
|
|
export function useDeviceControl() {
|
|
const [managed, setManaged] = useState<ManagedState | null>(cached), [control, setControl] = useState<Control>({ state: 'loading' });
|
|
const [failed, setFailed] = useState(false), [retry, setRetry] = useState(0);
|
|
const applied = useRef(0), current = useRef(managed);
|
|
current.current = managed;
|
|
const onApplied = useCallback((revision: number) => {
|
|
applied.current = revision;
|
|
try { localStorage.setItem(MANAGED_KEY, JSON.stringify(current.current)); } catch { /* Keep the current screen working even if persistent storage is unavailable. */ }
|
|
void fetch('/api/device/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...info(), appliedRevision: revision }) }).catch(() => {});
|
|
}, []);
|
|
useEffect(() => {
|
|
let stopped = false, timer: ReturnType<typeof setTimeout>, controller: AbortController | undefined, lastHeartbeat = 0;
|
|
let previousState = '', halted = false, running = false, firstRequest = true;
|
|
async function call(path: string, body?: object) {
|
|
controller = new AbortController(); const timeout = setTimeout(() => controller?.abort(), 12000);
|
|
try {
|
|
const response = await fetch(`/api/device/${path}`, { method: body ? 'POST' : 'GET', cache: 'no-store', signal: controller.signal, ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}) });
|
|
const value = await response.json(); if (!response.ok) throw new Error(value.error || 'devices_unavailable'); return value;
|
|
} finally { clearTimeout(timeout); }
|
|
}
|
|
async function load() {
|
|
if (stopped || halted || running) return;
|
|
running = true;
|
|
let delay = 15000;
|
|
try {
|
|
let value = await call(firstRequest && retry > 0 ? 'pair' : 'status', firstRequest && retry > 0 ? info() : undefined);
|
|
if (stopped) return;
|
|
firstRequest = false;
|
|
if (value.enabled === false) { halted = true; if (!stopped) { setControl({ state: 'standalone', enabled: false }); setManaged(null); setFailed(false); } return; }
|
|
if (value.state === 'unpaired' && previousState === 'pending') throw new Error('storage_unavailable');
|
|
if (['unpaired', 'expired'].includes(value.state)) { if (!stopped) { setManaged(null); applied.current = 0; localStorage.removeItem(MANAGED_KEY); } value = await call('pair', info()); }
|
|
if (value.state === 'paired' && !value.config) value = await call('status');
|
|
if (value.state === 'paired') {
|
|
value = hydrateManagedState(value);
|
|
if (!isManagedState(value)) throw new Error('invalid_settings');
|
|
if (!stopped) { setManaged(previous => previous && previous.device.id === value.device.id && previous.device.revision === value.device.revision ? previous : value); setControl(value); setFailed(false); }
|
|
if (Date.now() - lastHeartbeat >= 30000) { await call('heartbeat', { ...info(), appliedRevision: current.current?.device.id === value.device.id ? applied.current : 0 }); lastHeartbeat = Date.now(); }
|
|
} else if (value.state === 'pending') {
|
|
if (!/^\d{4}$/.test(value.code) || !Number.isFinite(Date.parse(value.expiresAt))) throw new Error('devices_unavailable');
|
|
delay = 3000; if (!stopped) { setControl(value); setManaged(null); setFailed(false); }
|
|
} else throw new Error('devices_unavailable');
|
|
previousState = value.state;
|
|
} catch (error) {
|
|
if (!stopped) {
|
|
const code = error instanceof Error ? error.message : 'devices_unavailable';
|
|
setFailed(true);
|
|
if (['device_revoked', 'device_unpaired'].includes(code)) { halted = true; setFailed(false); setManaged(null); applied.current = 0; try { localStorage.removeItem(MANAGED_KEY); } catch { /* No stored copy. */ } setControl({ state: 'revoked' }); return; }
|
|
if (code === 'storage_unavailable') halted = true;
|
|
if (!current.current) setControl(previous => ({ ...previous, state: code === 'storage_unavailable' ? 'storage-error' : previous.code ? 'pending' : 'error' }));
|
|
delay = 30000;
|
|
}
|
|
} finally { running = false; if (!stopped && !halted) timer = setTimeout(load, delay); }
|
|
}
|
|
void load(); const online = () => { clearTimeout(timer); void load(); }; window.addEventListener('online', online);
|
|
return () => { stopped = true; clearTimeout(timer); controller?.abort(); window.removeEventListener('online', online); };
|
|
}, [retry]);
|
|
return { managed, control, failed, onApplied, retry: () => setRetry(value => value + 1) };
|
|
}
|