Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 56 additions & 11 deletions lib/compute/inputs/measurement_inputs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,50 @@ 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);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
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<List<({double kg, int atMs})>> weighings({
num? near,
int fromMs = 0,
}) => _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 ||
Expand All @@ -42,14 +80,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)) yield (kg: kg.toDouble(), atMs: at);
}
}
}

/// 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;
await for (final w in _eachWeighing(near: near)) {
if (newest == null || w.atMs > newest.atMs) newest = w;
}
return newest;
}

Expand Down
2 changes: 1 addition & 1 deletion lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -3858,7 +3858,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."
},
Expand Down
2 changes: 1 addition & 1 deletion lib/l10n/app_es.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 65 additions & 13 deletions lib/ui2/screens/journal_compose.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -920,6 +921,52 @@ 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.
/// 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<String, double> weightByDay(
Map<String, Map<String, JournalMetricValue>> 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;
final out = <String, double>{};
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 = <String, int>{};
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;
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
Expand Down Expand Up @@ -947,22 +994,27 @@ 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 {
// 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 = {
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,
};
// 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 (_) {
Expand Down Expand Up @@ -1025,7 +1077,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
Expand Down
Loading
Loading