diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 54446b0..22b3053 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -441,10 +441,19 @@ pub fn set_rider_config( state: State<'_, AppState>, config: RiderConfig, ) -> Cmd { - if config.rider_kg <= 20.0 || config.bike_kg <= 0.0 { - return Err("Rider and bike mass must be positive and realistic".into()); + // Every bound refused here is one that makes the engine produce nonsense + // 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); Ok(config) } @@ -460,14 +469,40 @@ pub fn set_safety_limits( state: State<'_, AppState>, limits: SafetyLimits, ) -> Cmd { - if limits.min_gradient_pct >= limits.max_gradient_pct { - return Err("Gradient limits are inverted".into()); + crate::settings::validate_limits(&limits)?; + { + 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); 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::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) // --------------------------------------------------------------------------- diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 17eb3f9..da62b23 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ pub mod devices; pub mod events; pub mod heart_rate; pub mod known; +pub mod settings; pub mod profile_view; pub mod recording; pub mod samples; @@ -98,6 +99,8 @@ pub fn run() { commands::set_rider_config, commands::safety_limits, commands::set_safety_limits, + commands::preferences, + commands::set_preferences, // profiles commands::load_profile_from_path, commands::load_profile_from_text, @@ -130,6 +133,20 @@ pub fn run() { Ok(path) => handle.state::().lock().devices.attach_store(path), 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::(); + 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. handle.state::().lock().devices.start_scan(); state::spawn_ride_loop(handle.clone()); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs new file mode 100644 index 0000000..219c48f --- /dev/null +++ b/src-tauri/src/settings.rs @@ -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, +} + +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.002–0.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.170–0.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 { + 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()); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b58e552..107a95d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -54,6 +54,8 @@ pub struct Inner { /// Rides rebuilt from an orphaned journal at startup (FR-8.4), held until /// the webview asks for them. pub recovered: Vec, + /// 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_m: f64, lap_power_sum: f64, @@ -104,6 +106,7 @@ impl Inner { laps: Vec::new(), last_summary: None, recovered: Vec::new(), + settings: crate::settings::Settings::default(), lap_start_ms: 0, lap_start_m: 0.0, lap_power_sum: 0.0, diff --git a/ui/src/App.svelte b/ui/src/App.svelte index b744f84..a5adb81 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -6,6 +6,7 @@ import HelpOverlay from './components/HelpOverlay.svelte'; import ProfileDrawer from './components/ProfileDrawer.svelte'; import RideScreen from './components/RideScreen.svelte'; + import SettingsScreen from './components/SettingsScreen.svelte'; import SummaryScreen from './components/SummaryScreen.svelte'; import Toasts from './components/Toasts.svelte'; @@ -39,6 +40,23 @@ 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: // nudging the gradient of a ride that has ended is meaningless, and space // would silently start a new one out from under the summary. @@ -151,6 +169,13 @@ if (!input.pressed) return; const run = (fn: () => Promise) => 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 // face buttons carry that screen's own two actions instead. Leaving `a` on // toggle-pause here would restart the ride the rider just finished. @@ -211,6 +236,8 @@ {:else if !ready}
Starting…
+ {:else if app.screen === 'settings'} + {:else if app.screen === 'summary'} {:else if app.screen === 'ride'} diff --git a/ui/src/components/HelpOverlay.svelte b/ui/src/components/HelpOverlay.svelte index 312c372..53bd9e5 100644 --- a/ui/src/components/HelpOverlay.svelte +++ b/ui/src/components/HelpOverlay.svelte @@ -28,6 +28,7 @@ ['L', 'Insert lap marker'], ['P', 'Profiles and routes'], ['D', 'Device / connection screen'], + [',', 'Rider setup — weight, FTP, units'], ['R', 'Ride screen'], ['[ / ]', 'Target down / up (resistance or ERG power)'], ['S', 'Save the FIT file (summary screen)'], diff --git a/ui/src/components/SettingsScreen.svelte b/ui/src/components/SettingsScreen.svelte new file mode 100644 index 0000000..012c793 --- /dev/null +++ b/ui/src/components/SettingsScreen.svelte @@ -0,0 +1,448 @@ + + + +{#snippet field( + label: string, + value: number, + step: number, + unit: string, + hint: string | null, + commit: (v: number) => void, +)} + +{/snippet} + +
+
+

Rider setup

+ +
+ +
+ {#if !rider || !limits} +

Reading your setup…

+ {:else} +
+

You

+
+ {@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) }), + )} + +
+ Units +
+ {#each [['metric', 'km · kg'], ['imperial', 'mi · lb']] as [value, label]} + + {/each} +
+ Display only — rides record in SI +
+
+
+ + +
+

Bike and physics

+
+ {@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 170–175 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 }), + )} +
+
+ + +
+

Safety limits

+
+ {@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) }), + )} +
+
+ {/if} +
+
+ + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index bea9fc6..0fae45c 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -10,13 +10,15 @@ import type { InputAck, LapSummary, Notice, + Preferences, RideFrame, RideState, RideSummary, SampleProfile, + Units, } from './types'; -export type Screen = 'connect' | 'ride' | 'summary'; +export type Screen = 'connect' | 'ride' | 'summary' | 'settings'; let toastSeq = 0; @@ -40,6 +42,14 @@ class AppStore { controller = $state(null); /** Bumped on every snapshot so charts know to redraw without deep tracking. */ 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({ ftpW: 0, maxHrBpm: 0, units: 'metric' }); + /** The screen to return to when settings close. */ + settingsReturn: Screen = 'connect'; /** Bounded chart history — raw power, rolling power. */ readonly power = new History(2); @@ -53,6 +63,20 @@ class AppStore { 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. * @@ -83,7 +107,7 @@ class AppStore { * parallel set that can drift. */ async init(hooks: { onControllerInput?: (i: ControllerInput) => void } = {}): Promise { - const [ride, devices, samples, summary, recovered, controller] = await Promise.all([ + const [ride, devices, samples, summary, recovered, controller, prefs] = await Promise.all([ api.rideState(), api.deviceList(), api.sampleProfiles(), @@ -93,12 +117,14 @@ class AppStore { // *change*, so a webview reload with both pods already connected would // otherwise show two empty slots. api.controllerStatus(), + api.preferences(), ]); this.ride = ride; this.devices = devices; this.samples = samples; this.summary = summary; this.controller = controller; + this.prefs = prefs; // A ride already in progress (a reload, or an autostart) belongs on screen // immediately — nobody wants to click past a device list mid-effort. 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 { + const accepted = await this.run(() => api.setPreferences(next)); + if (accepted) this.prefs = accepted; + } + /** Leave the summary and set up for another ride. */ async newRide(): Promise { await this.run(() => api.reset()); diff --git a/ui/src/lib/bridge.ts b/ui/src/lib/bridge.ts index d83f689..b476bd2 100644 --- a/ui/src/lib/bridge.ts +++ b/ui/src/lib/bridge.ts @@ -13,6 +13,7 @@ import type { InputAck, LapSummary, Notice, + Preferences, ProfileView, Recovered, RideFrame, @@ -139,6 +140,8 @@ export const api = { setRiderConfig: (config: RiderConfig) => call('set_rider_config', { config }), safetyLimits: () => call('safety_limits'), setSafetyLimits: (limits: SafetyLimits) => call('set_safety_limits', { limits }), + preferences: () => call('preferences'), + setPreferences: (prefs: Preferences) => call('set_preferences', { prefs }), // profiles loadProfilePath: (path: string) => call('load_profile_from_path', { path }), diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 29d08d4..c8bd327 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -70,10 +70,15 @@ export interface RideSnapshot { profile_progress: number | null; } +/** Which FTMS channel the computed load is sent on. */ +export type LoadChannel = 'Gradient' | 'Power'; + export interface RiderConfig { rider_kg: number; bike_kg: number; crr: number; + /** Fixed drivetrain and flywheel loss, watts. */ + rolling_loss_w: number; cda: number; drivetrain_efficiency: number; air_density: number; @@ -83,6 +88,33 @@ export interface RiderConfig { descent_load_floor_pct: number; /** Development of the real gear through the Zwift Cog, m per crank rev. */ 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 {