/** * Client-side view state. Everything here is either received from Rust or is * purely presentational (which screen is showing, which toast is up). */ import { api, subscribe } from './bridge'; import { History } from './history'; import type { DeviceList, InputAck, LapSummary, Notice, RideFrame, RideState, SampleProfile, } from './types'; export type Screen = 'connect' | 'ride'; let toastSeq = 0; class AppStore { screen = $state('connect'); frame = $state(null); ride = $state(null); devices = $state({ scanning: false, devices: [] }); samples = $state([]); toasts = $state<(Notice & { id: number })[]>([]); lastAck = $state<(InputAck & { at: number }) | null>(null); lastLap = $state(null); showHelp = $state(false); showProfiles = $state(false); /** Bumped on every snapshot so charts know to redraw without deep tracking. */ revision = $state(0); /** Bounded chart history — raw power, rolling power. */ readonly power = new History(2); /** Bounded chart history — commanded gradient. */ readonly grade = new History(1); private lastElapsed = -1; async init(): Promise { const [ride, devices, samples] = await Promise.all([ api.rideState(), api.deviceList(), api.sampleProfiles(), ]); this.ride = ride; this.devices = devices; this.samples = samples; await subscribe({ onFrame: (f) => this.onFrame(f), onRideState: (s) => { this.ride = s; }, onDevices: (d) => { this.devices = d; }, onLap: (l) => { this.lastLap = l; }, onNotice: (n) => this.toast(n), onInputAck: (a) => { this.lastAck = { ...a, at: performance.now() }; }, }); } private onFrame(f: RideFrame): void { this.frame = f; const t = f.snapshot.elapsed_ms / 1000; // The ride clock only advances while running; a paused ride should not // stack duplicate points onto the charts. if (t > this.lastElapsed) { this.lastElapsed = t; this.power.push(t, [f.snapshot.telemetry.power_w ?? 0, f.derived.rollingPowerW]); this.grade.push(t, [f.snapshot.gradient_pct]); } else if (t < this.lastElapsed) { this.clearHistory(); this.lastElapsed = t; } this.revision++; } clearHistory(): void { this.power.clear(); this.grade.clear(); this.lastElapsed = -1; } toast(n: Notice): void { const entry = { ...n, id: ++toastSeq }; this.toasts = [...this.toasts, entry]; const ttl = n.level === 'error' ? 8000 : 4000; setTimeout(() => { this.toasts = this.toasts.filter((t) => t.id !== entry.id); }, ttl); } dismiss(id: number): void { this.toasts = this.toasts.filter((t) => t.id !== id); } /** Run a command and surface any rejection as a toast rather than silently. */ async run(fn: () => Promise): Promise { try { return await fn(); } catch (e) { this.toast({ level: 'error', message: String(e) }); return undefined; } } } export const app = new AppStore();