Adds backing state for Resistance and Erg control modes, which had no value to hold and so could never satisfy FR-4.3/FR-4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
117 lines
3.1 KiB
TypeScript
117 lines
3.1 KiB
TypeScript
/**
|
|
* 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<Screen>('connect');
|
|
frame = $state<RideFrame | null>(null);
|
|
ride = $state<RideState | null>(null);
|
|
devices = $state<DeviceList>({ scanning: false, devices: [] });
|
|
samples = $state<SampleProfile[]>([]);
|
|
toasts = $state<(Notice & { id: number })[]>([]);
|
|
lastAck = $state<(InputAck & { at: number }) | null>(null);
|
|
lastLap = $state<LapSummary | null>(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<void> {
|
|
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<T>(fn: () => Promise<T>): Promise<T | undefined> {
|
|
try {
|
|
return await fn();
|
|
} catch (e) {
|
|
this.toast({ level: 'error', message: String(e) });
|
|
return undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const app = new AppStore();
|