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
+27
View File
@@ -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<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
// 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 @@
</div>
{:else if !ready}
<div class="boot"><span class="label">Starting…</span></div>
{:else if app.screen === 'settings'}
<SettingsScreen />
{:else if app.screen === 'summary'}
<SummaryScreen />
{:else if app.screen === 'ride'}
+1
View File
@@ -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)'],
+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,
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<ControllerStatus | null>(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<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. */
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<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.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<void> {
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<void> {
await this.run(() => api.reset());
+3
View File
@@ -13,6 +13,7 @@ import type {
InputAck,
LapSummary,
Notice,
Preferences,
ProfileView,
Recovered,
RideFrame,
@@ -139,6 +140,8 @@ export const api = {
setRiderConfig: (config: RiderConfig) => call<RiderConfig>('set_rider_config', { config }),
safetyLimits: () => call<SafetyLimits>('safety_limits'),
setSafetyLimits: (limits: SafetyLimits) => call<SafetyLimits>('set_safety_limits', { limits }),
preferences: () => call<Preferences>('preferences'),
setPreferences: (prefs: Preferences) => call<Preferences>('set_preferences', { prefs }),
// profiles
loadProfilePath: (path: string) => call<ProfileView>('load_profile_from_path', { path }),
+32
View File
@@ -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 {