Add Svelte GUI, FIT encoder and README

Standalone binary embeds the frontend, avoiding the dev-server dependency
that made the window fail to load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:50:01 +02:00
co-authored by Claude Opus 5
parent 7c17ca6158
commit 3a2a787b7d
23 changed files with 3297 additions and 46 deletions
+97 -2
View File
@@ -44,10 +44,16 @@ pub struct FitSummary {
pub total_elapsed_s: f64,
/// Moving/recording time excluding explicit pauses, seconds.
pub total_timer_s: f64,
/// Virtual distance covered, metres.
pub total_distance_m: f64,
/// Cumulative climbing, metres.
pub total_ascent_m: u16,
/// Mean power over samples that reported one. `None` if none did.
pub avg_power_w: Option<u16>,
/// Peak power. `None` if no sample reported power.
pub max_power_w: Option<u16>,
/// Energy in kilocalories, from the trainer if it reports it and otherwise
/// derived from mechanical work.
pub total_calories: Option<u16>,
/// Number of BLE dropouts spanned (FR-8.5).
pub gaps: usize,
@@ -131,7 +137,8 @@ impl Aggregates {
(Some(a), Some(b)) if b >= a && b > 0 => return Some(b - a),
_ => {}
}
(self.work_j > 0.0).then(|| clamp_u16(self.work_j / 1000.0))
let kcal = clamp_u16(self.work_j / 1000.0);
(kcal > 0).then_some(kcal)
}
fn avg_grade_pct(&self) -> Option<f64> {
@@ -181,8 +188,13 @@ pub fn encode_activity(log: &RawLog) -> Result<(Vec<u8>, FitSummary), FitError>
lap_slices.push((begin, cursor));
// Pause time attributable to this lap.
let lap_paused = paused_within(log, lap_start_ms, lap_end_ms, end_ms);
// The first sample of the *next* lap closes this one's distance and
// altitude, so that the laps tile the session exactly rather than each
// dropping the stretch between its last sample and the next boundary.
let tail = resolved.get(cursor);
lap_aggs.push(aggregate(
&resolved[begin..cursor],
tail,
start_fit,
lap_start_ms,
lap_end_ms,
@@ -190,7 +202,7 @@ pub fn encode_activity(log: &RawLog) -> Result<(Vec<u8>, FitSummary), FitError>
));
}
let session_agg = aggregate(&resolved, start_fit, 0, end_ms, paused_ms);
let session_agg = aggregate(&resolved, None, start_fit, 0, end_ms, paused_ms);
let bytes = assemble(log, &resolved, &lap_slices, &lap_aggs, &session_agg, start_fit)?;
let summary = FitSummary {
@@ -316,8 +328,15 @@ fn overlap(a0: u64, a1: u64, b0: u64, b1: u64) -> u64 {
}
/// Fold a slice of samples into lap or session aggregates.
///
/// `tail` is the first sample *after* this slice, when there is one. It
/// contributes only to the closing distance and altitude, never to averages or
/// maxima — it belongs to the next lap. Without it, lap distances and ascents
/// would not sum to the session's, because each lap would silently drop the
/// stretch between its final sample and the lap boundary.
fn aggregate(
samples: &[Resolved],
tail: Option<&Resolved>,
start_fit: u32,
from_ms: u64,
to_ms: u64,
@@ -399,6 +418,19 @@ fn aggregate(
prev_alt = Some(r.altitude_m);
}
// Close the lap at the boundary rather than at its last sample.
if let Some(t) = tail {
agg.end_distance_m = t.sample.distance_m;
if let Some(prev) = prev_alt {
let d = t.altitude_m - prev;
if d > 0.0 {
agg.ascent_m += d;
} else {
agg.descent_m -= d;
}
}
}
agg
}
@@ -1087,6 +1119,69 @@ mod tests {
assert_eq!(summary.total_distance_m, 600.0);
}
#[test]
fn lap_distance_and_ascent_tile_the_session_exactly() {
// Each lap must be closed at the boundary, not at its last sample, or
// the laps quietly lose one sample interval of distance apiece.
let mut entries = Vec::new();
for i in 0..61u64 {
entries.push(LogEntry::Sample(Sample {
elapsed_ms: i * 1000,
power_w: Some(200),
speed_kph: 36.0,
distance_m: (i * 10) as f64,
gradient_pct: 5.0,
..Default::default()
}));
}
entries.push(LogEntry::Lap {
at_ms: 20_000,
from_controller: true,
});
entries.push(LogEntry::Lap {
at_ms: 40_000,
from_controller: true,
});
entries.push(LogEntry::End { at_ms: 60_000 });
let log = log_with(entries);
let start_fit = crate::timestamp::from_unix_millis(log.start.start_unix_ms).unwrap();
let resolved = resolve_samples(&log, start_fit).unwrap();
let bounds = lap_boundaries(&log, 60_000);
let mut cursor = 0usize;
let mut lap_distance = 0.0;
let mut lap_ascent = 0.0;
for (i, &(from, to)) in bounds.iter().enumerate() {
let begin = cursor;
let is_last = i + 1 == bounds.len();
while cursor < resolved.len() && (is_last || resolved[cursor].sample.elapsed_ms < to) {
cursor += 1;
}
let agg = aggregate(
&resolved[begin..cursor],
resolved.get(cursor),
start_fit,
from,
to,
0,
);
lap_distance += agg.total_distance_m();
lap_ascent += agg.ascent_m;
}
let session = aggregate(&resolved, None, start_fit, 0, 60_000, 0);
assert!(
(lap_distance - session.total_distance_m()).abs() < 1e-9,
"laps sum to {lap_distance} m, session is {} m",
session.total_distance_m()
);
assert!(
(lap_ascent - session.ascent_m).abs() < 1e-9,
"laps climb {lap_ascent} m, session climbs {} m",
session.ascent_m
);
assert_eq!(session.total_distance_m(), 600.0);
}
#[test]
fn trainer_reported_energy_is_preferred_for_calories() {
let entries = vec![