From 0c757a4a15b6f3b94db7f5197737d6bfbcc85e6a Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 20:15:11 +0200 Subject: [PATCH] Colour effort by zone, and read distance in the rider's units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src-tauri/src/profile_view.rs | 16 +- ui/src/app.css | 19 +++ ui/src/components/ProfileDrawer.svelte | 10 +- ui/src/components/Readout.svelte | 30 +++- ui/src/components/RideScreen.svelte | 215 +++++++++++++++++++++---- ui/src/components/RouteChart.svelte | 16 +- ui/src/components/SummaryScreen.svelte | 11 +- ui/src/lib/format.test.ts | 70 ++++++++ ui/src/lib/format.ts | 148 ++++++++++++++++- 9 files changed, 477 insertions(+), 58 deletions(-) create mode 100644 ui/src/lib/format.test.ts diff --git a/src-tauri/src/profile_view.rs b/src-tauri/src/profile_view.rs index 8b7cdee..afa5b40 100644 --- a/src-tauri/src/profile_view.rs +++ b/src-tauri/src/profile_view.rs @@ -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(), } } diff --git a/ui/src/app.css b/ui/src/app.css index df9741d..537ad31 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -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 . */ --gap: 24px; --edge: 44px; diff --git a/ui/src/components/ProfileDrawer.svelte b/ui/src/components/ProfileDrawer.svelte index a6f06ba..504bada 100644 --- a/ui/src/components/ProfileDrawer.svelte +++ b/ui/src/components/ProfileDrawer.svelte @@ -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 @@ Loaded {loaded.name} - {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' : ''} + + {#if show.routeChart}
- + +
+ +
+ {#if nextUp} + Next{nextUp.label} in {nextUp.away} + {/if}
{/if} @@ -314,27 +403,35 @@ - +
+ + + +
{#if !compact} - - + + {/if} @@ -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`} />