feat(train): Days, the Loop's days and nights kept sealed (ADR 0030, D37, D38)

What the band read brings, from the bridge or the background job's
file (`LoopLines`), is kept by `DayRepository`: one sealed `day` and
`night` a date and the day's heart rate in chunks of three hours, every
ID made from the person and the date, a later read replacing what it
brought and keeping the rest, nothing rewritten unchanged; versioned,
gzipped JSON (`day_payload.dart`). `PolarLoop` reads the band as each
connection is ready (every half hour at most, or when asked), takes in
the background file as the app opens, and remembers up to which day
the days are sealed, which the next read then deletes on the band;
it keeps the background job on the band and from the right day, and
`Let the band go` stops it.

Home gains a Days tab, each day with its steps, active time, sleep and
Nightly Recharge, pulled to read the band; a day's page shows its
figures, its time in each class, its heart rate, the night's phases
and rating, Nightly Recharge and skin temperature.

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 19:53:12 +01:00
commit 5250f32329
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_platform.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/loop_store.dart';
import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
@ -13,6 +14,7 @@ import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platfo
import '../../support/fake_loop_platform.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_day_gateway.dart';
import '../../support/memory_profile_gateway.dart';
/// The person's Loop on this phone (ADR 0030, D34, D35): set up with their
@ -35,14 +37,35 @@ void main() {
late Directory folder;
late TrainKeys keys;
late ProfileRepository profile;
late DayRepository days;
late FakeLoopPlatform platform;
var now = DateTime.utc(2026, 10, 28, 9);
final opened = <PolarLoop>[];
/// What was asked of the band but its days, which their own group checks.
List<String> bandCalls() => [
for (final call in platform.calls)
if (!call.startsWith('readDays') && !call.startsWith('background')) call,
];
/// The reads of the band's days asked for, in order.
List<String> reads() => [
for (final call in platform.calls)
if (call.startsWith('readDays')) call,
];
LoopStore store() => LoopStore(File('${folder.path}/loop.json'));
PolarLoop open() {
final loop = PolarLoop(platform, store(), profile, today: () => today)
..start();
final loop = PolarLoop(
platform,
store(),
profile,
days: days,
backgroundLines: File('${folder.path}/loop_days.txt'),
today: () => today,
now: () => now,
)..start();
opened.add(loop);
return loop;
}
@ -66,6 +89,8 @@ void main() {
person: 'person',
);
await profile.save(body);
days = DayRepository(MemoryDayGateway(), keys, person: 'person');
now = DateTime.utc(2026, 10, 28, 9);
platform = FakeLoopPlatform()..inReach.add('A1');
});
@ -75,6 +100,7 @@ void main() {
}
opened.clear();
await profile.dispose();
await days.dispose();
keys.dispose();
folder.deleteSync(recursive: true);
});
@ -124,7 +150,7 @@ void main() {
final loop = open();
await settled(loop);
expect(platform.calls, ['connect A1', 'setClock A1']);
expect(bandCalls(), ['connect A1', 'setClock A1']);
platform.goAway('A1');
await settled(loop);
@ -133,7 +159,7 @@ void main() {
platform.comeInReach('A1');
await settled(loop);
expect(platform.calls, ['connect A1', 'setClock A1', 'setClock A1']);
expect(bandCalls(), ['connect A1', 'setClock A1', 'setClock A1']);
expect(loop.status.connected, isTrue);
});
@ -177,7 +203,7 @@ void main() {
await settled(loop);
expect(loop.status.profileChanged, isTrue);
expect(platform.calls, isEmpty);
expect(bandCalls(), isEmpty);
await loop.sendProfile();
await settled(loop);
@ -226,4 +252,140 @@ void main() {
expect(platform.calls.last, 'disconnect A1');
});
group("the band's days (D37, D38)", () {
final yesterday = DateTime.utc(2026, 10, 27);
Map<String, Object?> summary(String date, int steps) => {
'kind': 'summary',
'date': date,
'steps': steps,
};
/// A band set up and remembered, as the app opens again.
Future<PolarLoop> setUpBand() async {
final first = open();
await first.setUp(band, loopFiguresOf(body, today)!);
await settled(first);
await opened.removeLast().dispose();
platform.calls.clear();
return open();
}
test(
'as the band is ready, its days are read from four weeks back, '
'kept sealed, and from then on deleted on it up to yesterday',
() async {
platform.days.add([
summary('2026-10-27', 9876),
summary('2026-10-28', 1200),
]);
final loop = await setUpBand();
await settled(loop);
expect(platform.calls, contains('readDays A1 2026-09-30 -'));
final all = await days.all();
expect(all.map((day) => day.day?.steps), [1200, 9876]);
expect(loop.status.loop?.keptUntil, yesterday);
expect(store().read()?.keptUntil, yesterday);
await loop.readDays();
expect(reads().last, 'readDays A1 2026-10-28 2026-10-27');
},
);
test('the band is read again on a connection only after half an hour, '
'or when asked', () async {
final loop = await setUpBand();
await settled(loop);
platform
..goAway('A1')
..comeInReach('A1');
await settled(loop);
expect(
reads(),
hasLength(1),
);
now = now.add(const Duration(minutes: 31));
platform
..goAway('A1')
..comeInReach('A1');
await settled(loop);
expect(
reads(),
hasLength(2),
);
});
test("what wasn't sealed isn't deleted: a read without the keys keeps "
'nothing, and the next deletes nothing', () async {
final keyless = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => null,
);
addTearDown(keyless.dispose);
await keyless.refresh();
days = DayRepository(MemoryDayGateway(), keyless, person: 'person');
platform.days.add([summary('2026-10-27', 9876)]);
final loop = await setUpBand();
await settled(loop);
expect(loop.status.loop?.keptUntil, isNull);
await loop.readDays();
expect(reads().last, 'readDays A1 2026-09-30 -');
});
test('the background job reads into the file what the app keeps as it '
'opens; the file goes once kept', () async {
final loop = await setUpBand();
await settled(loop);
await opened.removeLast().dispose();
platform.inReach.remove('A1');
File('${folder.path}/loop_days.txt').writeAsStringSync(
'{"kind":"read","on":"2026-10-30","from":"2026-10-28","to":"2026-10-30"}\n'
'{"kind":"summary","date":"2026-10-29","steps":4321}\n',
);
final again = open();
await settled(again);
expect((await days.all()).first.day?.steps, 4321);
expect(again.status.loop?.keptUntil, DateTime.utc(2026, 10, 29));
expect(File('${folder.path}/loop_days.txt').existsSync(), isFalse);
expect(platform.backgroundConfig?.toMap(), {
'folder': folder.path,
'id': 'A1',
'from': '2026-10-30',
'keptUntil': '2026-10-29',
});
});
test("a read that fails keeps what was kept, and isn't counted", () async {
final loop = await setUpBand();
await settled(loop);
final before = loop.status.loop;
platform.inReach.remove('A1');
now = now.add(const Duration(hours: 1));
await loop.readDays();
expect(reads().last, startsWith('readDays'));
expect(loop.status.loop?.lastRead, before?.lastRead);
expect(loop.status.loop?.keptUntil, before?.keptUntil);
});
test('letting the band go stops the background job', () async {
final loop = await setUpBand();
await settled(loop);
expect(platform.backgroundConfig, isNotNull);
await loop.letGo();
expect(platform.backgroundConfig, isNull);
});
});
}