Files
BikeControl/ui/src/components/SummaryScreen.svelte
T
dtourolleandClaude Opus 5 7b511db3dc Ride the drivetrain, command the load in watts
Speed now comes from the drivetrain and the load from the road, which is
the way round a bike actually works.

Speed is cadence x development, filtered lightly. Power, not cadence,
decides whether the rider is driving it: on a direct-drive trainer the
flywheel keeps the cranks turning after they stop, so cadence alone reads
a healthy 80 rpm for someone doing nothing. Below 15 W the speed runs
down to whatever the gradient sustains on no power - zero uphill, a real
freewheeling speed on a descent. Stopping on a 3.5% climb used to settle
at 22 km/h and stay there, because the model wanted to decelerate and a
blend toward the flywheel speed outvoted it; that blend is gone.

The D100 sends no cadence over FTMS - it is a rebadged Magene T110 with
cadence disabled in firmware (qdomyos-zwift#3282) - so it is inferred
from wheel speed, which one sprocket and no freewheel make exact. Its
Zwift channel does carry cadence, and is now greeted with RideOn and
subscribed on every notifying characteristic, so a measured value is used
where one arrives.

The load is commanded as power, not gradient. The trainer declares
50-600 W in 1 W steps against 0-6% inclination in 0.1% steps refusing
negatives, and whether it acts on 0x11 at all is still unconfirmed. Its
power target is a ceiling rather than a setpoint, which is very nearly
what a road is: exceed it and the surplus becomes speed. Gravity travels
on the same channel as watts, so nothing is lost by leaving 0x11 alone.
LoadChannel keeps the gradient path selectable and tested.

Virtual shifting reaches the trainer for the first time. The physics
load model was written but never called, and a paddle press both shifted
a gear in Rust and nudged the gradient in the webview - the shift
silently, the tilt visibly, so the paddles looked like a gradient trim.

Also: a fixed 12 W drivetrain loss, held as a power because that is how
it presents; crank length, so a gear can be reported as the force it puts
under the foot; gear and pedal force on the ride screen; a drag-race
profile for testing gearing on the flat.

Two readout bugs fixed on the way. The rolling windows were trimmed by
timestamp but fed on a fixed timer, so every second spent on the ride
screen before starting pushed samples at t=0 that could never expire -
speed read a fraction of the truth for the first 45 s. And the headline
speed was a 45 s mean, which took most of a minute to show a gear change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:21:08 +02:00

267 lines
6.9 KiB
Svelte

<script lang="ts">
/**
* The ride, after the ride (FR-9.13, FR-9.14).
*
* The activity is already written by the time this appears — `stop_ride`
* saves it before emitting the summary. So the primary action here is "save a
* copy where I want it", not "save or lose it", and nothing on this screen is
* urgent. It says where the file already is, whatever the rider does next.
*/
import { app } from '../lib/app.svelte';
import { clock, km, num } from '../lib/format';
import Readout from './Readout.svelte';
const s = $derived(app.summary);
/** Time not spent riding. Only worth showing when it is not zero. */
const pausedS = $derived(s ? Math.max(0, s.durationS - s.movingS) : 0);
/**
* Anything the rider should know about the *file* rather than the ride.
* Silence here means the recording was clean, so these only ever appear when
* there is genuinely something to say.
*/
const caveats = $derived.by(() => {
if (!s) return [];
const out: string[] = [];
if (s.recoveredFromCrash) {
out.push('This ride was rebuilt from its journal after an interruption.');
}
if (s.gaps > 0) {
out.push(
`${s.gaps} telemetry dropout${s.gaps === 1 ? '' : 's'} — the trainer stopped reporting and those stretches are gaps in the file.`,
);
}
if (s.skippedLogLines > 0) {
out.push(
`${s.skippedLogLines} journal line${s.skippedLogLines === 1 ? '' : 's'} could not be read and are missing from the activity.`,
);
}
return out;
});
</script>
<div class="summary">
{#if !s}
<section class="empty">
<h1>No finished ride</h1>
<p>End a ride and its summary appears here.</p>
<button class="btn primary" onclick={() => (app.screen = 'ride')}>Back to the ride</button>
</section>
{:else}
<header>
<div class="who">
<h1>Ride complete</h1>
<span class="sub">
{s.records} sample{s.records === 1 ? '' : 's'} · {s.laps} lap{s.laps === 1 ? '' : 's'}
</span>
</div>
<span class="chip tone-ok"><span class="dot"></span>Saved</span>
</header>
<!-- FR-9.13: duration, distance, elevation, avg/max power, avg cadence. -->
<section class="primary">
<Readout label="Duration" value={clock(s.durationS)} size="hero" colour="var(--route)" />
<Readout
label="Distance"
value={km(s.distanceM, 2)}
unit="km"
size="big"
colour="var(--route)"
/>
<Readout
label="Climbing"
value={num(s.ascentM, 0)}
unit="m"
size="big"
colour="var(--climb)"
/>
</section>
<section class="detail">
<Readout
label="Avg power"
value={s.avgPowerW != null ? num(s.avgPowerW, 0) : '—'}
unit={s.avgPowerW != null ? 'W' : ''}
colour="var(--power)"
dim={s.avgPowerW == null}
/>
<Readout
label="Max power"
value={s.maxPowerW != null ? num(s.maxPowerW, 0) : '—'}
unit={s.maxPowerW != null ? 'W' : ''}
colour="var(--power)"
dim={s.maxPowerW == null}
/>
<Readout
label="Avg cadence"
value={s.avgCadenceRpm != null ? num(s.avgCadenceRpm, 0) : '—'}
unit={s.avgCadenceRpm != null ? 'rpm' : ''}
dim={s.avgCadenceRpm == null}
/>
<Readout
label="Moving"
value={clock(s.movingS)}
sub={pausedS >= 1 ? `${clock(pausedS)} paused` : null}
/>
<Readout
label="Calories"
value={s.calories != null ? num(s.calories, 0) : '—'}
unit={s.calories != null ? 'kcal' : ''}
dim={s.calories == null}
/>
</section>
{#if caveats.length > 0}
<section class="caveats">
{#each caveats as caveat (caveat)}
<p>{caveat}</p>
{/each}
</section>
{/if}
<!--
The path is stated whether or not the rider saves a copy. A summary that
only said "saved" would leave someone who closes the window with no idea
where their ride went.
-->
<section class="where">
<span class="label">Activity file</span>
<code class="path">{s.savedPath ?? s.fitPath}</code>
{#if s.savedPath}
<span class="also">Automatic copy kept at {s.fitPath}</span>
{/if}
</section>
<footer>
<button class="btn primary" disabled={app.saving} onclick={() => app.saveFit()}>
{app.saving ? 'Saving…' : s.savedPath ? 'Save another copy' : 'Save FIT…'}
<span class="kbd">S</span>
</button>
<button class="btn" onclick={() => app.newRide()}>
New ride
<span class="kbd">N</span>
</button>
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>
Devices
<span class="kbd">D</span>
</button>
</footer>
{/if}
</div>
<style>
.summary {
display: flex;
flex-direction: column;
gap: var(--gap);
height: 100%;
min-height: 0;
overflow-y: auto;
padding: 1.2rem var(--edge) 1.6rem;
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--gap);
}
.who {
min-width: 0;
}
h1 {
margin: 0;
font-size: clamp(1.05rem, 1.6vw, 1.5rem);
font-weight: 600;
letter-spacing: -0.015em;
}
.sub {
font-size: 0.85rem;
color: var(--ink-dim);
}
.primary {
display: flex;
flex-wrap: wrap;
gap: var(--gap) calc(var(--gap) * 2);
align-items: flex-end;
}
.detail {
display: flex;
flex-wrap: wrap;
gap: var(--gap) calc(var(--gap) * 1.6);
padding-top: var(--gap);
border-top: 1px solid var(--hairline);
}
/* Warnings about the file, not the ride. Deliberately not a toast: these
outlive the four seconds a toast gets, and they are the reason someone
would go looking at the journal. */
.caveats {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75rem 0.9rem;
border-radius: 0.5rem;
background: var(--bg-lift);
border-left: 3px solid var(--warn);
}
.caveats p {
margin: 0;
font-size: 0.88rem;
color: var(--ink-soft);
}
.where {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.path {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem;
color: var(--ink-soft);
overflow-wrap: anywhere;
/* Selectable: the whole point of showing it is that it can be copied. */
user-select: text;
-webkit-user-select: text;
}
.also {
font-size: 0.78rem;
color: var(--ink-dim);
overflow-wrap: anywhere;
}
footer {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin-top: auto;
padding-top: var(--gap);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.8rem;
height: 100%;
text-align: center;
}
.empty p {
margin: 0;
color: var(--ink-dim);
}
</style>