fix(train): a day's skin temperature in time order, and its heart rate a point a minute without lost contact

The skin temperature's line ran backwards in places: the band's samples
aren't always in time order, and neither the Loop's lines, nor the kept
day, nor the chart sorted them. They are sorted now, a time given twice
kept once, as the lines are read and as a kept day is opened, and
WorkoutChart draws any points in the order of their x, from the least
to the most.

The heart rate spiked down: the band writes 0 where it lost contact,
and each run's mean, the chart's one point a run, counted those in. A
value that isn't a heart's rate is 0, none read, in its place (so the
samples after keep their times), left out of every mean, days kept
before included; the chart draws the samples a point a minute, averaged
across runs, rather than one mean a run, which zigzagged where runs lay
close. ADR 0030, D65.

Whats-New: A day's skin temperature no longer runs backwards, and its heart rate no longer drops where the band lost contact.
Whats-New-De: Die Hauttemperatur eines Tages läuft nicht mehr rückwärts, und sein Puls fällt nicht mehr ab, wo das Band den Kontakt verlor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-04 20:48:42 +01:00
commit ae3121310a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 232 additions and 16 deletions

View file

@ -0,0 +1,63 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// A day's 24/7 heart rate as its chart draws it (ADR 0030, D37, D65): the
/// samples a point a minute, what the band couldn't read left out.
void main() {
test("a run's mean leaves out what the band couldn't read", () {
const run = HeartRun(
at: Duration.zero,
trigger: HeartTrigger.timed,
bpm: [70, 0, 72, 0],
);
const none = HeartRun(
at: Duration.zero,
trigger: HeartTrigger.timed,
bpm: [0, 0],
);
expect(run.mean, 71);
expect(none.mean, isNull);
});
test('the samples come a point a minute, averaged across runs, in time '
'order, half a run lost contact or not', () {
const at = Duration(hours: 7, minutes: 30);
final points = heartByMinute([
// Later, a run of its own.
HeartRun(
at: at + const Duration(minutes: 10),
trigger: HeartTrigger.highActivity,
bpm: const [140, 142],
),
// Two minutes a sample a second, every other one not read.
HeartRun(
at: at,
trigger: HeartTrigger.timed,
bpm: [
for (var i = 0; i < 120; i++)
if (i.isOdd) 0 else if (i < 60) 60 else 64,
],
),
// Within the first minute too.
HeartRun(
at: at + const Duration(seconds: 50),
trigger: HeartTrigger.manual,
bpm: const [90],
),
// Nothing read at all.
HeartRun(
at: at + const Duration(minutes: 20),
trigger: HeartTrigger.timed,
bpm: const [0, 0, 0],
),
]);
expect(points, [
// 30 samples of 60 and one of 90.
(at, 61),
(at + const Duration(minutes: 1), 64),
(at + const Duration(minutes: 10), 141),
]);
});
}

View file

@ -65,6 +65,25 @@ void main() {
expect(decodeHeart(encodeHeart(heart)), heart);
});
test('a day kept with its skin temperature out of order comes back in '
'order, a time given twice once (D65)', () {
final kept = DayContent(
date: date,
skin: const [
(Duration(minutes: 2), 33.5),
(Duration.zero, 33.1),
(Duration(minutes: 1), 33.3),
(Duration.zero, 33.1),
],
);
expect(decodeDay(encodeDay(kept))!.skin, const [
(Duration.zero, 33.1),
(Duration(minutes: 1), 33.3),
(Duration(minutes: 2), 33.5),
]);
});
test('a day with nothing but its date comes back so', () {
final bare = DayContent(date: date);

View file

@ -188,6 +188,48 @@ void main() {
},
);
test('skin temperature comes in time order, a time given twice once '
'(D65)', () {
final lines = LoopLines()
..add(const {
'kind': 'skin',
'date': '2026-10-28',
'samples': [
[120000, 33.5],
[0, 33.1],
[60000, 33.3],
[0, 40.0],
[60000, 33.3],
],
});
expect(lines.skins[today], const [
(Duration.zero, 33.1),
(Duration(minutes: 1), 33.3),
(Duration(minutes: 2), 33.5),
]);
});
test("heart rate the band couldn't read, 0 or beyond a heart's, counts "
'as none read, in its place (D65)', () {
final lines = LoopLines()
..add(const {
'kind': 'heart',
'date': '2026-10-27',
'runs': [
{
'at': 600,
'trigger': 'timed',
'bpm': [60, 0, 62, 0, 300, -1],
},
],
});
final run = lines.hearts[yesterday]!.single;
expect(run.bpm, [60, 0, 62, 0, 0, 0]);
expect(run.mean, 61);
});
test('a later line of a kind and date replaces an earlier one', () {
final lines = LoopLines()
..add(summary)

View file

@ -24,6 +24,21 @@ void main() {
),
);
test('its points are drawn in the order of their x, never backwards '
'(D65); points in order are taken as they are', () {
const sorted = [(x: 0.0, value: 1.0), (x: 1.0, value: 2.0)];
expect(
chartInOrder(const [
(x: 2.0, value: 3.0),
(x: 0.0, value: 1.0),
(x: 1.0, value: 2.0),
]),
[(x: 0.0, value: 1.0), (x: 1.0, value: 2.0), (x: 2.0, value: 3.0)],
);
expect(identical(chartInOrder(sorted), sorted), isTrue);
});
testWidgets('held, the value and its moment under the finger show beside '
'the title, following the finger, and go when it lifts', (
tester,