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
5 changed files with 4079 additions and 28 deletions

View file

@ -0,0 +1,134 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_payload.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// A day, a night and a day's heart rate as their rows' payloads (ADR 0030,
/// D37): versioned JSON, gzipped before sealing.
void main() {
final date = DateTime.utc(2026, 10, 27);
final day = DayContent(
date: date,
steps: 9876,
metres: 7012.5,
activityCalories: 420,
trainingCalories: 120,
bmrCalories: 1500,
goal: 100,
achieved: 87.5,
times: const {
ActivityClass.sleep: Duration(hours: 7, minutes: 30),
ActivityClass.light: Duration(hours: 3),
},
recharge: const Recharge(ansStatus: 2.5, recovery: 4, rri: 1050, rmssd: 48),
skin: const [(Duration.zero, 33.1), (Duration(minutes: 1), 33.3)],
);
final night = NightContent(
date: date,
start: DateTime.utc(2026, 10, 26, 22, 15),
end: DateTime.utc(2026, 10, 27, 6, 5),
goalMinutes: 480,
phases: const [
(Duration.zero, SleepState.wake),
(Duration(minutes: 10), SleepState.light),
(Duration(hours: 2), SleepState.deep),
],
cycles: const [(Duration(minutes: 10), 1.5)],
rating: SleepRating.somewhatWell,
batteryRanOut: false,
skinCelsius: 33.4,
skinDeviation: -0.2,
);
const heart = [
HeartRun(
at: Duration(minutes: 10),
trigger: HeartTrigger.timed,
bpm: [58, 57, 59],
),
HeartRun(
at: Duration(hours: 2),
trigger: HeartTrigger.highActivity,
bpm: [141],
),
];
Map<String, Object?> json(ByteData payload) => jsonDecode(
utf8.decode(GZipCodec().decode(Uint8List.sublistView(payload))),
) as Map<String, Object?>;
test('a day, a night and heart rate come back as they were written', () {
expect(decodeDay(encodeDay(day)), day);
expect(decodeNight(encodeNight(night)), night);
expect(decodeHeart(encodeHeart(heart)), heart);
});
test('a day with nothing but its date comes back so', () {
final bare = DayContent(date: date);
expect(decodeDay(encodeDay(bare)), bare);
expect(
decodeNight(encodeNight(NightContent(date: date))),
NightContent(date: date),
);
});
test('each is gzipped JSON with its schema version and the date', () {
expect(
json(encodeDay(day)),
containsPair('schemaVersion', daySchemaVersion),
);
expect(json(encodeDay(day)), containsPair('date', '2026-10-27'));
expect(json(encodeNight(night)), containsPair('date', '2026-10-27'));
expect(
json(encodeHeart(heart)),
containsPair('schemaVersion', daySchemaVersion),
);
});
test('a blank payload, a newer schema and garbage are skipped', () {
ByteData gzipped(Map<String, Object?> json) => ByteData.sublistView(
Uint8List.fromList(GZipCodec().encode(utf8.encode(jsonEncode(json)))),
);
final newer = gzipped({
'schemaVersion': daySchemaVersion + 1,
'date': '2026-10-27',
});
for (final payload in [ByteData(0), newer, ByteData(3)]) {
expect(decodeDay(payload), isNull);
expect(decodeNight(payload), isNull);
expect(decodeHeart(payload), isNull);
}
});
test("a state, a class or a trigger this app doesn't know is unknown or "
'left out, the rest read', () {
final payload = ByteData.sublistView(
Uint8List.fromList(
GZipCodec().encode(
utf8.encode(
jsonEncode({
'schemaVersion': daySchemaVersion,
'date': '2026-10-27',
'phases': [
[0, 'dreaming'],
],
'rating': 'blissfully',
}),
),
),
),
);
expect(
decodeNight(payload),
NightContent(
date: date,
phases: const [(Duration.zero, SleepState.unknown)],
),
);
});
}

View file

@ -0,0 +1,296 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.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/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_day_gateway.dart';
/// The person's days and nights from the Loop (ADR 0030, D37), sealed on the
/// way to the instance and opened on the way back (D7), with libsodium as
/// its build hook compiles it.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late MemoryContentKeyGateway keyRows;
late MemoryDayGateway rows;
final opened = <TrainKeys>[];
final monday = DateTime.utc(2026, 10, 26);
final tuesday = DateTime.utc(2026, 10, 27);
Map<String, Object?> summary(DateTime date, int steps) => {
'kind': 'summary',
'date': dayKey(date),
'steps': steps,
};
Map<String, Object?> heart(DateTime date, List<int> hours) => {
'kind': 'heart',
'date': dayKey(date),
'runs': [
for (final hour in hours)
{
'at': hour * 3600,
'trigger': 'timed',
'bpm': [60 + hour],
},
],
};
final sleep = {
'kind': 'sleep',
'date': dayKey(tuesday),
'start': '2026-10-26T22:00:00Z',
'end': '2026-10-27T06:00:00Z',
'rating': 'well',
};
final recharge = {'kind': 'recharge', 'date': dayKey(tuesday), 'rmssd': 48};
final skin = {
'kind': 'skin',
'date': dayKey(tuesday),
'samples': [
[0, 33.1],
],
};
LoopLines linesOf(List<Map<String, Object?>> lines) {
final read = LoopLines();
lines.forEach(read.add);
return read;
}
/// A device of the account whose keys are [account] (none: signed in
/// without them), on the shared rows.
Future<DayRepository> device(UnlockedAccountKeys? account) async {
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys);
await keys.refresh();
return DayRepository(rows, keys, person: 'alice');
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemoryDayGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('what a read brought is kept, one day and one night a date, and '
'reads on another device', () async {
final phone = await device(alice);
await phone.take(
linesOf([
summary(tuesday, 9876),
heart(tuesday, [1, 2, 7]),
sleep,
recharge,
skin,
]),
);
final laptop = await device(alice);
final [day] = await laptop.all();
expect(day.date, tuesday);
expect(day.day?.steps, 9876);
expect(day.day?.recharge?.rmssd, 48);
expect(day.day?.skin, const [(Duration.zero, 33.1)]);
expect(day.night?.rating, SleepRating.well);
expect((await laptop.heart(tuesday)).map((run) => run.at.inHours), [
1,
2,
7,
]);
});
test("a day's heart rate goes in chunks of three hours, made from the "
"day's ID, so a read again rewrites the same rows", () async {
final phone = await device(alice);
await phone.take(
linesOf([
heart(tuesday, [1, 2, 7, 23]),
]),
);
final ids = rows.seriesRows.keys.toSet();
await phone.take(
linesOf([
heart(tuesday, [1, 2, 7, 23, 8]),
]),
);
expect(ids, hasLength(3));
expect(rows.seriesRows.keys.toSet(), ids);
expect((await phone.heart(tuesday)).map((run) => run.at.inHours), [
1,
2,
7,
8,
23,
]);
});
test('a chunk the day no longer has goes', () async {
final phone = await device(alice);
await phone.take(
linesOf([
heart(tuesday, [1, 7]),
]),
);
await phone.take(
linesOf([
heart(tuesday, [1]),
]),
);
expect(rows.seriesRows, hasLength(1));
});
test('a later read replaces what it brought and keeps what it '
"didn't", () async {
final phone = await device(alice);
await phone.take(
linesOf([
summary(tuesday, 5000),
heart(tuesday, [1]),
recharge,
skin,
]),
);
await phone.take(linesOf([summary(tuesday, 9876)]));
final [day] = await phone.all();
expect(day.day?.steps, 9876);
expect(day.day?.recharge?.rmssd, 48);
expect(day.day?.skin, isNotEmpty);
expect(await phone.heart(tuesday), hasLength(1));
});
test('nothing is written where a read brought nothing new', () async {
final phone = await device(alice);
final lines = linesOf([
summary(tuesday, 9876),
heart(tuesday, [1]),
sleep,
]);
await phone.take(lines);
final writes = rows.written.length;
await phone.take(lines);
expect(rows.written, hasLength(writes));
});
test('days come newest first, each with the night it ended', () async {
final phone = await device(alice);
await phone.take(linesOf([summary(monday, 1), summary(tuesday, 2), sleep]));
final days = await phone.all();
expect(days.map((day) => day.date), [tuesday, monday]);
expect(days.map((day) => day.night != null), [true, false]);
});
test('nothing the instance gets is readable: every row is sealed, no '
'JSON', () async {
final phone = await device(alice);
await phone.take(
linesOf([
summary(tuesday, 9876),
heart(tuesday, [1]),
sleep,
]),
);
expect(rows.written, hasLength(3));
for (final envelope in rows.written) {
expect(envelope.keyId, startsWith('train1.'));
expect(envelope.nonce, isNotNull);
final bytes = Uint8List.sublistView(envelope.payload);
expect(utf8.decode(bytes, allowMalformed: true), isNot(contains('2026')));
expect(bytes.take(2), isNot([0x1f, 0x8b]));
}
});
test(
"a device without the account's keys sees no day and keeps none",
() async {
final phone = await device(alice);
await phone.take(linesOf([summary(tuesday, 9876)]));
final writes = rows.written.length;
final elsewhere = await device(null);
expect(await elsewhere.all(), isEmpty);
await expectLater(
elsewhere.take(linesOf([summary(monday, 1)])),
throwsA(isA<KeysMissing>()),
);
expect(rows.written, hasLength(writes));
},
);
test("a row the instance moved, made up or changed doesn't open: a day "
'as a night, a chunk on another day, a readable day', () async {
final phone = await device(alice);
await phone.take(
linesOf([
summary(monday, 1),
heart(monday, [1]),
summary(tuesday, 2),
sleep,
]),
);
final tuesdayId = (await phone.all()).first.id;
final mondayId = (await phone.all()).last.id;
final nightId = rows.nightRows.keys.single;
final chunk = rows.seriesRows.values.single;
rows.nightRows[nightId] = rows.dayRows[tuesdayId]!;
rows.seriesRows[chunk.id] = (
id: chunk.id,
dayId: tuesdayId,
envelope: chunk.envelope,
);
rows.dayRows[mondayId] = (
keyId: null,
nonce: null,
payload: ByteData.sublistView(
Uint8List.fromList(
utf8.encode('{"schemaVersion":1,"date":"2026-10-26"}'),
),
),
);
final days = await phone.all();
expect(days.map((day) => day.date), [tuesday]);
expect(days.single.night, isNull);
expect(await phone.heart(tuesday), isEmpty);
expect(await phone.heart(monday), isEmpty);
});
test('changes, here and from other devices, are announced', () async {
final phone = await device(alice);
final announced = expectLater(phone.changes, emitsInOrder([null, null]));
await phone.take(linesOf([summary(tuesday, 1)]));
rows.bringRemoteChanges();
await announced;
});
}

View file

@ -0,0 +1,79 @@
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/bloc/day_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/day/bloc/days_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/day_repository.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_day_gateway.dart';
/// Home's days and a day's page (ADR 0030, D37), following what is kept.
void main() {
late TrainKeys keys;
late MemoryDayGateway rows;
late DayRepository days;
final tuesday = DateTime.utc(2026, 10, 27);
LoopLines read(int steps) => LoopLines()
..add({'kind': 'summary', 'date': '2026-10-27', 'steps': steps})
..add({
'kind': 'heart',
'date': '2026-10-27',
'runs': [
{
'at': 600,
'trigger': 'timed',
'bpm': [58],
},
],
})
..add({'kind': 'sleep', 'date': '2026-10-27', 'rating': 'well'});
setUp(() {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
rows = MemoryDayGateway();
days = DayRepository(rows, keys, person: 'alice');
});
tearDown(() async {
await days.dispose();
keys.dispose();
});
test('home lists the days kept, and follows them as they change', () async {
final cubit = DaysCubit(days, null);
addTearDown(cubit.close);
await cubit.open();
expect(cubit.state.loaded, isTrue);
expect(cubit.state.days, isEmpty);
expect(cubit.state.holdsLoop, isFalse);
await days.take(read(9876));
await pumpEventQueue();
expect(cubit.state.days.single.day?.steps, 9876);
});
test("a day's page shows the day, its night and its heart rate, and "
'follows them', () async {
await days.take(read(5000));
final cubit = DayCubit(days, tuesday);
addTearDown(cubit.close);
await cubit.open();
expect(cubit.state.day?.steps, 5000);
expect(cubit.state.night, isNotNull);
expect(cubit.state.heart, hasLength(1));
await days.take(read(9876));
await pumpEventQueue();
expect(cubit.state.day?.steps, 9876);
});
}

View file

@ -0,0 +1,215 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/day/model/day.dart';
/// The lines `LoopDays.kt` makes of a read (ADR 0030, D37), as the app
/// takes them: the same names and units as `LoopDaysTest.kt` checks.
void main() {
final yesterday = DateTime.utc(2026, 10, 27);
final today = DateTime.utc(2026, 10, 28);
const read = {
'kind': 'read',
'on': '2026-10-28',
'from': '2026-10-27',
'to': '2026-10-28',
};
const summary = {
'kind': 'summary',
'date': '2026-10-27',
'steps': 9876,
'metres': 7012.5,
'activityCalories': 420,
'trainingCalories': 120,
'bmrCalories': 1500,
'goal': 100.0,
'achieved': 87.5,
'times': {
'sleep': 27000,
'intermittentVigorous': 300,
'flying': 60,
'light': null,
},
};
const heart = {
'kind': 'heart',
'date': '2026-10-27',
'runs': [
{
'at': 600,
'trigger': 'timed',
'bpm': [58, 57, 59],
},
{
'at': 63005,
'trigger': 'high_activity',
'bpm': [141],
},
{
'at': 70000,
'trigger': 'sideways',
'bpm': [80],
},
],
};
const sleep = {
'kind': 'sleep',
'date': '2026-10-28',
'start': '2026-10-27T23:15+01:00',
'end': '2026-10-28T07:05+01:00',
'goalMinutes': 480,
'phases': [
[0, 'wake'],
[600, 'nonrem12'],
[7200, 'nonrem3'],
[9000, 'rem'],
],
'cycles': [
[600, 1.5],
],
'rating': 'somewhat_well',
'batteryRanOut': false,
'skinCelsius': 33.4,
'skinDeviation': -0.2,
};
const recharge = {
'kind': 'recharge',
'date': '2026-10-28',
'ansStatus': 2.5,
'recovery': 4,
'rri': 1050,
'rmssd': 48,
};
const skin = {
'kind': 'skin',
'date': '2026-10-28',
'samples': [
[0, 33.1],
[60000, 33.3],
],
};
test('a summary keeps its figures and its time in each class', () {
final lines = LoopLines()..add(summary);
expect(
lines.summaries[yesterday],
DayContent(
date: yesterday,
steps: 9876,
metres: 7012.5,
activityCalories: 420,
trainingCalories: 120,
bmrCalories: 1500,
goal: 100,
achieved: 87.5,
times: const {
ActivityClass.sleep: Duration(hours: 7, minutes: 30),
ActivityClass.intermittentVigorous: Duration(minutes: 5),
},
),
);
});
test('heart rate keeps each run, a trigger unknown as such', () {
final lines = LoopLines()..add(heart);
expect(lines.hearts[yesterday], const [
HeartRun(
at: Duration(minutes: 10),
trigger: HeartTrigger.timed,
bpm: [58, 57, 59],
),
HeartRun(
at: Duration(hours: 17, minutes: 30, seconds: 5),
trigger: HeartTrigger.highActivity,
bpm: [141],
),
HeartRun(
at: Duration(seconds: 70000),
trigger: HeartTrigger.unknown,
bpm: [80],
),
]);
});
test(
"a night counts for the day it ended, Polar's stages as light and deep",
() {
final lines = LoopLines()..add(sleep);
expect(
lines.nights[today],
NightContent(
date: today,
start: DateTime.utc(2026, 10, 27, 22, 15),
end: DateTime.utc(2026, 10, 28, 6, 5),
goalMinutes: 480,
phases: const [
(Duration.zero, SleepState.wake),
(Duration(minutes: 10), SleepState.light),
(Duration(hours: 2), SleepState.deep),
(Duration(hours: 2, minutes: 30), SleepState.rem),
],
cycles: const [(Duration(minutes: 10), 1.5)],
rating: SleepRating.somewhatWell,
batteryRanOut: false,
skinCelsius: 33.4,
skinDeviation: -0.2,
),
);
},
);
test('Nightly Recharge and skin temperature keep their figures', () {
final lines = LoopLines()
..add(recharge)
..add(skin);
expect(
lines.recharges[today],
const Recharge(ansStatus: 2.5, recovery: 4, rri: 1050, rmssd: 48),
);
expect(lines.skins[today], const [
(Duration.zero, 33.1),
(Duration(minutes: 1), 33.3),
]);
expect(lines.dates, {today});
});
test(
'a read tells until when every day was seen whole: the day before it',
() {
final lines = LoopLines()..add(read);
expect(lines.isEmpty, isTrue);
expect(lines.completeUntil, yesterday);
expect(LoopLines().completeUntil, isNull);
},
);
test('a later line of a kind and date replaces an earlier one', () {
final lines = LoopLines()
..add(summary)
..add({...summary, 'steps': 10000});
expect(lines.summaries[yesterday]?.steps, 10000);
});
test("lines without a date, of a kind unknown or that aren't JSON are "
'skipped', () {
final lines = LoopLines()
..add({...summary, 'date': null})
..add({'kind': 'weather', 'date': '2026-10-27'})
..add('summary')
..addText(
'{"kind":"summary","date":"2026-10-27","steps":5}\n'
'\n'
'{"kind":"heart","date":"2026-10-27","runs":[{"at":6',
);
expect(lines.dates, {yesterday});
expect(lines.summaries[yesterday]?.steps, 5);
expect(lines.hearts, isEmpty);
});
}

View file

@ -0,0 +1,100 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.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/day/data/loop_lines.dart';
import 'package:halkyon_train_flutter/src/feature/day/data/sync_day_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
// The days on the device's own database, as the sync engine keeps them, with
// libsodium as its build hook compiles it.
final _person = protocol.UuidValue.withValidation(
'01a16000-0000-7000-8000-000000000001',
);
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late Directory folder;
late OfflineSyncDatabaseSession db;
late SyncService sync;
late TrainKeys keys;
late DayRepository days;
final tuesday = DateTime.utc(2026, 10, 27);
LoopLines read(List<int> hours, {int steps = 9876}) => LoopLines()
..add({'kind': 'summary', 'date': '2026-10-27', 'steps': steps})
..add({
'kind': 'heart',
'date': '2026-10-27',
'runs': [
for (final hour in hours)
{
'at': hour * 3600,
'trigger': 'timed',
'bpm': [60],
},
],
})
..add({'kind': 'sleep', 'date': '2026-10-27', 'rating': 'well'});
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('days');
db = await protocol.Client(
'http://127.0.0.1:9/',
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
sync = SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
);
keys = TrainKeys(
SyncContentKeyGateway(db, sync),
SodiumContentCipher(),
() async => alice,
);
await keys.refresh();
days = DayRepository(SyncDayGateway(db, sync), keys, person: 'alice');
});
tearDown(() async {
await days.dispose();
keys.dispose();
await sync.dispose();
await db.close();
await folder.delete(recursive: true);
});
test('a day, its heart rate and its night are kept, and read back '
'opened', () async {
await days.take(read([1, 7]));
final [stored] = await days.all();
expect(stored.date, tuesday);
expect(stored.day?.steps, 9876);
expect(stored.night, isNotNull);
expect((await days.heart(tuesday)).map((run) => run.at.inHours), [1, 7]);
});
test('a day read again rewrites its rows, a chunk it no longer has '
'deleted', () async {
await days.take(read([1, 7]));
await days.take(read([1], steps: 10000));
expect(await protocol.Day.db.count(db), 1);
expect(await protocol.Night.db.count(db), 1);
expect(await protocol.DaySeries.db.count(db), 1);
expect((await days.all()).single.day?.steps, 10000);
expect(await days.heart(tuesday), hasLength(1));
});
}