Remember the rider, not only the hardware

`RiderConfig` lived in `RideInputs` and nowhere else, and nothing in the
UI ever called `set_rider_config`. So every ride was ridden as the
struct's own default — a 105 kg rider on an 8 kg bike — with no way to
say otherwise short of editing the source. Mass is not a preference: it
sets the speed a given power produces, the ETA that follows from it, the
calorie estimate, and how a 6% ramp feels. FR-7.4 is a Must, and a
command the UI never calls does not satisfy it.

- settings.rs persists rider config, safety limits and display
  preferences to app_data_dir()/settings.json, written atomically and
  read back before the first tick, so no snapshot is ever computed
  against the default. Advisory like known.rs: an unreadable file costs
  the rider their setup, never their ride.
- A stored file is refused *whole* if it fails the same checks the
  commands apply. It may predate a tightened bound or have been edited
  by hand, and a zero mass reaching the engine divides by itself on the
  next tick.
- The commands validate with instructions rather than codes — "CdA must
  be between 0.1 and 1.5 m² — a road position is about 0.32" — because
  this is now a form a rider fills in, not a struct only I ever touched.
- Preferences (FTP, maximum heart rate, units) are Tauri-side, not in
  `RiderConfig`. None of it reaches the physics, and crates/core is the
  frozen contract the engine and the FIT writer share. Zero is a real
  answer for both references and means "no zones", not "unset and
  guessed at".
- SettingsScreen commits on field-exit and reseats every input from what
  Rust returned, so a rejected value can never sit on screen looking
  accepted. Weight, FTP and units are on top; the eight settings with a
  defensible default are folded away.
- Reachable on `,` from any screen, returning to whichever screen opened
  it. Setup swallows the ride controls while it is up — a stray arrow
  key while reading the form must not trim the gradient of a ride
  happening behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 20:14:46 +02:00
co-authored by Claude Opus 5
parent 7497a5d602
commit 4269c5a446
10 changed files with 954 additions and 8 deletions
+41 -6
View File
@@ -441,10 +441,19 @@ pub fn set_rider_config(
state: State<'_, AppState>, state: State<'_, AppState>,
config: RiderConfig, config: RiderConfig,
) -> Cmd<RiderConfig> { ) -> Cmd<RiderConfig> {
if config.rider_kg <= 20.0 || config.bike_kg <= 0.0 { // Every bound refused here is one that makes the engine produce nonsense
return Err("Rider and bike mass must be positive and realistic".into()); // rather than something merely unusual, and each refusal says what a
// workable value looks like — this is now a form a rider fills in, not a
// struct only the developer ever touched.
crate::settings::validate_rider(&config)?;
{
let mut inner = state.lock();
inner.inputs.rider = config;
// Written down immediately. A setup that lasts only as long as the
// process is what left every rider on the 105 kg default (FR-7.4).
let inner = &*inner;
inner.settings.save(&inner.inputs.rider, &inner.inputs.limits);
} }
state.lock().inputs.rider = config;
emit_ride_state(&app); emit_ride_state(&app);
Ok(config) Ok(config)
} }
@@ -460,14 +469,40 @@ pub fn set_safety_limits(
state: State<'_, AppState>, state: State<'_, AppState>,
limits: SafetyLimits, limits: SafetyLimits,
) -> Cmd<SafetyLimits> { ) -> Cmd<SafetyLimits> {
if limits.min_gradient_pct >= limits.max_gradient_pct { crate::settings::validate_limits(&limits)?;
return Err("Gradient limits are inverted".into()); {
let mut inner = state.lock();
inner.inputs.limits = limits;
let inner = &*inner;
inner.settings.save(&inner.inputs.rider, &inner.inputs.limits);
} }
state.lock().inputs.limits = limits;
emit_ride_state(&app); emit_ride_state(&app);
Ok(limits) Ok(limits)
} }
/// Display preferences: FTP, maximum heart rate, units (§4.3).
///
/// Separate from [`rider_config`] because nothing here reaches the physics —
/// these decide how a number is drawn, not what it is. They are stored in the
/// same file because that is where the rider expects to find them.
#[tauri::command]
pub fn preferences(state: State<'_, AppState>) -> crate::settings::Preferences {
state.lock().settings.prefs
}
#[tauri::command]
pub fn set_preferences(
state: State<'_, AppState>,
prefs: crate::settings::Preferences,
) -> Cmd<crate::settings::Preferences> {
crate::settings::validate_prefs(&prefs)?;
let mut inner = state.lock();
inner.settings.prefs = prefs;
let inner = &*inner;
inner.settings.save(&inner.inputs.rider, &inner.inputs.limits);
Ok(prefs)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Profiles (§5.5, §5.6) // Profiles (§5.5, §5.6)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+17
View File
@@ -15,6 +15,7 @@ pub mod devices;
pub mod events; pub mod events;
pub mod heart_rate; pub mod heart_rate;
pub mod known; pub mod known;
pub mod settings;
pub mod profile_view; pub mod profile_view;
pub mod recording; pub mod recording;
pub mod samples; pub mod samples;
@@ -98,6 +99,8 @@ pub fn run() {
commands::set_rider_config, commands::set_rider_config,
commands::safety_limits, commands::safety_limits,
commands::set_safety_limits, commands::set_safety_limits,
commands::preferences,
commands::set_preferences,
// profiles // profiles
commands::load_profile_from_path, commands::load_profile_from_path,
commands::load_profile_from_text, commands::load_profile_from_text,
@@ -130,6 +133,20 @@ pub fn run() {
Ok(path) => handle.state::<AppState>().lock().devices.attach_store(path), Ok(path) => handle.state::<AppState>().lock().devices.attach_store(path),
Err(e) => tracing::warn!(error = %e, "remembered devices unavailable"), Err(e) => tracing::warn!(error = %e, "remembered devices unavailable"),
} }
// FR-7.4: the rider's mass, bike and drag, before the first tick
// reads them. Restored ahead of the ride loop starting so no
// snapshot is ever computed against the 105 kg default the struct
// falls back to.
match settings::store_path(&handle) {
Ok(path) => {
let state = handle.state::<AppState>();
let mut inner = state.lock();
let inner = &mut *inner;
let (rider, limits) = (&mut inner.inputs.rider, &mut inner.inputs.limits);
inner.settings.attach(path, rider, limits);
}
Err(e) => tracing::warn!(error = %e, "rider settings unavailable"),
}
// NFR-7: scanning starts immediately, not on a user click. // NFR-7: scanning starts immediately, not on a user click.
handle.state::<AppState>().lock().devices.start_scan(); handle.state::<AppState>().lock().devices.start_scan();
state::spawn_ride_loop(handle.clone()); state::spawn_ride_loop(handle.clone());
+343
View File
@@ -0,0 +1,343 @@
//! Rider setup, remembered across launches (FR-7.4).
//!
//! Two things were wrong before this module existed, and they were the same
//! thing twice.
//!
//! `RiderConfig` lived only in [`crate::backend::RideInputs`], which meant it
//! lived exactly as long as the process — and nothing in the UI ever called
//! `set_rider_config`, so in practice every ride was ridden as a **105 kg
//! rider on an 8 kg bike**, the struct's own defaults. Mass is not a cosmetic
//! setting: it sets the speed a given power produces, the ETA that follows from
//! it, the calorie estimate, and how a 6% ramp feels. A 62 kg rider was being
//! shown somebody else's ride.
//!
//! ```text
//! app_data_dir()/settings.json
//! { "version": 1, "rider": {…}, "limits": {…}, "prefs": {…} }
//! ```
//!
//! Written whole on every change — it is three small structs, and settings are
//! changed by hand at human speed, so this never lands in the ride loop's path.
//! Like [`crate::known`] it is *advisory*: an unreadable file costs the rider
//! their setup, never their ride, so every failure is logged and swallowed.
//!
//! ## Why `prefs` is here and not in `RiderConfig`
//!
//! FTP, maximum heart rate and the unit system change nothing about the
//! physics — they decide how a number is *drawn*. `bikecontrol_core::types` is
//! the frozen contract the engine and the FIT writer share, and a display
//! preference has no business in it. The file keeps them side by side because
//! that is where the rider expects to find them; the types stay apart.
use std::path::{Path, PathBuf};
use bikecontrol_core::types::{RiderConfig, SafetyLimits};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
/// Bumped only if the shape changes incompatibly. An older file with a version
/// we do not know is discarded rather than guessed at.
const VERSION: u32 = 1;
const FILE: &str = "settings.json";
/// Which units the rider reads. Everything is *stored* and *recorded* in SI
/// regardless — this is the last conversion before the glass, so a FIT file
/// never depends on what the screen was set to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Units {
#[default]
Metric,
Imperial,
}
/// Display preferences. Not physics — see the module note.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Preferences {
/// Functional threshold power, watts. The reference every power zone is a
/// fraction of; zero means the rider has not set one and zones are not
/// drawn at all, which is honest — a zone against a guessed FTP is worse
/// than no zone.
pub ftp_w: u16,
/// Maximum heart rate, bpm. Same contract: zero means no HR zones.
pub max_hr_bpm: u16,
pub units: Units,
}
impl Default for Preferences {
fn default() -> Self {
Self {
// Deliberately no default FTP: an invented threshold would colour
// every ride wrong and look authoritative doing it.
ftp_w: 0,
max_hr_bpm: 0,
units: Units::Metric,
}
}
}
/// The file on disk.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct Stored {
version: u32,
rider: RiderConfig,
limits: SafetyLimits,
prefs: Preferences,
}
/// The rider's setup, plus where to write it.
#[derive(Debug, Default)]
pub struct Settings {
pub prefs: Preferences,
/// `None` before [`Settings::attach`] — `AppState::new` runs before there
/// is an `AppHandle` to ask for a data directory, so the first moments of
/// the process are in-memory only.
path: Option<PathBuf>,
}
impl Settings {
/// Point at the file and read it back over the defaults already in
/// `rider` and `limits`.
///
/// Applied by `&mut` rather than returned because a partial application is
/// the one outcome that must not be possible: the rider's mass and the
/// safety clamps that bound what can be sent to the trainer come from the
/// same file and are adopted in the same breath.
pub fn attach(&mut self, path: PathBuf, rider: &mut RiderConfig, limits: &mut SafetyLimits) {
self.path = Some(path.clone());
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "could not read settings");
return;
}
};
let stored: Stored = match serde_json::from_str(&text) {
Ok(s) => s,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "settings are unreadable; using defaults");
return;
}
};
if stored.version != VERSION {
tracing::warn!(
found = stored.version,
expected = VERSION,
"settings are from another version; using defaults"
);
return;
}
// A stored file that fails the same checks the commands apply is not
// trusted just because it is on disk — it may predate a tightened
// bound, or have been edited by hand.
if validate_rider(&stored.rider).is_err() || validate_limits(&stored.limits).is_err() {
tracing::warn!("stored settings are out of range; using defaults");
return;
}
*rider = stored.rider;
*limits = stored.limits;
self.prefs = stored.prefs;
tracing::info!(rider_kg = stored.rider.rider_kg, "settings restored");
}
/// Write the file, atomically. A half-written `settings.json` is discarded
/// whole on the next launch, which would silently put the rider back on a
/// 105 kg default — the exact failure this module exists to prevent.
pub fn save(&self, rider: &RiderConfig, limits: &SafetyLimits) {
let Some(path) = &self.path else {
return;
};
let stored = Stored {
version: VERSION,
rider: *rider,
limits: *limits,
prefs: self.prefs,
};
if let Err(e) = write_atomic(path, &stored) {
tracing::warn!(path = %path.display(), error = %e, "could not save settings");
}
}
}
/// Bounds worth refusing, with the reason a rider can act on.
///
/// These are not taste. Each one is a value that makes the ride engine produce
/// nonsense rather than merely something unusual — a zero mass divides, a zero
/// wheel circumference divides, an efficiency above 1 invents power.
pub fn validate_rider(c: &RiderConfig) -> Result<(), String> {
let check = |ok: bool, msg: &str| if ok { Ok(()) } else { Err(msg.to_string()) };
check(
(20.0..=250.0).contains(&c.rider_kg),
"Rider mass must be between 20 and 250 kg.",
)?;
check(
(1.0..=50.0).contains(&c.bike_kg),
"Bike mass must be between 1 and 50 kg.",
)?;
check(
(0.0005..=0.05).contains(&c.crr),
"Rolling resistance is typically 0.0020.010 for road tyres.",
)?;
check(
(0.1..=1.5).contains(&c.cda),
"CdA must be between 0.1 and 1.5 m² — a road position is about 0.32.",
)?;
check(
(0.5..=1.0).contains(&c.drivetrain_efficiency),
"Drivetrain efficiency is a fraction between 0.5 and 1.0 — about 0.97 for a clean chain.",
)?;
check(
(0.5..=1.6).contains(&c.air_density),
"Air density must be between 0.5 and 1.6 kg/m³ — sea level is 1.225.",
)?;
check(
(0.5..=3.5).contains(&c.wheel_circumference_m),
"Wheel circumference must be between 0.5 and 3.5 m — a 700×25 is about 2.1.",
)?;
check(
(0.1..=0.25).contains(&c.crank_length_m),
"Crank length must be between 0.10 and 0.25 m — road cranks are 0.1700.175.",
)?;
check(
(0.5..=20.0).contains(&c.physical_development_m),
"Physical development must be between 0.5 and 20 m per crank revolution.",
)?;
Ok(())
}
pub fn validate_limits(l: &SafetyLimits) -> Result<(), String> {
if l.min_gradient_pct >= l.max_gradient_pct {
return Err("Gradient limits are inverted.".into());
}
if l.min_resistance >= l.max_resistance {
return Err("Resistance limits are inverted.".into());
}
if l.min_power_w >= l.max_power_w {
return Err("Power limits are inverted.".into());
}
if l.max_power_w > 2000 {
return Err("Maximum power above 2000 W is not a limit, it is a hazard.".into());
}
Ok(())
}
pub fn validate_prefs(p: &Preferences) -> Result<(), String> {
// Zero is the "not set" case for both, and must stay reachable: a rider who
// does not know their FTP is better served by no zones than by a guess.
if p.ftp_w != 0 && !(50..=600).contains(&p.ftp_w) {
return Err("FTP must be between 50 and 600 W, or 0 for no zones.".into());
}
if p.max_hr_bpm != 0 && !(100..=230).contains(&p.max_hr_bpm) {
return Err("Maximum heart rate must be between 100 and 230 bpm, or 0 for no zones.".into());
}
Ok(())
}
fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
let text = serde_json::to_string_pretty(stored)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)
}
/// Beside the remembered devices and the recorded rides.
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("no app data directory: {e}"))?;
std::fs::create_dir_all(&dir)
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
Ok(dir.join(FILE))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("bikecontrol-settings-test-{name}.json"))
}
#[test]
fn a_rider_survives_the_process() {
let path = temp("roundtrip");
let _ = std::fs::remove_file(&path);
let mut settings = Settings::default();
let mut rider = RiderConfig::default();
let mut limits = SafetyLimits::default();
settings.attach(path.clone(), &mut rider, &mut limits);
rider.rider_kg = 62.0;
settings.prefs.ftp_w = 240;
settings.save(&rider, &limits);
let mut again = Settings::default();
let mut rider2 = RiderConfig::default();
let mut limits2 = SafetyLimits::default();
again.attach(path.clone(), &mut rider2, &mut limits2);
assert_eq!(rider2.rider_kg, 62.0);
assert_eq!(again.prefs.ftp_w, 240);
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_missing_file_leaves_the_defaults_alone() {
let path = temp("missing");
let _ = std::fs::remove_file(&path);
let mut settings = Settings::default();
let mut rider = RiderConfig::default();
let mut limits = SafetyLimits::default();
settings.attach(path, &mut rider, &mut limits);
assert_eq!(rider.rider_kg, RiderConfig::default().rider_kg);
assert_eq!(settings.prefs, Preferences::default());
}
/// A file someone edited by hand must not be able to put a zero mass into
/// the engine, which would divide by it on the next tick.
#[test]
fn an_out_of_range_file_is_refused_whole() {
let path = temp("nonsense");
let stored = Stored {
version: VERSION,
rider: RiderConfig {
rider_kg: 0.0,
..RiderConfig::default()
},
limits: SafetyLimits::default(),
prefs: Preferences {
ftp_w: 300,
..Preferences::default()
},
};
write_atomic(&path, &stored).unwrap();
let mut settings = Settings::default();
let mut rider = RiderConfig::default();
let mut limits = SafetyLimits::default();
settings.attach(path.clone(), &mut rider, &mut limits);
assert_eq!(rider.rider_kg, RiderConfig::default().rider_kg);
// Refused whole: the preferences in the same file do not sneak through.
assert_eq!(settings.prefs.ftp_w, 0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn zero_means_no_zones_rather_than_an_invalid_ftp() {
assert!(validate_prefs(&Preferences::default()).is_ok());
assert!(validate_prefs(&Preferences {
ftp_w: 20,
..Preferences::default()
})
.is_err());
}
}
+3
View File
@@ -54,6 +54,8 @@ pub struct Inner {
/// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until /// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until
/// the webview asks for them. /// the webview asks for them.
pub recovered: Vec<Recovered>, pub recovered: Vec<Recovered>,
/// The rider's own setup, and where it is written down (FR-7.4).
pub settings: crate::settings::Settings,
lap_start_ms: u64, lap_start_ms: u64,
lap_start_m: f64, lap_start_m: f64,
lap_power_sum: f64, lap_power_sum: f64,
@@ -104,6 +106,7 @@ impl Inner {
laps: Vec::new(), laps: Vec::new(),
last_summary: None, last_summary: None,
recovered: Vec::new(), recovered: Vec::new(),
settings: crate::settings::Settings::default(),
lap_start_ms: 0, lap_start_ms: 0,
lap_start_m: 0.0, lap_start_m: 0.0,
lap_power_sum: 0.0, lap_power_sum: 0.0,
+27
View File
@@ -6,6 +6,7 @@
import HelpOverlay from './components/HelpOverlay.svelte'; import HelpOverlay from './components/HelpOverlay.svelte';
import ProfileDrawer from './components/ProfileDrawer.svelte'; import ProfileDrawer from './components/ProfileDrawer.svelte';
import RideScreen from './components/RideScreen.svelte'; import RideScreen from './components/RideScreen.svelte';
import SettingsScreen from './components/SettingsScreen.svelte';
import SummaryScreen from './components/SummaryScreen.svelte'; import SummaryScreen from './components/SummaryScreen.svelte';
import Toasts from './components/Toasts.svelte'; import Toasts from './components/Toasts.svelte';
@@ -39,6 +40,23 @@
app.run(fn); app.run(fn);
}; };
// Setup is reachable from anywhere, on the key every other application
// uses for it, and returns to whichever screen it was opened from.
if (e.key === ',') {
e.preventDefault();
app.openSettings();
return;
}
// On setup, the ride controls are inert — a stray arrow key while reading
// the form must not trim the gradient of a ride happening behind it.
if (app.screen === 'settings') {
if (e.key === 'Escape') {
e.preventDefault();
app.closeSettings();
}
return;
}
// The summary screen has its own two keys, and swallows the ride controls: // The summary screen has its own two keys, and swallows the ride controls:
// nudging the gradient of a ride that has ended is meaningless, and space // nudging the gradient of a ride that has ended is meaningless, and space
// would silently start a new one out from under the summary. // would silently start a new one out from under the summary.
@@ -151,6 +169,13 @@
if (!input.pressed) return; if (!input.pressed) return;
const run = (fn: () => Promise<unknown>) => app.run(fn); const run = (fn: () => Promise<unknown>) => app.run(fn);
// Same rule as the keyboard: setup swallows the ride controls, and the
// pod's left button is the way back out of it.
if (app.screen === 'settings') {
if (input.button === 'left') app.closeSettings();
return;
}
// As with the keyboard: on the summary the ride controls are inert, and the // As with the keyboard: on the summary the ride controls are inert, and the
// face buttons carry that screen's own two actions instead. Leaving `a` on // face buttons carry that screen's own two actions instead. Leaving `a` on
// toggle-pause here would restart the ride the rider just finished. // toggle-pause here would restart the ride the rider just finished.
@@ -211,6 +236,8 @@
</div> </div>
{:else if !ready} {:else if !ready}
<div class="boot"><span class="label">Starting…</span></div> <div class="boot"><span class="label">Starting…</span></div>
{:else if app.screen === 'settings'}
<SettingsScreen />
{:else if app.screen === 'summary'} {:else if app.screen === 'summary'}
<SummaryScreen /> <SummaryScreen />
{:else if app.screen === 'ride'} {:else if app.screen === 'ride'}
+1
View File
@@ -28,6 +28,7 @@
['L', 'Insert lap marker'], ['L', 'Insert lap marker'],
['P', 'Profiles and routes'], ['P', 'Profiles and routes'],
['D', 'Device / connection screen'], ['D', 'Device / connection screen'],
[',', 'Rider setup — weight, FTP, units'],
['R', 'Ride screen'], ['R', 'Ride screen'],
['[ / ]', 'Target down / up (resistance or ERG power)'], ['[ / ]', 'Target down / up (resistance or ERG power)'],
['S', 'Save the FIT file (summary screen)'], ['S', 'Save the FIT file (summary screen)'],
+448
View File
@@ -0,0 +1,448 @@
<script lang="ts">
/**
* Rider setup (FR-7.4).
*
* This screen did not exist, and its absence was the app's largest single
* lie: `RiderConfig::default()` is a 105 kg rider on an 8 kg bike, and with
* no way to change it every rider was shown a stranger's speed, a stranger's
* ETA and a stranger's calorie count. Mass is not a preference, it is half
* the physics.
*
* Three rules hold here:
*
* 1. **Rust owns validity.** Every commit round-trips through the command,
* which validates, persists and hands back what it accepted — and that
* is what lands in the form. A field can never end up showing a value
* the engine is not using.
* 2. **Commit on change, not on a Save button.** There is no "unsaved"
* state to lose, and no button to forget to press. Fields commit when
* they are left, so a half-typed "7" on the way to "72" is never sent.
* 3. **What matters is on top.** Weight, FTP and units are the three a
* rider actually sets. Everything below them is aerodynamic and
* drivetrain detail that has a defensible default, so it is folded away
* rather than made to look equally important.
*/
import { app } from '../lib/app.svelte';
import { api } from '../lib/bridge';
import { fromMass, massUnit, toMass } from '../lib/format';
import type { Preferences, RiderConfig, SafetyLimits, Units } from '../lib/types';
let rider = $state<RiderConfig | null>(null);
let limits = $state<SafetyLimits | null>(null);
const prefs = $derived(app.prefs);
const units = $derived(app.units);
/** Bumped after every commit so the inputs re-read the accepted values —
* an input the rider has typed into keeps its own DOM value otherwise, and
* a rejected 900 kg would sit there looking accepted. */
let revision = $state(0);
$effect(() => {
void (async () => {
rider = await api.riderConfig();
limits = await api.safetyLimits();
})();
});
/**
* Send the whole config, spread over what Rust last gave us.
*
* Spread rather than rebuilt: `RiderConfig` carries fields this form does not
* show (the load channel, the fixed drivetrain loss), and a form that
* reconstructs the struct would quietly reset them to serde defaults every
* time somebody changed their weight.
*/
async function commitRider(patch: Partial<RiderConfig>) {
if (!rider) return;
const next = { ...rider, ...patch };
const accepted = await app.run(() => api.setRiderConfig(next));
if (accepted) rider = accepted;
revision++;
}
async function commitLimits(patch: Partial<SafetyLimits>) {
const base = limits;
if (!base) return;
const accepted = await app.run(() => api.setSafetyLimits({ ...base, ...patch }));
if (accepted) limits = accepted;
revision++;
}
async function commitPrefs(patch: Partial<Preferences>) {
await app.savePrefs({ ...prefs, ...patch });
revision++;
}
/** Total mass is what the physics actually uses, so it is worth seeing. */
const totalMass = $derived(rider ? rider.rider_kg + rider.bike_kg : 0);
</script>
<!--
One field. `value` is read through `key` so that a commit — accepted or
rejected — reseats the input from the truth Rust returned.
-->
{#snippet field(
label: string,
value: number,
step: number,
unit: string,
hint: string | null,
commit: (v: number) => void,
)}
<label class="field">
<span class="name">{label}</span>
<span class="input">
{#key revision}
<input
type="number"
{step}
value={Number.isFinite(value) ? Number(value.toFixed(4)) : 0}
onchange={(e) => commit(e.currentTarget.valueAsNumber)}
/>
{/key}
<span class="unit">{unit}</span>
</span>
{#if hint}<span class="hint">{hint}</span>{/if}
</label>
{/snippet}
<div class="screen">
<header>
<h1>Rider setup</h1>
<button class="btn ghost" onclick={() => app.closeSettings()}>
Done <span class="kbd">Esc</span>
</button>
</header>
<div class="body">
{#if !rider || !limits}
<p class="waiting">Reading your setup…</p>
{:else}
<section class="card">
<h2>You</h2>
<div class="grid">
{@render field(
'Weight',
toMass(rider.rider_kg, units),
0.5,
massUnit(units),
'Sets speed, ETA and calories',
(v) => commitRider({ rider_kg: fromMass(v, units) }),
)}
{@render field(
'Bike',
toMass(rider.bike_kg, units),
0.1,
massUnit(units),
`${toMass(totalMass, units).toFixed(1)} ${massUnit(units)} on the road`,
(v) => commitRider({ bike_kg: fromMass(v, units) }),
)}
{@render field(
'FTP',
prefs.ftpW,
5,
'W',
// Zero is a real answer, not an empty box: it is how a rider says
// "I do not know mine", and the screen then shows plain watts
// rather than a zone measured against a guess.
prefs.ftpW ? 'Colours power by zone' : '0 — no power zones',
(v) => commitPrefs({ ftpW: Math.round(v) }),
)}
{@render field(
'Max heart rate',
prefs.maxHrBpm,
1,
'bpm',
prefs.maxHrBpm ? 'Colours heart rate by zone' : '0 — no heart-rate zones',
(v) => commitPrefs({ maxHrBpm: Math.round(v) }),
)}
<div class="field">
<span class="name">Units</span>
<div class="segmented">
{#each [['metric', 'km · kg'], ['imperial', 'mi · lb']] as [value, label]}
<button
class:on={units === value}
onclick={() => commitPrefs({ units: value as Units })}
>
{label}
</button>
{/each}
</div>
<span class="hint">Display only — rides record in SI</span>
</div>
</div>
</section>
<!--
Everything below has a defensible default and changes the ride subtly
rather than obviously. Folded, so the three settings that matter are not
buried among nine that do not.
-->
<details class="card">
<summary><h2>Bike and physics</h2></summary>
<div class="grid">
{@render field('CdA', rider.cda, 0.005, 'm²', 'Road position ≈ 0.32', (v) =>
commitRider({ cda: v }),
)}
{@render field('Rolling resistance', rider.crr, 0.0005, 'Crr', 'Road tyre ≈ 0.004', (v) =>
commitRider({ crr: v }),
)}
{@render field(
'Drivetrain',
rider.drivetrain_efficiency * 100,
0.5,
'%',
'Clean chain ≈ 97%',
(v) => commitRider({ drivetrain_efficiency: v / 100 }),
)}
{@render field('Air density', rider.air_density, 0.005, 'kg/m³', 'Sea level 1.225', (v) =>
commitRider({ air_density: v }),
)}
{@render field(
'Wheel',
rider.wheel_circumference_m * 1000,
5,
'mm',
'700×25 ≈ 2100 mm',
(v) => commitRider({ wheel_circumference_m: v / 1000 }),
)}
{@render field(
'Crank',
rider.crank_length_m * 1000,
2.5,
'mm',
'Road 170175 mm',
(v) => commitRider({ crank_length_m: v / 1000 }),
)}
{@render field(
'Real gear',
rider.physical_development_m,
0.1,
'm/rev',
'Through the Zwift Cog — 34×14 ≈ 5.1',
(v) => commitRider({ physical_development_m: v }),
)}
{@render field(
'Descent floor',
rider.descent_load_floor_pct,
0.5,
'%',
'Keeps load under the pedals downhill',
(v) => commitRider({ descent_load_floor_pct: v }),
)}
</div>
</details>
<!-- SAF-3. These bound everything sent to the trainer, whatever asked
for it, so they are shown last and phrased as limits, not targets. -->
<details class="card">
<summary><h2>Safety limits</h2></summary>
<div class="grid">
{@render field('Gradient floor', limits.min_gradient_pct, 0.5, '%', null, (v) =>
commitLimits({ min_gradient_pct: v }),
)}
{@render field('Gradient ceiling', limits.max_gradient_pct, 0.5, '%', null, (v) =>
commitLimits({ max_gradient_pct: v }),
)}
{@render field('Resistance floor', limits.min_resistance, 1, 'L', null, (v) =>
commitLimits({ min_resistance: Math.round(v) }),
)}
{@render field('Resistance ceiling', limits.max_resistance, 1, 'L', null, (v) =>
commitLimits({ max_resistance: Math.round(v) }),
)}
{@render field('Power floor', limits.min_power_w, 5, 'W', null, (v) =>
commitLimits({ min_power_w: Math.round(v) }),
)}
{@render field('Power ceiling', limits.max_power_w, 5, 'W', 'Clamped at transmission', (v) =>
commitLimits({ max_power_w: Math.round(v) }),
)}
</div>
</details>
{/if}
</div>
</div>
<style>
.screen {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: clamp(1rem, 2.4vw, 1.8rem) var(--edge) 0.7rem;
}
h1 {
margin: 0;
font-size: clamp(1.4rem, 2.2vw, 2rem);
font-weight: 300;
letter-spacing: -0.03em;
}
header .btn {
margin-left: auto;
}
.body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 0 var(--edge) 3rem;
-webkit-overflow-scrolling: touch;
}
.card {
max-width: 54rem;
margin: 0 0 0.6rem;
padding: 0.9rem 1rem 1rem;
border-radius: 0.7rem;
background: var(--bg-lift);
}
h2 {
margin: 0 0 0.2rem;
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
}
summary {
cursor: pointer;
min-height: var(--touch-min);
display: flex;
align-items: center;
}
/* `display: flex` on a summary drops the browser's own marker, and a
disclosure with nothing to disclose-looking about it does not read as one. */
summary::before {
content: '';
width: 0;
height: 0;
margin-right: 0.5rem;
border-left: 5px solid currentColor;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
opacity: 0.6;
transition: transform 120ms ease;
}
details[open] > summary::before {
transform: rotate(90deg);
}
summary h2 {
margin: 0;
color: var(--ink-soft);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
gap: 0.7rem 1rem;
margin-top: 0.7rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.name {
font-size: 0.8rem;
font-weight: 600;
color: var(--ink-soft);
}
.input {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.45rem 0.6rem;
border-radius: 0.45rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--hairline);
}
.input:focus-within {
border-color: color-mix(in srgb, var(--route) 45%, transparent);
}
input {
flex: 1 1 auto;
min-width: 0;
background: none;
border: none;
color: var(--ink);
font: inherit;
font-size: 1.05rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
/* The spinners are 12 px of target on a screen where nothing else is under
48, and they steal the width the number needs. */
-moz-appearance: textfield;
appearance: textfield;
min-height: var(--touch-min);
}
input:focus {
outline: none;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.unit {
flex: none;
font-size: 0.8rem;
font-weight: 600;
color: var(--ink-dim);
}
.hint {
font-size: 0.75rem;
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.segmented {
display: flex;
gap: 2px;
padding: 2px;
border-radius: 0.45rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--hairline);
}
.segmented button {
flex: 1 1 0;
padding: 0.4rem 0.5rem;
border-radius: 0.35rem;
color: var(--ink-dim);
font-size: 0.85rem;
font-weight: 600;
min-height: var(--touch-min);
}
.segmented button.on {
background: var(--route);
color: #04121a;
}
.waiting {
color: var(--ink-dim);
}
</style>
+39 -2
View File
@@ -10,13 +10,15 @@ import type {
InputAck, InputAck,
LapSummary, LapSummary,
Notice, Notice,
Preferences,
RideFrame, RideFrame,
RideState, RideState,
RideSummary, RideSummary,
SampleProfile, SampleProfile,
Units,
} from './types'; } from './types';
export type Screen = 'connect' | 'ride' | 'summary'; export type Screen = 'connect' | 'ride' | 'summary' | 'settings';
let toastSeq = 0; let toastSeq = 0;
@@ -40,6 +42,14 @@ class AppStore {
controller = $state<ControllerStatus | null>(null); controller = $state<ControllerStatus | null>(null);
/** Bumped on every snapshot so charts know to redraw without deep tracking. */ /** Bumped on every snapshot so charts know to redraw without deep tracking. */
revision = $state(0); revision = $state(0);
/**
* Display preferences (FR-7.4). Held here rather than read where they are
* needed so that changing units or FTP redraws every readout at once —
* `format.ts` stays pure and takes them as an argument.
*/
prefs = $state<Preferences>({ ftpW: 0, maxHrBpm: 0, units: 'metric' });
/** The screen to return to when settings close. */
settingsReturn: Screen = 'connect';
/** Bounded chart history — raw power, rolling power. */ /** Bounded chart history — raw power, rolling power. */
readonly power = new History(2); readonly power = new History(2);
@@ -53,6 +63,20 @@ class AppStore {
return this.devices.devices.some((d) => d.kind === 'trainer' && d.controlAcquired); return this.devices.devices.some((d) => d.kind === 'trainer' && d.controlAcquired);
} }
get units(): Units {
return this.prefs.units;
}
/** Open settings from wherever the rider is, and remember where that was. */
openSettings(): void {
if (this.screen !== 'settings') this.settingsReturn = this.screen;
this.screen = 'settings';
}
closeSettings(): void {
this.screen = this.settingsReturn;
}
/** /**
* Enter the ride screen. * Enter the ride screen.
* *
@@ -83,7 +107,7 @@ class AppStore {
* parallel set that can drift. * parallel set that can drift.
*/ */
async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise<void> { async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise<void> {
const [ride, devices, samples, summary, recovered, controller] = await Promise.all([ const [ride, devices, samples, summary, recovered, controller, prefs] = await Promise.all([
api.rideState(), api.rideState(),
api.deviceList(), api.deviceList(),
api.sampleProfiles(), api.sampleProfiles(),
@@ -93,12 +117,14 @@ class AppStore {
// *change*, so a webview reload with both pods already connected would // *change*, so a webview reload with both pods already connected would
// otherwise show two empty slots. // otherwise show two empty slots.
api.controllerStatus(), api.controllerStatus(),
api.preferences(),
]); ]);
this.ride = ride; this.ride = ride;
this.devices = devices; this.devices = devices;
this.samples = samples; this.samples = samples;
this.summary = summary; this.summary = summary;
this.controller = controller; this.controller = controller;
this.prefs = prefs;
// A ride already in progress (a reload, or an autostart) belongs on screen // A ride already in progress (a reload, or an autostart) belongs on screen
// immediately — nobody wants to click past a device list mid-effort. // immediately — nobody wants to click past a device list mid-effort.
if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride'; if (ride.status === 'running' || ride.status === 'paused') this.screen = 'ride';
@@ -205,6 +231,17 @@ class AppStore {
} }
} }
/**
* Save a preference and keep the screen honest about what stuck.
*
* Rust validates and returns the accepted value, so what lands in `prefs` is
* what is actually stored — never what the input box happened to contain.
*/
async savePrefs(next: Preferences): Promise<void> {
const accepted = await this.run(() => api.setPreferences(next));
if (accepted) this.prefs = accepted;
}
/** Leave the summary and set up for another ride. */ /** Leave the summary and set up for another ride. */
async newRide(): Promise<void> { async newRide(): Promise<void> {
await this.run(() => api.reset()); await this.run(() => api.reset());
+3
View File
@@ -13,6 +13,7 @@ import type {
InputAck, InputAck,
LapSummary, LapSummary,
Notice, Notice,
Preferences,
ProfileView, ProfileView,
Recovered, Recovered,
RideFrame, RideFrame,
@@ -139,6 +140,8 @@ export const api = {
setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }), setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }),
safetyLimits: () => call<SafetyLimits>('safety_limits'), safetyLimits: () => call<SafetyLimits>('safety_limits'),
setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }), setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }),
preferences: () => call<Preferences>('preferences'),
setPreferences: (prefs: Preferences) => call<Preferences>('set_preferences', { prefs }),
// profiles // profiles
loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }), loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }),
+32
View File
@@ -70,10 +70,15 @@ export interface RideSnapshot {
profile_progress: number | null; profile_progress: number | null;
} }
/** Which FTMS channel the computed load is sent on. */
export type LoadChannel = 'Gradient' | 'Power';
export interface RiderConfig { export interface RiderConfig {
rider_kg: number; rider_kg: number;
bike_kg: number; bike_kg: number;
crr: number; crr: number;
/** Fixed drivetrain and flywheel loss, watts. */
rolling_loss_w: number;
cda: number; cda: number;
drivetrain_efficiency: number; drivetrain_efficiency: number;
air_density: number; air_density: number;
@@ -83,6 +88,33 @@ export interface RiderConfig {
descent_load_floor_pct: number; descent_load_floor_pct: number;
/** Development of the real gear through the Zwift Cog, m per crank rev. */ /** Development of the real gear through the Zwift Cog, m per crank rev. */
physical_development_m: number; physical_development_m: number;
load_channel: LoadChannel;
}
// --- src-tauri/src/settings.rs -----------------------------------------------
/**
* What the rider reads, not what is recorded. Every stored value and every FIT
* field stays SI whatever this says the conversion is the last step before
* the glass.
*/
export type Units = 'metric' | 'imperial';
/**
* Display preferences (FR-7.4's neighbours). Deliberately *not* part of
* `RiderConfig`: none of this reaches the physics, and `crates/core` is the
* frozen contract the engine and the FIT writer share.
*/
export interface Preferences {
/**
* Functional threshold power, watts. **Zero means unset**, and zones are then
* not drawn at all a zone measured against a guessed FTP is worse than no
* zone, because it looks authoritative.
*/
ftpW: number;
/** Maximum heart rate, bpm. Zero means unset, same contract as `ftpW`. */
maxHrBpm: number;
units: Units;
} }
export interface SafetyLimits { export interface SafetyLimits {