From a5c2b8ebd21fbc95c2b6e142186fc4201df964d7 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:55:38 +0200 Subject: [PATCH 1/3] Weight trend: include scale weighings alongside the journal's weight --- lib/compute/inputs/measurement_inputs.dart | 63 ++++-- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/ui2/screens/journal_compose.dart | 62 +++++- test/weight_trend_scale_test.dart | 219 +++++++++++++++++++++ 6 files changed, 326 insertions(+), 24 deletions(-) create mode 100644 test/weight_trend_scale_test.dart diff --git a/lib/compute/inputs/measurement_inputs.dart b/lib/compute/inputs/measurement_inputs.dart index a24dcc227..a04cb22eb 100644 --- a/lib/compute/inputs/measurement_inputs.dart +++ b/lib/compute/inputs/measurement_inputs.dart @@ -28,12 +28,46 @@ const double kWeighingBand = 0.10; bool nearWeight(num kg, num? near) => near == null || near <= 0 || (kg - near).abs() <= near * kWeighingBand; -/// The newest weighing any paired health-measurement device with its flag -/// on (rule R6, [wearableEnabled]) banked (kg, epoch ms), or null. With -/// [near] (the profile's weight), only weighings within [kWeighingBand] of -/// it count. -Future<({double kg, int atMs})?> newestWeighing({num? near}) async { - ({double kg, int atMs})? newest; +/// The household-band chain over [ws], in time order: the first weighing +/// seeds it and a later one is kept while within [kWeighingBand] of the +/// previous kept one. A run of 3 consecutive rejected weighings, each near +/// the one before it, is a lasting change (a guest seeded the chain, or a +/// real step in weight), so the whole run is kept and its last member becomes +/// the anchor; a rejected weighing that breaks the run starts a new run of 1. +/// Pure and deterministic: same input, same output, however often it runs. +List<({double kg, int atMs})> weighingChain( + Iterable<({double kg, int atMs})> ws, +) { + final sorted = [...ws] + ..sort((a, b) { + final c = a.atMs.compareTo(b.atMs); + return c != 0 ? c : a.kg.compareTo(b.kg); + }); + final chain = <({double kg, int atMs})>[]; + var run = <({double kg, int atMs})>[]; + for (final w in sorted) { + if (chain.isEmpty || nearWeight(w.kg, chain.last.kg)) { + chain.add(w); + run = []; + continue; + } + run = run.isNotEmpty && nearWeight(w.kg, run.last.kg) ? [...run, w] : [w]; + if (run.length >= 3) { + chain.addAll(run); + run = []; + } + } + return chain; +} + +/// Every weighing (kg, epoch ms) of a paired health-measurement device with +/// its flag on (rule R6, [wearableEnabled]), from [fromMs]. With [near] (the +/// profile's weight), only weighings within [kWeighingBand] of it count. +Future> weighings({ + num? near, + int fromMs = 0, +}) async { + final out = <({double kg, int atMs})>[]; for (final row in await LocalDb.deviceRows()) { final adapter = row['adapter_id'] as String?; if (adapter == null || @@ -42,14 +76,21 @@ Future<({double kg, int atMs})?> newestWeighing({num? near}) async { continue; } final w = await LocalDb.deviceObservationValues( - row['id'] as String, 'weight'); + row['id'] as String, 'weight', + fromMs: fromMs); for (final MapEntry(key: at, value: kg) in w.entries) { - if (!nearWeight(kg, near)) continue; - if (newest == null || at > newest.atMs) { - newest = (kg: kg.toDouble(), atMs: at); - } + if (nearWeight(kg, near)) out.add((kg: kg.toDouble(), atMs: at)); } } + return out; +} + +/// The newest of [weighings], or null. +Future<({double kg, int atMs})?> newestWeighing({num? near}) async { + ({double kg, int atMs})? newest; + for (final w in await weighings(near: near)) { + if (newest == null || w.atMs > newest.atMs) newest = w; + } return newest; } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b6c5cd7ab..6567c8509 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -811,7 +811,7 @@ "journalComposeNotEnoughEntriesTitle": "Nicht genug Einträge für einen Verlauf", "journalComposeNotEnoughEntriesBody": "Die Linie ist ein Sieben-Tage-Durchschnitt deiner Einträge und braucht daher mindestens zwei Tage. Lücken dazwischen werden nicht aufgefüllt.", "journalComposeSevenDayTrend": "Sieben-Tage-Trend", - "journalComposeTrendFootnote": "Von dir eingegeben. Tage ohne Eintrag bleiben leer.", + "journalComposeTrendFootnote": "Von dir oder deiner Waage eingegeben. Tage ohne Eintrag bleiben leer.", "journalComposeWeightTrendExplainer": "Von dir oder deiner Waage eingetragen – das Band misst kein Gewicht. Angezeigt wird ein Sieben-Tage-Durchschnitt, denn allein durch Wasser und Essen schwankt die Waage um ein bis zwei Kilo, und die Rohwerte würden das wie eine echte Veränderung aussehen lassen. {count, plural, one{{count} Tag eingetragen.} other{{count} Tage eingetragen.}}", "nutritionTabToday": "Heute", "nutritionTabWeek": "Woche", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5a99c0202..ff2fc9266 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3846,7 +3846,7 @@ "@journalComposeSevenDayTrend": { "description": "Title of the weight trend chart." }, - "journalComposeTrendFootnote": "Entered by you. Days with no entry are left empty.", + "journalComposeTrendFootnote": "Entered by you or your scale. Days with no entry are left empty.", "@journalComposeTrendFootnote": { "description": "Footnote under the weight trend chart." }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 415c2de32..3f603d5e1 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -774,7 +774,7 @@ "journalComposeNotEnoughEntriesTitle": "No hay suficientes registros para una tendencia", "journalComposeNotEnoughEntriesBody": "La línea es un promedio de siete días con lo que introdujiste, así que hacen falta al menos dos días. No se rellena nada entre ellos.", "journalComposeSevenDayTrend": "Tendencia de siete días", - "journalComposeTrendFootnote": "Introducido por ti. Los días sin registro quedan vacíos.", + "journalComposeTrendFootnote": "Introducido por ti o por tu báscula. Los días sin registro quedan vacíos.", "journalComposeWeightTrendExplainer": "Introducido por ti o por tu báscula — la banda no mide el peso. Lo que se dibuja es un promedio de siete días, porque una báscula puede variar uno o dos kilos solo por agua y comida, y las lecturas sin procesar mostrarían eso como algo que le sucede a tu cuerpo. {count, plural, one{Se registró {count} día.} other{Se registraron {count} días.}}", "nutritionTabToday": "Hoy", "nutritionTabWeek": "Semana", diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 0b9ddc44e..82c225878 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -18,6 +18,7 @@ import 'package:provider/provider.dart'; import '../../ai/journal_ai.dart'; import '../../coach/coach_config.dart'; +import '../../compute/inputs/measurement_inputs.dart' show weighingChain, weighings; import '../../data/day_label.dart'; import '../../data/db.dart'; import '../../data/journal_fields.dart'; @@ -917,6 +918,47 @@ class _WeightRow extends StatelessWidget { } } +/// Per-day weight for the trend: the journal's [manual] value and the scale +/// [scaled] weighings (see `weighings`: flag-on, in band), bucketed by LOCAL +/// day, the day's latest weighing standing for it. When a day has both, the +/// later one wins: the journal's time of day against the weighing's, and a +/// journal value with no time loses to the scale. Days with neither are absent. +/// Scale weighings dated after [nowMs] (a band clock set ahead) are dropped. +@visibleForTesting +Map weightByDay( + Map> manual, + List<({double kg, int atMs})> scaled, { + int? nowMs, +}) { + final until = nowMs ?? DateTime.now().millisecondsSinceEpoch; + bool ok(double v) => v.isFinite && v > 0; + final out = {}; + for (final e in manual.entries) { + final v = e.value['weight_kg']; + if (v != null && ok(v.value)) out[e.key] = v.value; + } + // A scale weighs whoever steps on it, and banding against TODAY's profile + // weight would drop a long loss's old readings. So chain instead (see + // weighingChain): off-band weighings are someone else's unless they persist. + final chain = weighingChain( + scaled.where((w) => ok(w.kg) && w.atMs <= until), + ); + final at = {}; + for (final w in chain) { + final t = DateTime.fromMillisecondsSinceEpoch(w.atMs); + final day = dayLabelOf(t); + if (at[day] != null && at[day]! >= w.atMs) continue; + final m = t.hour * 60 + t.minute; + final jm = manual[day]?['weight_kg']?.atMinuteOfDay; + if (out.containsKey(day) && !at.containsKey(day) && jm != null && jm > m) { + continue; + } + at[day] = w.atMs; + out[day] = w.kg; + } + return out; +} + /// The trend, and only the trend. /// /// One line, no headline number, no delta, no arrow, no target and no colour @@ -948,18 +990,18 @@ class _WeightTrendState extends State<_WeightTrend> { DateTime(now.year, now.month, now.day - (kWeightTrendDays - 1)), ); final rows = await LocalDb.journalMetricsByDay(sinceDaysEpoch: since); + // Its own guard: a failing scale read must not blank the manual trend. + var scale = const <({double kg, int atMs})>[]; + try { + scale = await weighings( + fromMs: DateTime.parse(since).millisecondsSinceEpoch, + ); + } catch (e) { + debugPrint('weight trend: scale read failed: $e'); + } if (!mounted) return; setState(() { - _byDay = { - for (final e in rows.entries) - // The `when` does real work — a non-finite or non-positive weight - // is not a weight — and the null-aware element form has nowhere to - // put a guard. - // ignore: use_null_aware_elements - if (e.value['weight_kg']?.value case final v? - when v.isFinite && v > 0) - e.key: v, - }; + _byDay = weightByDay(rows, scale); _loading = false; }); } catch (_) { diff --git a/test/weight_trend_scale_test.dart b/test/weight_trend_scale_test.dart new file mode 100644 index 000000000..8ef9fff24 --- /dev/null +++ b/test/weight_trend_scale_test.dart @@ -0,0 +1,219 @@ +// The weight trend merges scale weighings with the journal's manual weight. +// Pins: one value per LOCAL day, the later of journal/scale wins, a flag-off +// scale and an out-of-band (someone else's) weighing never reach the line. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/inputs/canonical.dart'; +import 'package:openstrap_edge/compute/inputs/measurement_inputs.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/observation.dart'; +import 'package:openstrap_edge/ui2/screens/journal_compose.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.root); + final String root; + @override + Future getTemporaryPath() async => root; + @override + Future getApplicationSupportPath() async => root; + @override + Future getApplicationDocumentsPath() async => root; + @override + Future getApplicationCachePath() async => root; + @override + Future getLibraryPath() async => root; + @override + Future getDownloadsPath() async => root; +} + +int _ms(int d, int h, [int m = 0]) => + DateTime(2026, 10, d, h, m).millisecondsSinceEpoch; + +({double kg, int atMs}) _w(double kg, int atMs) => (kg: kg, atMs: atMs); + +Map> _j( + String day, + double kg, [ + int? atMin, +]) => { + day: {'weight_kg': JournalMetricValue(kg, atMinuteOfDay: atMin)}, +}; + +void main() { + group('weightByDay', () { + test('scale-only day', () { + expect(weightByDay({}, [_w(80, _ms(3, 7))]), {'2026-10-03': 80.0}); + }); + + test('manual-only day', () { + expect(weightByDay(_j('2026-10-03', 79.5), []), {'2026-10-03': 79.5}); + }); + + test('no data stays absent', () { + expect(weightByDay({}, []), isEmpty); + }); + + test('latest weighing of the day stands for it', () { + expect(weightByDay({}, [_w(81, _ms(3, 20)), _w(80, _ms(3, 7))]), { + '2026-10-03': 81.0, + }); + }); + + test('both on one day: the later one wins', () { + // Scale at 07:00 (420), journal at 21:00 (1260): journal is later. + expect(weightByDay(_j('2026-10-03', 79, 1260), [_w(80, _ms(3, 7))]), { + '2026-10-03': 79.0, + }); + // Journal at 06:00, scale at 07:00: scale is later. + expect(weightByDay(_j('2026-10-03', 79, 360), [_w(80, _ms(3, 7))]), { + '2026-10-03': 80.0, + }); + // Journal without a time: the scale wins. + expect(weightByDay(_j('2026-10-03', 79), [_w(80, _ms(3, 7))]), { + '2026-10-03': 80.0, + }); + }); + + test('a gradual 95 to 80 kg loss keeps every weighing', () { + final ws = [ + for (var i = 0; i < 16; i++) + _w(95.0 - i, _ms(1, 7) + i * 86400000), + ]; + expect(weightByDay({}, ws).length, 16); + }); + + test('a household outlier amid ~80 kg is dropped', () { + final out = weightByDay({}, [ + _w(80, _ms(3, 7)), + _w(120, _ms(4, 7)), + _w(79.5, _ms(5, 7)), + ]); + expect(out, {'2026-10-03': 80.0, '2026-10-05': 79.5}); + }); + + test('a guest seeding the chain does not lock the user out', () { + final out = weightByDay({}, [ + _w(20, _ms(1, 7)), + _w(70, _ms(2, 7)), + _w(71, _ms(3, 7)), + _w(70, _ms(4, 7)), + ]); + expect(out['2026-10-02'], 70.0); + expect(out['2026-10-03'], 71.0); + expect(out['2026-10-04'], 70.0); + }); + + test('a lasting step in weight re-seeds after three weighings', () { + final out = weightByDay({}, [ + _w(90, _ms(1, 7)), + _w(79, _ms(2, 7)), + _w(79, _ms(3, 7)), + _w(80, _ms(4, 7)), + _w(80, _ms(5, 7)), + ]); + expect(out, { + '2026-10-01': 90.0, + '2026-10-02': 79.0, + '2026-10-03': 79.0, + '2026-10-04': 80.0, + '2026-10-05': 80.0, + }); + }); + + test('a lone outlier or a two-weighing run stays dropped', () { + final lone = weightByDay({}, [ + _w(70, _ms(1, 7)), + _w(120, _ms(2, 7)), + _w(71, _ms(3, 7)), + ]); + expect(lone, {'2026-10-01': 70.0, '2026-10-03': 71.0}); + final two = weightByDay({}, [ + _w(70, _ms(1, 7)), + _w(120, _ms(2, 7)), + _w(121, _ms(3, 7)), + _w(71, _ms(4, 7)), + ]); + expect(two, {'2026-10-01': 70.0, '2026-10-04': 71.0}); + }); + + test('three unrelated outliers do not re-seed', () { + final out = weightByDay({}, [ + _w(70, _ms(1, 7)), + _w(120, _ms(2, 7)), + _w(40, _ms(3, 7)), + _w(120, _ms(4, 7)), + _w(71, _ms(5, 7)), + ]); + expect(out, {'2026-10-01': 70.0, '2026-10-05': 71.0}); + }); + + test('weighings dated after now are dropped', () { + final out = weightByDay( + {}, + [_w(80, _ms(3, 7)), _w(80, _ms(9, 7))], + nowMs: _ms(5, 7), + ); + expect(out, {'2026-10-03': 80.0}); + }); + + test('buckets by local day near midnight', () { + final out = weightByDay({}, [ + _w(80, DateTime(2026, 10, 3, 23, 59).millisecondsSinceEpoch), + _w(81, DateTime(2026, 10, 4, 0, 1).millisecondsSinceEpoch), + ]); + expect(out, {'2026-10-03': 80.0, '2026-10-04': 81.0}); + }); + }); + + group('weighings gate', () { + late Directory tmp; + const scale = 'scale-1'; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + tmp = await Directory.systemTemp.createTemp('openstrap_weight_trend_'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + LocalDb.dbName = 'openstrap_weight_trend_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + await LocalDb.upsertDevice(id: scale, adapterId: 'miscale_bc'); + for (final (d, kg) in [(3, 78.0), (4, 120.0)]) { + await LocalDb.putObservation( + Observation( + sourceKind: ObservationSource.vendor, + key: 'weight', + value: kg, + unit: 'kg', + attribution: 'Mi Scale', + at: DateTime(2026, 10, d, 7), + ), + deviceId: scale, + ); + } + }); + + tearDownAll(() async { + await LocalDb.close(); + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + test('flag off: excluded; flag on: only in-band weighings', () async { + await LocalDb.setCursor(wearableEnabledCursor('miscale_bc'), '0'); + expect(await weighings(near: 80), isEmpty); + + await LocalDb.setCursor(wearableEnabledCursor('miscale_bc'), '1'); + final w = await weighings(near: 80); // 120 kg is outside the band + expect([for (final x in w) x.kg], [78.0]); + expect(w.single.atMs, _ms(3, 7)); + // No profile weight: nothing to compare against, both count. + expect((await weighings()).length, 2); + }); + }); +} From 16a4d628f4a83e609edb3c8c769d97aa19979e14 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:44:38 +0200 Subject: [PATCH 2/3] Weight trend: local-midnight cutoff, fixed test clock, running max for newestWeighing --- lib/compute/inputs/measurement_inputs.dart | 16 +++++---- lib/ui2/screens/journal_compose.dart | 13 ++++--- test/weight_trend_scale_test.dart | 40 +++++++++++++--------- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/lib/compute/inputs/measurement_inputs.dart b/lib/compute/inputs/measurement_inputs.dart index a04cb22eb..a9b29565a 100644 --- a/lib/compute/inputs/measurement_inputs.dart +++ b/lib/compute/inputs/measurement_inputs.dart @@ -66,8 +66,12 @@ List<({double kg, int atMs})> weighingChain( Future> weighings({ num? near, int fromMs = 0, -}) async { - final out = <({double kg, int atMs})>[]; +}) => _eachWeighing(near: near, fromMs: fromMs).toList(); + +Stream<({double kg, int atMs})> _eachWeighing({ + num? near, + int fromMs = 0, +}) async* { for (final row in await LocalDb.deviceRows()) { final adapter = row['adapter_id'] as String?; if (adapter == null || @@ -79,16 +83,16 @@ Future> weighings({ row['id'] as String, 'weight', fromMs: fromMs); for (final MapEntry(key: at, value: kg) in w.entries) { - if (nearWeight(kg, near)) out.add((kg: kg.toDouble(), atMs: at)); + if (nearWeight(kg, near)) yield (kg: kg.toDouble(), atMs: at); } } - return out; } -/// The newest of [weighings], or null. +/// The newest of [weighings], or null. A running maximum: the whole history +/// is never held at once. Future<({double kg, int atMs})?> newestWeighing({num? near}) async { ({double kg, int atMs})? newest; - for (final w in await weighings(near: near)) { + await for (final w in _eachWeighing(near: near)) { if (newest == null || w.atMs > newest.atMs) newest = w; } return newest; diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 82c225878..63ee51ebe 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -986,15 +986,20 @@ class _WeightTrendState extends State<_WeightTrend> { // Day arithmetic, not a subtracted duration: a DST day is 23 or 25 hours // long and `now - 180 days` lands on the wrong calendar date across one. final now = DateTime.now(); - final since = dayLabelOf( - DateTime(now.year, now.month, now.day - (kWeightTrendDays - 1)), + final start = DateTime( + now.year, + now.month, + now.day - (kWeightTrendDays - 1), ); + final since = dayLabelOf(start); final rows = await LocalDb.journalMetricsByDay(sinceDaysEpoch: since); // Its own guard: a failing scale read must not blank the manual trend. var scale = const <({double kg, int atMs})>[]; try { scale = await weighings( - fromMs: DateTime.parse(since).millisecondsSinceEpoch, + // Local midnight, matching the local-day buckets below; parsing the + // label would read it as UTC midnight. + fromMs: start.millisecondsSinceEpoch, ); } catch (e) { debugPrint('weight trend: scale read failed: $e'); @@ -1064,7 +1069,7 @@ class _WeightTrendState extends State<_WeightTrend> { yAxis: axis, xLabels: [days.first, days.last], footnote: l?.journalComposeTrendFootnote ?? - 'Entered by you. Days with no entry are left empty.', + 'Entered by you or your scale. Days with no entry are left empty.', series: vals, empty: axis == null ? const NoData() : null, child: axis == null diff --git a/test/weight_trend_scale_test.dart b/test/weight_trend_scale_test.dart index 8ef9fff24..05a18cdb0 100644 --- a/test/weight_trend_scale_test.dart +++ b/test/weight_trend_scale_test.dart @@ -35,6 +35,14 @@ class _FakePathProvider extends PathProviderPlatform { int _ms(int d, int h, [int m = 0]) => DateTime(2026, 10, d, h, m).millisecondsSinceEpoch; +/// `weightByDay` with a fixed clock after every fixture reading, so the +/// future-dated filter never depends on the date the tests run. +Map _byDay( + Map> manual, + List<({double kg, int atMs})> scaled, { + int? nowMs, +}) => weightByDay(manual, scaled, nowMs: nowMs ?? _ms(30, 0)); + ({double kg, int atMs}) _w(double kg, int atMs) => (kg: kg, atMs: atMs); Map> _j( @@ -48,34 +56,34 @@ Map> _j( void main() { group('weightByDay', () { test('scale-only day', () { - expect(weightByDay({}, [_w(80, _ms(3, 7))]), {'2026-10-03': 80.0}); + expect(_byDay({}, [_w(80, _ms(3, 7))]), {'2026-10-03': 80.0}); }); test('manual-only day', () { - expect(weightByDay(_j('2026-10-03', 79.5), []), {'2026-10-03': 79.5}); + expect(_byDay(_j('2026-10-03', 79.5), []), {'2026-10-03': 79.5}); }); test('no data stays absent', () { - expect(weightByDay({}, []), isEmpty); + expect(_byDay({}, []), isEmpty); }); test('latest weighing of the day stands for it', () { - expect(weightByDay({}, [_w(81, _ms(3, 20)), _w(80, _ms(3, 7))]), { + expect(_byDay({}, [_w(81, _ms(3, 20)), _w(80, _ms(3, 7))]), { '2026-10-03': 81.0, }); }); test('both on one day: the later one wins', () { // Scale at 07:00 (420), journal at 21:00 (1260): journal is later. - expect(weightByDay(_j('2026-10-03', 79, 1260), [_w(80, _ms(3, 7))]), { + expect(_byDay(_j('2026-10-03', 79, 1260), [_w(80, _ms(3, 7))]), { '2026-10-03': 79.0, }); // Journal at 06:00, scale at 07:00: scale is later. - expect(weightByDay(_j('2026-10-03', 79, 360), [_w(80, _ms(3, 7))]), { + expect(_byDay(_j('2026-10-03', 79, 360), [_w(80, _ms(3, 7))]), { '2026-10-03': 80.0, }); // Journal without a time: the scale wins. - expect(weightByDay(_j('2026-10-03', 79), [_w(80, _ms(3, 7))]), { + expect(_byDay(_j('2026-10-03', 79), [_w(80, _ms(3, 7))]), { '2026-10-03': 80.0, }); }); @@ -85,11 +93,11 @@ void main() { for (var i = 0; i < 16; i++) _w(95.0 - i, _ms(1, 7) + i * 86400000), ]; - expect(weightByDay({}, ws).length, 16); + expect(_byDay({}, ws).length, 16); }); test('a household outlier amid ~80 kg is dropped', () { - final out = weightByDay({}, [ + final out = _byDay({}, [ _w(80, _ms(3, 7)), _w(120, _ms(4, 7)), _w(79.5, _ms(5, 7)), @@ -98,7 +106,7 @@ void main() { }); test('a guest seeding the chain does not lock the user out', () { - final out = weightByDay({}, [ + final out = _byDay({}, [ _w(20, _ms(1, 7)), _w(70, _ms(2, 7)), _w(71, _ms(3, 7)), @@ -110,7 +118,7 @@ void main() { }); test('a lasting step in weight re-seeds after three weighings', () { - final out = weightByDay({}, [ + final out = _byDay({}, [ _w(90, _ms(1, 7)), _w(79, _ms(2, 7)), _w(79, _ms(3, 7)), @@ -127,13 +135,13 @@ void main() { }); test('a lone outlier or a two-weighing run stays dropped', () { - final lone = weightByDay({}, [ + final lone = _byDay({}, [ _w(70, _ms(1, 7)), _w(120, _ms(2, 7)), _w(71, _ms(3, 7)), ]); expect(lone, {'2026-10-01': 70.0, '2026-10-03': 71.0}); - final two = weightByDay({}, [ + final two = _byDay({}, [ _w(70, _ms(1, 7)), _w(120, _ms(2, 7)), _w(121, _ms(3, 7)), @@ -143,7 +151,7 @@ void main() { }); test('three unrelated outliers do not re-seed', () { - final out = weightByDay({}, [ + final out = _byDay({}, [ _w(70, _ms(1, 7)), _w(120, _ms(2, 7)), _w(40, _ms(3, 7)), @@ -154,7 +162,7 @@ void main() { }); test('weighings dated after now are dropped', () { - final out = weightByDay( + final out = _byDay( {}, [_w(80, _ms(3, 7)), _w(80, _ms(9, 7))], nowMs: _ms(5, 7), @@ -163,7 +171,7 @@ void main() { }); test('buckets by local day near midnight', () { - final out = weightByDay({}, [ + final out = _byDay({}, [ _w(80, DateTime(2026, 10, 3, 23, 59).millisecondsSinceEpoch), _w(81, DateTime(2026, 10, 4, 0, 1).millisecondsSinceEpoch), ]); From 67f1f10f866dec06ff4ae4b26f9d2f2a84d24297 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:59:11 +0200 Subject: [PATCH 3/3] Weight trend: seed the weighing chain from the full history, plot only the window --- lib/ui2/screens/journal_compose.dart | 17 +++++++++++------ test/weight_trend_scale_test.dart | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 63ee51ebe..0548c3bd3 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -924,11 +924,15 @@ class _WeightRow extends StatelessWidget { /// later one wins: the journal's time of day against the weighing's, and a /// journal value with no time loses to the scale. Days with neither are absent. /// Scale weighings dated after [nowMs] (a band clock set ahead) are dropped. +/// The chain runs over every weighing in [scaled]; only those at or after +/// [fromMs] are plotted, so the window's start does not decide which readings +/// anchor the chain (and the same data charts the same on every day). @visibleForTesting Map weightByDay( Map> manual, List<({double kg, int atMs})> scaled, { int? nowMs, + int fromMs = 0, }) { final until = nowMs ?? DateTime.now().millisecondsSinceEpoch; bool ok(double v) => v.isFinite && v > 0; @@ -945,6 +949,7 @@ Map weightByDay( ); final at = {}; for (final w in chain) { + if (w.atMs < fromMs) continue; final t = DateTime.fromMillisecondsSinceEpoch(w.atMs); final day = dayLabelOf(t); if (at[day] != null && at[day]! >= w.atMs) continue; @@ -996,17 +1001,17 @@ class _WeightTrendState extends State<_WeightTrend> { // Its own guard: a failing scale read must not blank the manual trend. var scale = const <({double kg, int atMs})>[]; try { - scale = await weighings( - // Local midnight, matching the local-day buckets below; parsing the - // label would read it as UTC midnight. - fromMs: start.millisecondsSinceEpoch, - ); + // All history, not just the window: the chain's anchor must not + // depend on where the window starts (see weightByDay). + scale = await weighings(); } catch (e) { debugPrint('weight trend: scale read failed: $e'); } if (!mounted) return; setState(() { - _byDay = weightByDay(rows, scale); + // Local midnight, matching the local-day buckets; parsing the label + // would read it as UTC midnight. + _byDay = weightByDay(rows, scale, fromMs: start.millisecondsSinceEpoch); _loading = false; }); } catch (_) { diff --git a/test/weight_trend_scale_test.dart b/test/weight_trend_scale_test.dart index 05a18cdb0..0ed1476be 100644 --- a/test/weight_trend_scale_test.dart +++ b/test/weight_trend_scale_test.dart @@ -41,7 +41,8 @@ Map _byDay( Map> manual, List<({double kg, int atMs})> scaled, { int? nowMs, -}) => weightByDay(manual, scaled, nowMs: nowMs ?? _ms(30, 0)); + int fromMs = 0, +}) => weightByDay(manual, scaled, nowMs: nowMs ?? _ms(30, 0), fromMs: fromMs); ({double kg, int atMs}) _w(double kg, int atMs) => (kg: kg, atMs: atMs); @@ -161,6 +162,21 @@ void main() { expect(out, {'2026-10-01': 70.0, '2026-10-05': 71.0}); }); + test('readings before the window anchor the chain; only the window plots', + () { + final ws = [ + _w(70, _ms(1, 7)), + _w(70, _ms(2, 7)), + _w(30, _ms(5, 7)), + _w(71, _ms(6, 7)), + ]; + // The window opens on the 4th: the 30 kg reading is judged against the + // earlier 70 kg history, not allowed to seed a chain of its own. + expect(_byDay({}, ws, fromMs: _ms(4, 0)), {'2026-10-06': 71.0}); + // Moving the window start must not change a day that is still inside it. + expect(_byDay({}, ws, fromMs: _ms(5, 0)), {'2026-10-06': 71.0}); + }); + test('weighings dated after now are dropped', () { final out = _byDay( {},