Files
ridebus-board/src/useDeviceControl.ts
Zakhar Timoshenko 5c69f6691d feat: implement night mode functionality with UI updates and journey tracking
- 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.
2026-10-11 05:04:00 +03:00

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) };
}