Colour effort by zone, and read distance in the rider's units

A watt is a fact; a zone is what it costs you. The biggest number on the
ride screen was the same shade of white at 90 W and at 400 W, which is a
thing no training app has done in fifteen years.

Zones, with two rules:

- **No reference, no zone.** An unset FTP draws the plain number. A zone
  measured against a guessed threshold would paint every ride with a
  confident lie.
- **Colour never carries it alone.** "Z4" renders beside the swatch, so
  the meaning survives a colour-blind rider, a phone in direct sun and a
  black-and-white screenshot.

Read off the rolling average, not the instantaneous watts: at 4 Hz the
raw figure crosses two boundaries every pedal stroke, and a colour that
strobes is worse than no colour. Not pedalling is not zone 1.

Units are a display preference applied at the last step before the
glass. Everything computed, stored and recorded stays SI, so a FIT file
never depends on what the screen was set to. `format.ts` takes the unit
system as an argument rather than reading a module-level setting — pure
functions are what let every readout redraw the moment it changes. The
`km` helper is gone rather than left beside `dist`, so there is no
second way to format a distance that ignores the preference.

Rust's block labels lose their baked-in kilometres. The block already
carries start_x and end_x and the frontend renders that span in the
rider's units; a kilometre in the text sat inside a sentence saying
miles everywhere else.

Also on the ride screen:

- The gradient gets a wedge beside the number. A signed decimal has to
  be read; a slope is seen. Exaggerated and clamped, because a true-scale
  6% is indistinguishable from 3% at 40 px wide.
- What is coming, from the profile's own block list — "2.1 km at 12% in
  460 m". The chart says where the rider is; what is about to happen is
  what decides whether to shift now. The data was already computed
  Rust-side and thrown away here. Close in, the small unit reads better
  than a fraction of the big one.
- Mode and target merge into one chip. They are a single fact, and
  splitting them spent a chip of header width repeating the word
  "target".
- The pod chip no longer reports a missing `+` pod while the `−` pod is
  connected. The `−` pod relays its twin, so that is the intended
  configuration — the ride screen was calling it a fault, contradicting
  the device screen two keystrokes away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 20:15:11 +02:00
co-authored by Claude Opus 5
parent cdff678167
commit 0c757a4a15
9 changed files with 477 additions and 58 deletions
+10 -6
View File
@@ -333,13 +333,17 @@ fn block_label(block: &Block) -> String {
amplitude,
repeats
),
// No distance in the label. The block already carries `start_x` and
// `end_x`, and the frontend renders that span in the rider's own units
// (FR-7.5a) — a kilometre baked into the text here would sit inside a
// sentence that says miles everywhere else.
Block::Segments { segments } => {
let d: f64 = segments.iter().map(|s| s.distance_m).sum();
format!("{} segments · {:.1} km", segments.len(), d / 1000.0)
}
Block::Terrain { points } => {
let d = points.last().map(|p| p.distance_m).unwrap_or(0.0);
format!("terrain · {:.1} km", d / 1000.0)
format!(
"{} segment{}",
segments.len(),
if segments.len() == 1 { "" } else { "s" }
)
}
Block::Terrain { .. } => "terrain".to_string(),
}
}
+19
View File
@@ -39,6 +39,25 @@
--power: #dfe8f3;
--power-raw: #3f4d5d;
/*
* Effort zones (see `powerZone` in lib/format.ts). An ordered ramp, not a
* categorical palette: cool and quiet at the bottom, hot and loud at the top,
* so intensity reads from the colour before the number is even focused on.
* Four of the seven are the tones this stylesheet already uses for ok / warn
* / climb / bad, which keeps one vocabulary on the screen rather than two.
*
* Colour never carries the meaning alone — every zoned readout renders "Z4"
* beside the swatch. That is what makes this safe for the ~8% of male riders
* with a colour vision deficiency, and for a phone in direct sun.
*/
--zone-1: #7b8b9c;
--zone-2: #4aa8ff;
--zone-3: #35d9a0;
--zone-4: #ffcf4a;
--zone-5: #ff9a3c;
--zone-6: #ff5a52;
--zone-7: #c07cff;
/* Fallbacks; viewport.svelte.ts overwrites all of these on <html>. */
--gap: 24px;
--edge: 44px;
+6 -4
View File
@@ -10,7 +10,7 @@
import { open } from '@tauri-apps/plugin-dialog';
import { app } from '../lib/app.svelte';
import { api } from '../lib/bridge';
import { axisValue } from '../lib/format';
import { axisValue, dist, distUnit, elev, elevUnit } from '../lib/format';
import type { ProfileView } from '../lib/types';
let yaml = $state('');
@@ -136,9 +136,11 @@
<span class="label">Loaded</span>
<strong>{loaded.name}</strong>
<span class="sample-sub">
{loaded.totalMetres ? `${(loaded.totalMetres / 1000).toFixed(1)} km` : ''}
{loaded.totalMetres ? `${dist(loaded.totalMetres, app.units, 1)} ${distUnit(app.units)}` : ''}
{loaded.totalSeconds ? `${Math.round(loaded.totalSeconds / 60)} min` : ''}
{loaded.totalAscentM != null ? `· ${loaded.totalAscentM.toFixed(0)} m up` : ''}
{loaded.totalAscentM != null
? `· ${elev(loaded.totalAscentM, app.units)} ${elevUnit(app.units)} up`
: ''}
{loaded.looping ? '· loops' : ''}
</span>
<button class="btn ghost danger" onclick={() => app.run(() => api.clearProfile())}>
@@ -174,7 +176,7 @@
<em>{b.kind}</em>
{b.label}
<span class="quiet">
{axisValue(b.unit, b.endX - b.startX)}
{axisValue(b.unit, b.endX - b.startX, app.units)}
</span>
</span>
{/each}
+29 -1
View File
@@ -13,6 +13,15 @@
sub?: string | null;
dim?: boolean;
align?: 'start' | 'end';
/**
* A short tag beside the label — the effort zone, in practice.
*
* It sits next to the *label* rather than the number so the number keeps
* its full weight, and it is a word rather than a colour alone: `Z4` still
* reads on a phone in the sun and to a rider who cannot separate the amber
* from the green (see the zone note in app.css).
*/
badge?: { label: string; colour: string } | null;
}
let {
@@ -24,11 +33,15 @@
sub = null,
dim = false,
align = 'start',
badge = null,
}: Props = $props();
</script>
<div class="readout {size}" class:dim style:align-items={align === 'end' ? 'flex-end' : 'flex-start'}>
<span class="label">{label}</span>
<span class="head">
<span class="label">{label}</span>
{#if badge}<span class="badge" style:color={badge.colour}>{badge.label}</span>{/if}
</span>
<span class="value" style:color={colour}>
{value}{#if unit}<span class="unit">{unit}</span>{/if}
</span>
@@ -43,6 +56,21 @@
min-width: 0;
}
.head {
display: inline-flex;
align-items: baseline;
gap: 0.4em;
min-width: 0;
}
.badge {
font-size: var(--type-label);
font-weight: 700;
letter-spacing: 0.08em;
/* No pill: a box around a two-character tag is more furniture than the tag
is worth, and this stylesheet earns its structure from space. */
}
.value {
font-weight: 300;
line-height: 0.92;
+183 -32
View File
@@ -11,12 +11,19 @@
import { api } from '../lib/bridge';
import {
clock,
dist,
distUnit,
duration,
elev,
elevUnit,
finishAt,
km,
hrZone,
MODE_LABEL,
num,
powerZone,
signed,
speed,
speedUnit,
targetText,
} from '../lib/format';
import { viewport } from '../lib/viewport.svelte';
@@ -33,6 +40,9 @@
const show = $derived(viewport.plan.show);
const compact = $derived(viewport.plan.sizeClass === 'compact');
const units = $derived(app.units);
const prefs = $derived(app.prefs);
const frame = $derived(app.frame);
const snap = $derived(frame?.snapshot ?? null);
const d = $derived(frame?.derived ?? null);
@@ -81,13 +91,20 @@
const podChip = $derived.by(() => {
const c = app.controller;
if (!c) return null;
const missing = [c.minus, c.plus].filter((p) => p.state !== 'connected');
if (missing.length === 0) return null;
// The pod relays its twin, so a `+` pod that is not connected while the
// `` pod is up is the *intended* configuration, not a missing device —
// and this chip was reporting it as one, contradicting the device screen.
if (c.minus.state === 'connected') return null;
const missing = c.plus.state === 'connected' ? [c.minus] : [c.minus, c.plus];
const names = missing.map((p) => `${p.symbol} pod`).join(' and ');
const searching = missing.some((p) => p.state === 'searching' || p.state === 'reconnecting');
return {
tone: searching ? 'tone-warn' : 'tone-idle',
label: searching ? `Looking for the ${names}…` : `No ${names}`,
label: searching
? `Looking for the ${names}…`
: c.plus.state === 'connected'
? '+ pod only — no D-pad'
: `No ${names}`,
// The keyboard is always the fallback, which is what keeps a missing pod
// an annoyance rather than the end of the ride.
title: 'Open Devices to connect, or use the keyboard — press ? for the list',
@@ -190,10 +207,10 @@
*/
const speedSub = $derived.by(() => {
if (speedFault) return speedFault;
const now = `now ${num(snap?.virtual_speed_kph ?? 0, 1)}`;
const now = `now ${speed(snap?.virtual_speed_kph ?? 0, units)}`;
const trainer = snap?.telemetry.speed_kph;
const coasting = snap?.speed_source === 'Coasting' ? ' · coasting' : '';
return (trainer != null ? `${now} · trainer ${num(trainer, 1)}` : now) + coasting;
return (trainer != null ? `${now} · trainer ${speed(trainer, units)}` : now) + coasting;
});
const statusChip = $derived.by(() => {
@@ -220,13 +237,66 @@
const routeSummary = $derived.by(() => {
if (!profile) return null;
const bits: string[] = [];
if (profile.totalMetres) bits.push(`${(profile.totalMetres / 1000).toFixed(1)} km`);
if (profile.totalMetres) bits.push(`${dist(profile.totalMetres, units, 1)} ${distUnit(units)}`);
if (profile.totalSeconds) bits.push(`${Math.round(profile.totalSeconds / 60)} min`);
if (profile.totalAscentM != null) bits.push(`${profile.totalAscentM.toFixed(0)} m up`);
if (profile.totalAscentM != null) {
bits.push(`${elev(profile.totalAscentM, units)} ${elevUnit(units)} up`);
}
if (profile.looping) bits.push('loops');
return bits.join(' · ');
});
/**
* Effort as a zone, not just a number (see `powerZone` in lib/format.ts).
*
* Read off the *rolling* average rather than the instantaneous watts: at 4 Hz
* the raw figure crosses two zone boundaries every pedal stroke, and a
* colour that strobes is worse than no colour. `null` whenever the rider has
* not set an FTP — the readout then draws exactly as it always did.
*/
const pZone = $derived(powerZone(d?.rollingPowerW, prefs.ftpW));
const hZone = $derived(hrZone(snap?.telemetry.heart_rate_bpm, prefs.maxHrBpm));
/**
* The gradient, as a shape.
*
* A signed decimal has to be read; a wedge is seen. The angle is exaggerated
* — real road gradients are almost flat at true scale, and 6% drawn honestly
* is indistinguishable from 3% at 40 px wide — and clamped so a profile with
* a 30% wall cannot draw a vertical cliff.
*/
const wedge = $derived.by(() => {
const clamped = Math.max(-15, Math.min(15, gradient));
// 22 px of rise across 38 px of run at the clamp, which reads as a
// recognisable hill without leaving the line box.
const rise = (clamped / 15) * 11;
return { y1: 12 + rise, y2: 12 - rise };
});
/**
* What is coming, from the profile's own block list.
*
* The route chart says where the rider *is*; every professional app also says
* what is about to happen, because that is what decides whether to shift now
* or hold on. The data was already computed Rust-side and thrown away here.
*/
const nextUp = $derived.by(() => {
if (!profile || !d) return null;
const at = d.positionX;
const block = profile.blocks.find((b) => b.startX > at);
if (!block) return null;
const away = block.startX - at;
// Close in, the small unit reads better than a fraction of the big one:
// "in 460 m" is a distance a rider can feel, "in 0.46 km" is arithmetic.
const inWords =
block.unit !== 'metres'
? duration(away)
: away < 1000
? `${elev(away, units)} ${elevUnit(units)}`
: `${dist(away, units, 1)} ${distUnit(units)}`;
return { label: block.label, away: inWords };
});
const openRoutes = () => (app.showProfiles = true);
</script>
@@ -257,17 +327,36 @@
<span class="dot"></span>{podChip.label}
</span>
{/if}
<span class="chip mode">{MODE_LABEL[ride?.mode ?? 'ManualGrade']}</span>
<span class="chip target">Target {targetText(ride?.target ?? null)}</span>
<!-- One chip, not two. The mode and the target it produced are a single
fact — "Manual grade, +2.5%" — and splitting them cost a whole chip
of the header's width to repeat the word "target" (FR-9.8). -->
<span class="chip mode">
{MODE_LABEL[ride?.mode ?? 'ManualGrade']}
<span class="target">{targetText(ride?.target ?? null)}</span>
</span>
<button class="btn" onclick={openRoutes}>Route <span class="kbd">P</span></button>
<button class="btn ghost" onclick={() => (app.screen = 'connect')}>Devices</button>
<!-- Hidden on a phone (see below): the setup screen is a one-off, it has
its own way in from the device screen, and the header is the one row
on this layout that cannot afford a second line. -->
<button class="btn ghost setup" title="Rider setup" onclick={() => app.openSettings()}>
Rider <span class="kbd">,</span>
</button>
</div>
</header>
<!-- The hero. -->
{#if show.routeChart}
<section class="route">
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
<!-- The chart owns the slack; the next-up line takes the one text row it
needs. Without this wrapper the chart is 100% of the section and the
line is clipped by the section's own `overflow: hidden`. -->
<div class="route-chart">
<RouteChart {profile} positionX={d?.positionX ?? 0} revision={app.revision} />
</div>
{#if nextUp}
<span class="next"><span class="label">Next</span>{nextUp.label} in {nextUp.away}</span>
{/if}
</section>
{/if}
@@ -314,27 +403,35 @@
</div>
<Readout
label="To go"
value={remaining != null ? km(remaining, 2) : '—'}
unit={remaining != null ? 'km' : ''}
value={remaining != null ? dist(remaining, units, 2) : '—'}
unit={remaining != null ? distUnit(units) : ''}
size="big"
sub={d?.distanceTotalM ? `of ${km(d.distanceTotalM, 1)} km` : null}
sub={d?.distanceTotalM ? `of ${dist(d.distanceTotalM, units, 1)} ${distUnit(units)}` : null}
dim={remaining == null}
/>
<Readout
label="Speed"
value={num(d?.displaySpeedKph ?? 0, 1)}
unit="km/h"
value={speed(d?.displaySpeedKph ?? 0, units)}
unit={speedUnit(units)}
size="big"
sub={speedSub}
/>
<Readout
label="Gradient"
value={signed(gradient, 1)}
unit="%"
size="big"
colour={gradeColour}
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
/>
<div class="grade-cell">
<Readout
label="Gradient"
value={signed(gradient, 1)}
unit="%"
size="big"
colour={gradeColour}
sub={ride?.gradientOffsetPct ? `trim ${signed(ride.gradientOffsetPct, 1)}%` : null}
/>
<!-- The same number as a slope. Seen, not read (see `wedge`). -->
<svg class="wedge" viewBox="0 0 40 24" aria-hidden="true" style:color={gradeColour}>
<path d="M2 {wedge.y1} L38 {wedge.y2}" stroke="currentColor" stroke-width="2.6"
stroke-linecap="round" fill="none" />
<path d="M2 {wedge.y1} L38 {wedge.y2} L38 22 L2 22 Z" fill="currentColor" opacity="0.14" />
</svg>
</div>
<Readout
label="Gear"
value={gear.value}
@@ -350,14 +447,14 @@
<section class="detail">
<Readout
label="Elevation"
value={d?.elevationM != null ? num(d.elevationM, 0) : '—'}
unit={d?.elevationM != null ? 'm' : ''}
value={d?.elevationM != null ? elev(d.elevationM, units) : '—'}
unit={d?.elevationM != null ? elevUnit(units) : ''}
colour="var(--climb)"
/>
<Readout
label="Climbing left"
value={d?.ascentRemainingM != null ? num(d.ascentRemainingM, 0) : '—'}
unit={d?.ascentRemainingM != null ? 'm' : ''}
value={d?.ascentRemainingM != null ? elev(d.ascentRemainingM, units) : '—'}
unit={d?.ascentRemainingM != null ? elevUnit(units) : ''}
/>
<!--
Compact keeps three of the five, and elapsed time is one of them: it is
@@ -366,8 +463,16 @@
"how far" ("of 42.0 km") and the summary screen answers the rest.
-->
{#if !compact}
<Readout label="Covered" value={km(snap?.virtual_distance_m ?? 0, 2)} unit="km" />
<Readout label="Ascended" value={num(snap?.elevation_gain_m ?? 0, 0)} unit="m" />
<Readout
label="Covered"
value={dist(snap?.virtual_distance_m ?? 0, units, 2)}
unit={distUnit(units)}
/>
<Readout
label="Ascended"
value={elev(snap?.elevation_gain_m ?? 0, units)}
unit={elevUnit(units)}
/>
{/if}
<Readout label="Elapsed" value={clock((snap?.elapsed_ms ?? 0) / 1000)} />
</section>
@@ -380,8 +485,9 @@
value={num(d?.rollingPowerW ?? 0, 0)}
unit="W"
size="mid"
colour="var(--power)"
sub={`now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
colour={pZone?.colour ?? 'var(--power)'}
badge={pZone ? { label: pZone.short, colour: pZone.colour } : null}
sub={`${pZone ? pZone.name + ' · ' : ''}now ${num(snap?.telemetry.power_w ?? 0, 0)} W`}
/>
<Readout
label="Cadence"
@@ -395,6 +501,9 @@
value={snap?.telemetry.heart_rate_bpm != null ? num(snap.telemetry.heart_rate_bpm, 0) : '—'}
unit="bpm"
size="mid"
colour={hZone?.colour ?? 'var(--ink)'}
badge={hZone ? { label: hZone.short, colour: hZone.colour } : null}
sub={hZone?.name ?? null}
/>
<!--
Averages, normalised power, work and calories are what the summary screen
@@ -517,18 +626,60 @@
background: #131b26;
}
.chip.mode .target {
color: var(--route);
font-variant-numeric: tabular-nums;
}
/* The gradient number and its slope, side by side and sharing a colour. */
.grade-cell {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.wedge {
width: 2.5rem;
height: 1.5rem;
flex: none;
}
:global([data-size='compact']) .chips .setup {
display: none;
}
.next {
display: inline-flex;
align-items: baseline;
gap: 0.45rem;
padding-top: 0.15rem;
font-size: 0.82rem;
color: var(--ink-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip.target {
color: var(--route);
background: rgba(69, 208, 255, 0.1);
}
.route {
display: flex;
flex-direction: column;
flex: 2 1 0;
min-height: var(--route-min);
padding: 0 var(--edge);
overflow: hidden;
}
.route-chart {
flex: 1 1 auto;
min-height: 0;
}
/* ---- pre-ride launch panel ---------------------------------------- */
.launch {
+13 -3
View File
@@ -13,6 +13,8 @@
import { onMount } from 'svelte';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { app } from '../lib/app.svelte';
import { dist, elev } from '../lib/format';
import { axis, observeSize, positionMarker } from '../lib/uplot';
import type { ProfileView } from '../lib/types';
@@ -24,6 +26,10 @@
let { profile, positionX, revision }: Props = $props();
/** Axis labels are the one part of this chart that a display preference
* reaches, so a change of units rebuilds it — see the effect below. */
const units = $derived(app.units);
let host = $state<HTMLDivElement | null>(null);
let plot: uPlot | null = null;
let disposeSize: (() => void) | null = null;
@@ -56,12 +62,15 @@
axes: [
axis({
values: (_u, splits) =>
splits.map((v) => (isMetres ? `${(v / 1000).toFixed(1)}` : formatMinutes(v))),
splits.map((v) => (isMetres ? dist(v, units, 1) : formatMinutes(v))),
}),
axis({
side: 3,
size: 46,
values: (_u, splits) => splits.map((v) => v.toFixed(0)),
// Metres of elevation follow the rider's units; a profile's own
// channel (percent, watts) is already in the unit it means.
values: (_u, splits) =>
splits.map((v) => (source?.isElevation ? elev(v, units) : v.toFixed(0))),
}),
],
series: [
@@ -122,7 +131,8 @@
// is a single array walk rather than a chart teardown.
let builtFor: string | null = null;
$effect(() => {
const key = host && profile ? `${profile.name}|${profile.source}|${profile.totalX}` : null;
const key =
host && profile ? `${profile.name}|${profile.source}|${profile.totalX}|${units}` : null;
if (key === builtFor) return;
builtFor = key;
if (key) {
+6 -5
View File
@@ -8,10 +8,11 @@
* 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 { clock, dist, distUnit, elev, elevUnit, num } from '../lib/format';
import Readout from './Readout.svelte';
const s = $derived(app.summary);
const units = $derived(app.units);
/** Time not spent riding. Only worth showing when it is not zero. */
const pausedS = $derived(s ? Math.max(0, s.durationS - s.movingS) : 0);
@@ -64,15 +65,15 @@
<Readout label="Duration" value={clock(s.durationS)} size="hero" colour="var(--route)" />
<Readout
label="Distance"
value={km(s.distanceM, 2)}
unit="km"
value={dist(s.distanceM, units, 2)}
unit={distUnit(units)}
size="big"
colour="var(--route)"
/>
<Readout
label="Climbing"
value={num(s.ascentM, 0)}
unit="m"
value={elev(s.ascentM, units)}
unit={elevUnit(units)}
size="big"
colour="var(--climb)"
/>
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { dist, elev, hrZone, percentOf, powerZone, speed, toMass, fromMass } from './format';
/**
* The display layer is where a rider's preference is applied and nowhere else,
* so these tests exist to pin two properties: the conversions are exact, and a
* zone is never invented from a reference the rider has not given.
*/
describe('units', () => {
it('leaves metric alone and converts imperial exactly', () => {
expect(dist(42195, 'metric', 2)).toBe('42.20');
expect(dist(1609.344, 'imperial', 3)).toBe('1.000');
expect(elev(1000, 'metric')).toBe('1000');
expect(elev(304.8, 'imperial')).toBe('1000');
expect(speed(32.18688, 'imperial')).toBe('20.0');
});
it('round-trips a mass through the form and back', () => {
const kg = 72.5;
expect(fromMass(toMass(kg, 'imperial'), 'imperial')).toBeCloseTo(kg, 6);
expect(toMass(kg, 'metric')).toBe(kg);
});
it('says nothing rather than NaN when there is no value', () => {
expect(dist(null, 'metric')).toBe('—');
expect(elev(undefined, 'imperial')).toBe('—');
});
});
describe('effort zones', () => {
it('places power on the Coggan boundaries', () => {
const ftp = 250;
expect(powerZone(100, ftp)?.short).toBe('Z1'); // 40%
expect(powerZone(175, ftp)?.short).toBe('Z2'); // 70%
expect(powerZone(220, ftp)?.short).toBe('Z3'); // 88%
expect(powerZone(250, ftp)?.short).toBe('Z4'); // 100%
expect(powerZone(290, ftp)?.short).toBe('Z5'); // 116%
expect(powerZone(350, ftp)?.short).toBe('Z6'); // 140%
expect(powerZone(500, ftp)?.short).toBe('Z7'); // 200%
});
it('puts a boundary value in the lower zone', () => {
// 55% of FTP is the top of zone 1, not the bottom of zone 2.
expect(powerZone(137.5, 250)?.short).toBe('Z1');
});
/** The whole contract of `ftpW: 0` — no reference, no colour. */
it('refuses to invent a zone without a reference', () => {
expect(powerZone(300, 0)).toBeNull();
expect(hrZone(150, 0)).toBeNull();
expect(percentOf(300, 0)).toBeNull();
});
it('does not colour a rider who is not pedalling', () => {
expect(powerZone(0, 250)).toBeNull();
expect(powerZone(null, 250)).toBeNull();
});
it('places heart rate as a fraction of maximum', () => {
expect(hrZone(100, 190)?.short).toBe('Z1'); // 53%
expect(hrZone(125, 190)?.short).toBe('Z2'); // 66%
expect(hrZone(150, 190)?.short).toBe('Z3'); // 79%
expect(hrZone(170, 190)?.short).toBe('Z4'); // 89%
expect(hrZone(185, 190)?.short).toBe('Z5'); // 97%
});
it('reports the percentage the zone came from', () => {
expect(percentOf(275, 250)).toBe('110% of FTP');
});
});
+141 -7
View File
@@ -1,4 +1,5 @@
/** Display formatting only. No ride logic lives in the frontend (§4.3). */
import type { Units } from './types';
const EM_DASH = '—';
@@ -32,11 +33,6 @@ export function finishAt(secondsFromNow: number | null | undefined): string {
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
}
export function km(metres: number | null | undefined, digits = 2): string {
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
return (metres / 1000).toFixed(digits);
}
export function num(v: number | null | undefined, digits = 0): string {
if (v == null || !Number.isFinite(v)) return EM_DASH;
return v.toFixed(digits);
@@ -51,8 +47,8 @@ export function axisLabel(unit: 'seconds' | 'metres'): string {
return unit === 'metres' ? 'distance' : 'time';
}
export function axisValue(unit: 'seconds' | 'metres', x: number): string {
return unit === 'metres' ? `${(x / 1000).toFixed(1)} km` : clock(x);
export function axisValue(unit: 'seconds' | 'metres', x: number, units: Units): string {
return unit === 'metres' ? `${dist(x, units, 1)} ${distUnit(units)}` : clock(x);
}
export function rssiBars(rssi: number): number {
@@ -102,3 +98,141 @@ export function connectionText(
return { label: 'Idle', tone: 'idle' };
}
}
// ---------------------------------------------------------------------------
// Units (FR-7.4's neighbour)
//
// SI everywhere behind this line. The engine computes in metres and km/h, the
// FIT file records in metres and km/h, and a rider who prefers miles changes
// *only* what the glass says. Every function here therefore takes the unit
// system as an argument rather than reading a module-level setting: pure
// functions are what let the ride screen redraw the moment the preference
// changes, and what keeps a recorded activity independent of a display choice.
// ---------------------------------------------------------------------------
const KM_PER_MILE = 1.609344;
const M_PER_FOOT = 0.3048;
const KG_PER_LB = 0.45359237;
export function distUnit(units: Units): string {
return units === 'imperial' ? 'mi' : 'km';
}
export function elevUnit(units: Units): string {
return units === 'imperial' ? 'ft' : 'm';
}
export function speedUnit(units: Units): string {
return units === 'imperial' ? 'mph' : 'km/h';
}
export function massUnit(units: Units): string {
return units === 'imperial' ? 'lb' : 'kg';
}
/** Metres → the rider's long-distance unit. */
export function dist(metres: number | null | undefined, units: Units, digits = 2): string {
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
const km = metres / 1000;
return (units === 'imperial' ? km / KM_PER_MILE : km).toFixed(digits);
}
/** Metres of climbing → metres or feet. Always whole: nobody climbs 0.4 m. */
export function elev(metres: number | null | undefined, units: Units): string {
if (metres == null || !Number.isFinite(metres)) return EM_DASH;
return (units === 'imperial' ? metres / M_PER_FOOT : metres).toFixed(0);
}
export function speed(kph: number | null | undefined, units: Units, digits = 1): string {
if (kph == null || !Number.isFinite(kph)) return EM_DASH;
return (units === 'imperial' ? kph / KM_PER_MILE : kph).toFixed(digits);
}
/** Kilograms → kg or lb. For the settings form, which edits SI underneath. */
export function toMass(kg: number, units: Units): number {
return units === 'imperial' ? kg / KG_PER_LB : kg;
}
export function fromMass(value: number, units: Units): number {
return units === 'imperial' ? value * KG_PER_LB : value;
}
// ---------------------------------------------------------------------------
// Effort zones
//
// A watt is a fact; a zone is what it *costs you*, and it is the difference
// between a number and a number that means something. Every professional
// training app colours effort this way and this one did not, so the biggest
// figure on the ride screen was the same shade of white at 90 W and at 400 W.
//
// Two rules hold here:
//
// 1. **No reference, no zone.** `ftp` or `maxHr` of zero means the rider has
// not told us, and an invented threshold would paint every ride with a
// confident lie. `null` is the honest answer and the callers draw the
// plain number.
// 2. **Colour never carries it alone.** Every zone has a short name that is
// rendered beside the swatch, so the meaning survives a colour-blind
// rider, a sun-washed phone and a black-and-white screenshot.
// ---------------------------------------------------------------------------
export interface Zone {
/** One-based, as riders say them: "zone 4". */
index: number;
/** What it is called in a training plan. */
name: string;
/** Short form for a chip beside a number: "Z4". */
short: string;
/** A CSS custom property reference, so themes stay in the stylesheet. */
colour: string;
}
/**
* Coggan's seven power zones, as fractions of FTP. The boundaries are the
* conventional ones — 55 / 75 / 90 / 105 / 120 / 150 % — which is what makes a
* "zone 3 ride" here mean the same thing it means in the rider's plan.
*/
const POWER_ZONES: { upto: number; zone: Zone }[] = [
{ upto: 0.55, zone: { index: 1, name: 'Recovery', short: 'Z1', colour: 'var(--zone-1)' } },
{ upto: 0.75, zone: { index: 2, name: 'Endurance', short: 'Z2', colour: 'var(--zone-2)' } },
{ upto: 0.9, zone: { index: 3, name: 'Tempo', short: 'Z3', colour: 'var(--zone-3)' } },
{ upto: 1.05, zone: { index: 4, name: 'Threshold', short: 'Z4', colour: 'var(--zone-4)' } },
{ upto: 1.2, zone: { index: 5, name: 'VO₂ max', short: 'Z5', colour: 'var(--zone-5)' } },
{ upto: 1.5, zone: { index: 6, name: 'Anaerobic', short: 'Z6', colour: 'var(--zone-6)' } },
{ upto: Infinity, zone: { index: 7, name: 'Sprint', short: 'Z7', colour: 'var(--zone-7)' } },
];
/** Five heart-rate zones as fractions of maximum. */
const HR_ZONES: { upto: number; zone: Zone }[] = [
{ upto: 0.6, zone: { index: 1, name: 'Recovery', short: 'Z1', colour: 'var(--zone-1)' } },
{ upto: 0.7, zone: { index: 2, name: 'Endurance', short: 'Z2', colour: 'var(--zone-2)' } },
{ upto: 0.8, zone: { index: 3, name: 'Tempo', short: 'Z3', colour: 'var(--zone-3)' } },
{ upto: 0.9, zone: { index: 4, name: 'Threshold', short: 'Z4', colour: 'var(--zone-5)' } },
{ upto: Infinity, zone: { index: 5, name: 'Maximum', short: 'Z5', colour: 'var(--zone-6)' } },
];
function zoneFor(
table: { upto: number; zone: Zone }[],
value: number | null | undefined,
reference: number,
): Zone | null {
// Not pedalling is not zone 1. A stopped rider in "Recovery" green is the
// screen claiming an effort that is not happening.
if (!reference || value == null || !Number.isFinite(value) || value <= 0) return null;
const ratio = value / reference;
return table.find((row) => ratio <= row.upto)?.zone ?? null;
}
export function powerZone(watts: number | null | undefined, ftp: number): Zone | null {
return zoneFor(POWER_ZONES, watts, ftp);
}
export function hrZone(bpm: number | null | undefined, maxHr: number): Zone | null {
return zoneFor(HR_ZONES, bpm, maxHr);
}
/** Percentage of the reference, for the subtitle under a zoned number. */
export function percentOf(value: number | null | undefined, reference: number): string | null {
if (!reference || value == null || !Number.isFinite(value) || value <= 0) return null;
return `${Math.round((value / reference) * 100)}% of FTP`;
}