feat(train): a workout keeps its plan's steps as laps, shown on its page (ADR 0030, D43)

As a recording with a plan is saved, each step run becomes a lap in the
workout's sealed payload: the step, its round, when it started and ended,
its moving time and distance, with the highest heart rate the zones ran
with. The page lists the steps with their pace, speed or split, stroke
rate, watts and heart rate, marks whether each held its target, and the
charts shade the work steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 23:12:34 +01:00
commit e1304975d6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 840 additions and 38 deletions

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
@ -311,4 +312,89 @@ void main() {
expect(analysis.watts.last.value, 200);
});
});
group('a plan (D43)', () {
// 3 m/s (5:33 a km) for 600 s, the heart at 150, then 170 from 300 s.
final fixes = [for (var s = 0; s <= 600; s += 10) north(s, s * 3)];
final rates = [
for (var s = 0; s <= 600; s += 10)
HeartRate(time: at(s), bpm: s < 300 ? 150 : 170),
];
Recording intervals(List<PlanStep> steps, {int? maxHeartRate}) => Recording(
activity: Activity.running,
startedAt: start,
fixes: fixes,
heartRates: rates,
plan: PlanContent(
name: 'Two',
blocks: [PlanBlock(steps: steps)],
),
planMaxHeartRate: maxHeartRate,
planSteps: [
StepMark(time: at(0), index: 0),
StepMark(time: at(300), index: 1),
],
endedAt: at(600),
);
test('each step has its own heart rate, and says whether it held its '
'target', () {
final analysis = analysed(
intervals([
const PlanStep(
kind: StepKind.recovery,
end: EndAfterTime(Duration(minutes: 5)),
target: Target(TargetKind.pace, 320, 340),
),
const PlanStep(
kind: StepKind.work,
end: EndAfterTime(Duration(minutes: 5)),
target: Target(TargetKind.pace, 270, 300),
),
]),
);
expect(analysis.laps.map((l) => l.averageBpm), [150, 170]);
expect(analysis.laps.map((l) => l.held), [true, false]);
expect(analysis.laps.first.lap.metres, 900);
});
test(
'a zone is held on the heart rates it spans for the highest rate it '
'ran with; without that rate, or without a target, it says nothing',
() {
final zones = [
PlanStep(
kind: StepKind.work,
end: const EndAfterTime(Duration(minutes: 5)),
target: Target.zone(3),
),
const PlanStep(
kind: StepKind.work,
end: EndAfterTime(Duration(minutes: 5)),
),
];
// Zone 3 of 200 bpm: 140 to 160.
expect(
analysed(intervals(zones, maxHeartRate: 200)).laps.map((l) => l.held),
[true, null],
);
expect(analysed(intervals(zones)).laps.first.held, isNull);
},
);
test('its work steps are where the charts shade, over the distance', () {
final analysis = analysed(
intervals(const [
PlanStep(kind: StepKind.warmUp, end: EndWithLap()),
PlanStep(kind: StepKind.work, end: EndWithLap()),
]),
);
final band = analysis.workBands.single;
expect(band.from, closeTo(900, 1));
expect(band.to, closeTo(1800, 1));
});
});
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/plan/model/plan.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_payload.dart';
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
@ -84,4 +85,86 @@ void main() {
expect(decodeWorkout(encodeWorkout(imported)), imported);
expect(imported.withText(name: 'Intervals').origin, imported.origin);
});
test('a workout run with a plan keeps its name, its highest heart rate and '
'its steps as run (D43)', () {
final intervals = WorkoutContent(
activity: Activity.running,
startedAt: run.startedAt,
endedAt: run.endedAt,
plan: WorkoutPlan(
name: '4×4',
maxHeartRate: 190,
laps: [
WorkoutLap(
step: PlanStep(
kind: StepKind.work,
end: const EndAfterTime(Duration(minutes: 4)),
target: Target.zone(4),
),
rounds: 4,
startedAt: run.startedAt,
endedAt: run.startedAt.add(const Duration(minutes: 4, seconds: 9)),
movingTime: const Duration(minutes: 4),
metres: 1012,
),
WorkoutLap(
step: const PlanStep(kind: StepKind.recovery, end: EndWithLap()),
rounds: 4,
startedAt: run.startedAt.add(
const Duration(minutes: 4, seconds: 9),
),
endedAt: run.endedAt,
movingTime: const Duration(minutes: 3),
),
],
),
);
expect(decodeWorkout(encodeWorkout(intervals)), intervals);
expect(intervals.withText(name: 'Hard').plan, intervals.plan);
});
test("a lap it can't read is left out, the rest kept", () {
final json = jsonDecode(
utf8.decode(
Uint8List.sublistView(
encodeWorkout(
WorkoutContent(
activity: Activity.running,
startedAt: run.startedAt,
endedAt: run.endedAt,
plan: WorkoutPlan(
name: 'Laps',
laps: [
WorkoutLap(
step: const PlanStep(
kind: StepKind.work,
end: EndWithLap(),
),
startedAt: run.startedAt,
endedAt: run.endedAt,
movingTime: const Duration(minutes: 1),
),
],
),
),
),
),
),
) as Map<String, Object?>;
final plan = json['plan']! as Map<String, Object?>;
plan['laps'] = [
{
'step': {'kind': 'sprint'},
},
...plan['laps']! as List<Object?>,
];
final read = decodeWorkout(
ByteData.sublistView(utf8.encode(jsonEncode(json))),
)!;
expect(read.plan?.laps, hasLength(1));
});
}