diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6d7378b..6a3e015 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,7 +28,7 @@ repos: args: [--pytest-test-first] # Shared fakes and fixtures, not test modules. Split out of conftest.py # and test_sync.py only because those files passed the 250-line cap. - exclude: ^diet_guard/tests/_(tk_fakes(_containers|_canvas)?|gate_layout_probe|gate_focus_probe|gate_measurement|gate_keyboard_probe|gate_fixtures|sync_fakes|kuchnia_fakes)\.py$ + exclude: ^diet_guard/tests/_(tk_fakes(_containers|_canvas)?|gate_layout_probe|gate_focus_probe|gate_measurement|gate_keyboard_probe|gate_fixtures|sync_fakes|kuchnia_fakes|state_redirects)\.py$ - id: check-ast - id: mixed-line-ending args: [--fix=lf] @@ -108,9 +108,10 @@ repos: # than merely an old one. - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync - - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays + - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays - mcp>=2,<3 - pydantic + - defusedxml==0.7.1 # Test suites intentionally use patterns (protected-access, magic-value # comparisons) that don't belong in the source-code 10/10 gate. exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py) @@ -145,6 +146,8 @@ repos: - id: codespell args: - --skip=*.json,*.lock,.git,__pycache__,.venv + # Devine: the 1974 ideal-body-weight formula's author, not "define". + - --ignore-words-list=devine - repo: local hooks: diff --git a/AGENTS.md b/AGENTS.md index 8cdee1d..0b05116 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ catch-up rule). - Lint: `pre-commit run --all-files` - Test the lock (safe, closeable): `python -m diet_guard gate --demo` - One sync tick: `python -m diet_guard sync` -- Averages: `python -m diet_guard averages` +- Averages: `python -m diet_guard averages`; Body: `body`, `weight KG`, `profile`, `plan-week` - Prune dead sync peers (dry run): `python -m diet_guard prune-peers` - Install for production: `bash install.sh` - App tests: `cd app && flutter test` @@ -67,8 +67,8 @@ every idle tick to approximate the two moments state actually changes here. demo already writes real entries through `log_meal` (only the *slot* is synthetic). - `pull_shared_log()` is misnamed: it runs a *full* tick (pull, merge, re-sign, - persist, **push**, budget, both food banks, and last the phone's weigh-in - into `w` via `_phone_weight`: `wake_alarm._weight`, lazy, never raises). + persist, **push**, budget, both food banks, the Body doc via `_sync_body`, + and last the newest logged weight into `w` via `_phone_weight`). `_sync_refresh.pull_peer_logs()` is for anything a user waits on. What must follow every tick goes in `run_sync`, not `cmd_sync` (the PC never runs it). - Accepted trade: a PC neither logging nor locking does not pull. @@ -79,8 +79,8 @@ every idle tick to approximate the two moments state actually changes here. `_sync_refresh.py`, the fetch button, or the publish paths. - Every test that logs a meal would otherwise hit the network through these new call sites; `conftest._isolate_state` patches each one - (`_cli_log.publish_after_log_detached`, `_cli_gate.publish_after_log`, - `_mcp.publish_after_log`, `_cli_gate.pull_peer_logs`) — not on + (listed in `tests/_state_redirects.py`: `_cli_log`, `_cli_gate`, `_mcp`, + `_cli_body` publish sites and `_cli_gate.pull_peer_logs`) — not on `_sync_events`, so a direct test still exercises the real helper. A new network entry point missing from that list reaches the real remote. @@ -139,14 +139,14 @@ reach the running service. `test_budget_history.py`; it syncs as `hist:` fields on the existing `budget` record, so devices predating the feature relay them untouched. -- **Biometrics are used once and discarded** — only the computed budget is - persisted. +- **Body data persists and syncs** (profile, weight log, PC workouts) as + `body.json`; its tables are informational. [docs/DOCS-body.md](docs/DOCS-body.md). - **PC and phone share one source of truth for everything**: food log, budget, - budget history, body weight (`w`), curated food bank. If you add a stored + budget history, body weight (`w`), Body doc, curated food bank. If you add a stored field, sync it or comment why it physically cannot be. - **State lives entirely under `~/.local/share/diet_guard/`** — no cross-repo - file coupling. Exception: the sync timer touches `kuhyx/syncs` and - `~/.config/diet_guard/sync_token`. + file coupling. Exceptions: sync (`kuhyx/syncs`, `~/.config/diet_guard/`) and + read-only workout sources (RunnerUp drop, screen-locker: `_activity_sources`). - **Every device pushes under a persisted per-install uuid**, not a role constant. `_device.py` / `sync_device_id.dart` (`crdt.nodeId`). The old role constant survives as the legacy id so `devices//` is skipped as this diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index 85801d4..73cf1bb 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -39,7 +39,8 @@ android { applicationId = "com.kuhy.diet_guard_app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + // Health Connect (the `health` plugin) needs API 26. + minSdk = maxOf(26, flutter.minSdkVersion) targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 071eaf1..cbe65c9 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,10 @@ (permission_handler's ignoreBatteryOptimizations) to actually show the system dialog instead of silently returning denied. --> + + + + + + + + + + + + + + + + + diff --git a/app/android/app/src/main/kotlin/com/kuhy/diet_guard_app/MainActivity.kt b/app/android/app/src/main/kotlin/com/kuhy/diet_guard_app/MainActivity.kt index 852aa10..dbb6aea 100644 --- a/app/android/app/src/main/kotlin/com/kuhy/diet_guard_app/MainActivity.kt +++ b/app/android/app/src/main/kotlin/com/kuhy/diet_guard_app/MainActivity.kt @@ -1,5 +1,8 @@ package com.kuhy.diet_guard_app -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity -class MainActivity : FlutterActivity() +// FlutterFragmentActivity, not FlutterActivity: the `health` plugin requests +// Health Connect permissions through registerForActivityResult, which needs a +// ComponentActivity (see the plugin README, "Android 14"). +class MainActivity : FlutterFragmentActivity() diff --git a/app/lib/main.dart b/app/lib/main.dart index 85accc0..dabe77e 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; import 'package:diet_guard_app/services/background_tasks.dart'; +import 'package:diet_guard_app/services/body_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/frame_stats.dart'; @@ -29,6 +30,7 @@ Future main() async { await MealScheduleService.init(); await FoodBankService.init(); await KuchniaCredentialService.init(); + await BodyService.init(); await KuchniaQueueService.init(); final notifications = await NotificationService.init(); // Deliberately *not* awaited before the first frame: the browser's diff --git a/app/lib/models/body_document.dart b/app/lib/models/body_document.dart new file mode 100644 index 0000000..84f1d73 --- /dev/null +++ b/app/lib/models/body_document.dart @@ -0,0 +1,142 @@ +/// The synced Body document: profile, per-day weights, published workouts. +/// +/// Same JSON shape as `~/.local/share/diet_guard/body.json` on the PC (see +/// `docs/DOCS-body.md`). Every field carries its own edit stamp `t`, which is +/// what the sync merge derives its clocks from. Parsing is tolerant: a +/// malformed part is dropped, never raised, so one bad field from a peer +/// cannot take out the rest. +library; + +import 'package:diet_guard_app/models/body_entries.dart'; + +export 'package:diet_guard_app/models/body_entries.dart'; + +/// Profile field names. +const String profileBirth = 'birth'; + +/// Profile height field. +const String profileHeight = 'height_cm'; + +/// Profile sex field (`m` / `f`). +const String profileSex = 'sex'; + +/// The whole document. +class BodyDocument { + /// Creates a document. + const BodyDocument({ + this.profile = const {}, + this.weights = const {}, + this.bodyFat = const {}, + this.steps = const {}, + this.activity, + }); + + /// Parses the local document JSON (already decoded). + factory BodyDocument.fromJson(Object? raw) { + if (raw is! Map) return const BodyDocument(); + final profile = {}; + final rawProfile = raw['profile']; + // Every stamped field is kept, known or not: a field a newer peer added + // (the table prefs were one) must survive this device's re-push. + if (rawProfile is Map) { + rawProfile.forEach((name, field) { + if (name is String && field is Map && field['t'] is String) { + profile[name] = Stamped(field['v'], field['t'] as String); + } + }); + } + final weights = {}; + final rawWeights = raw['weights']; + if (rawWeights is Map) { + rawWeights.forEach((day, value) { + final entry = parseWeightValue(value, value is Map ? value['t'] : null); + if (isDateKey(day) && entry != null) weights[day as String] = entry; + }); + } + final bodyFat = {}; + final rawFat = raw['bodyfat']; + if (rawFat is Map) { + rawFat.forEach((day, value) { + final t = value is Map ? value['t'] : null; + final entry = parseBodyFatValue(value, t); + if (isDateKey(day) && entry != null) bodyFat[day as String] = entry; + }); + } + final steps = {}; + final rawSteps = raw['steps']; + if (rawSteps is Map) { + rawSteps.forEach((day, value) { + final t = value is Map ? value['t'] : null; + final entry = parseStepsValue(value, t); + if (isDateKey(day) && entry != null) steps[day as String] = entry; + }); + } + final rawActivity = raw['activity']; + ActivityData? activity; + if (rawActivity is Map && + rawActivity['sessions'] is List && + rawActivity['t'] is String) { + activity = ActivityData( + sessions: List.from(rawActivity['sessions'] as List), + t: rawActivity['t'] as String, + ); + } + return BodyDocument( + profile: profile, + weights: weights, + bodyFat: bodyFat, + steps: steps, + activity: activity, + ); + } + + /// Profile fields by name. + final Map profile; + + /// Weights by `YYYY-MM-DD`. + final Map weights; + + /// Body fat readings by `YYYY-MM-DD`. + final Map bodyFat; + + /// Steps outside workouts by `YYYY-MM-DD` (written by the phone). + final Map steps; + + /// Workouts published by the PC, or null when none ever were. + final ActivityData? activity; + + /// Returns a copy with the given parts replaced. + BodyDocument copyWith({ + Map? profile, + Map? weights, + Map? bodyFat, + Map? steps, + ActivityData? activity, + }) => BodyDocument( + profile: profile ?? this.profile, + weights: weights ?? this.weights, + bodyFat: bodyFat ?? this.bodyFat, + steps: steps ?? this.steps, + activity: activity ?? this.activity, + ); + + /// JSON form of the local document. + Map toJson() => { + 'v': 1, + 'profile': { + for (final entry in profile.entries) + entry.key: {'v': entry.value.value, 't': entry.value.t}, + }, + 'weights': { + for (final entry in weights.entries) entry.key: entry.value.toJson(), + }, + 'bodyfat': { + for (final entry in bodyFat.entries) entry.key: entry.value.toJson(), + }, + 'steps': { + for (final entry in steps.entries) entry.key: entry.value.toJson(), + }, + if (activity != null) + 'activity': {'sessions': activity!.sessions, 't': activity!.t}, + }; +} diff --git a/app/lib/models/body_entries.dart b/app/lib/models/body_entries.dart new file mode 100644 index 0000000..ccd1e29 --- /dev/null +++ b/app/lib/models/body_entries.dart @@ -0,0 +1,132 @@ +/// The Body document's parts: stamped values, dated entries, parsers. +/// +/// Split out of `body_document.dart` for the 250-line cap; that file +/// re-exports this one, so importers see no change. Parsing is tolerant: a +/// malformed part is dropped (null), never raised. +library; + +/// A value and the ISO time it was last edited. +class Stamped { + /// Creates a stamped value. + const Stamped(this.value, this.t); + + /// The value. + final Object? value; + + /// ISO-8601 edit time. + final String t; +} + +/// One day's weight. [kg] null is a tombstone (a deleted day). +class WeightEntry { + /// Creates an entry. + const WeightEntry({required this.kg, required this.src, required this.t}); + + /// Kilograms, or null when deleted. + final double? kg; + + /// `phone`, `manual` or `init`. + final String src; + + /// ISO-8601 edit time. + final String t; + + /// JSON form of the local document. + Map toJson() => {'kg': kg, 'src': src, 't': t}; +} + +/// One day's body fat %. [pct] null is a tombstone (a deleted day). +class BodyFatEntry { + /// Creates an entry. + const BodyFatEntry({required this.pct, required this.t}); + + /// Percent, or null when deleted. + final double? pct; + + /// ISO-8601 edit time. + final String t; + + /// JSON form of the local document. + Map toJson() => {'pct': pct, 't': t}; +} + +/// One day's steps outside workouts, as the phone read them. +class StepsEntry { + /// Creates an entry. + const StepsEntry({required this.n, required this.t}); + + /// Step count. + final int n; + + /// ISO-8601 edit time. + final String t; + + /// JSON form of the local document. + Map toJson() => {'n': n, 't': t}; +} + +/// Workouts published by the PC. +class ActivityData { + /// Creates the published activity. + const ActivityData({required this.sessions, required this.t}); + + /// Raw session objects, as on the wire. + final List sessions; + + /// ISO-8601 time the PC last changed them. + final String t; +} + +/// Parses an ISO stamp to epoch milliseconds; unparsable -> 0 (the epoch). +int stampMillis(String t) => DateTime.tryParse(t)?.millisecondsSinceEpoch ?? 0; + +/// Whether [raw] is a usable weight in kilograms. +bool plausibleKg(Object? raw) => raw is num && raw >= 20 && raw <= 400; + +/// Body fat below/above this (%) is a typo, not a measurement. +const double minBodyFat = 3; + +/// Upper bound of a plausible body fat %. +const double maxBodyFat = 70; + +/// Whether [raw] is a usable body fat %. +bool plausibleBodyFat(Object? raw) => + raw is num && raw >= minBodyFat && raw <= maxBodyFat; + +final RegExp _dateKey = RegExp(r'^\d{4}-\d{2}-\d{2}$'); + +/// Whether [raw] is a `YYYY-MM-DD` date key. +bool isDateKey(Object? raw) => raw is String && _dateKey.hasMatch(raw); + +/// Parses a weight value `{kg, src}` stamped [t], or null when malformed. +/// +/// `kg` must be null (a tombstone) or a plausible number. +WeightEntry? parseWeightValue(Object? value, Object? t) { + if (value is! Map || t is! String) return null; + final kg = value['kg']; + if (kg != null && !plausibleKg(kg)) return null; + final src = value['src']; + return WeightEntry( + kg: (kg as num?)?.toDouble(), + src: src is String ? src : 'manual', + t: t, + ); +} + +/// Parses a body-fat value `{pct}` stamped [t], or null when malformed. +/// +/// `pct` must be null (a tombstone) or a plausible number. +BodyFatEntry? parseBodyFatValue(Object? value, Object? t) { + if (value is! Map || t is! String) return null; + final pct = value['pct']; + if (pct != null && !plausibleBodyFat(pct)) return null; + return BodyFatEntry(pct: (pct as num?)?.toDouble(), t: t); +} + +/// Parses a steps value `{n}` stamped [t], or null when malformed. +StepsEntry? parseStepsValue(Object? value, Object? t) { + if (value is! Map || t is! String) return null; + final n = value['n']; + if (n is! num || n < 0) return null; + return StepsEntry(n: n.toInt(), t: t); +} diff --git a/app/lib/models/workout_session.dart b/app/lib/models/workout_session.dart new file mode 100644 index 0000000..1eceb45 --- /dev/null +++ b/app/lib/models/workout_session.dart @@ -0,0 +1,72 @@ +/// One published workout, as the PC syncs it in the Body document. +/// +/// Split out of `services/activity_kcal.dart` for the 250-line cap; that +/// file re-exports it, so importers see no change. Parsing is tolerant: a +/// malformed session is dropped, an unparsable time reads as null. +library; + +/// One workout, reduced to what the energy formulas need. +class Session { + /// Creates a session. + const Session({ + required this.day, + required this.kind, + required this.minutes, + required this.km, + required this.label, + required this.source, + this.start, + this.end, + }); + + /// Parses the synced wire object (`day, kind, min, km, label, src`), or + /// null when it is malformed. + static Session? fromWire(Object? raw) { + if (raw is! Map) return null; + final day = raw['day']; + final kind = raw['kind']; + final minutes = raw['min']; + final km = raw['km']; + if (day is! String || kind is! String || minutes is! num) return null; + if (km != null && km is! num) return null; + return Session( + day: day, + kind: kind, + minutes: minutes.toDouble(), + km: (km as num?)?.toDouble(), + label: raw['label'] is String ? raw['label'] as String : kind, + source: raw['src'] is String ? raw['src'] as String : '', + start: _stampOrNull(raw['start']), + end: _stampOrNull(raw['end']), + ); + } + + /// Local `YYYY-MM-DD` the workout happened on. + final String day; + + /// `strength`, `run`, `walk`, `cycle` or `other`. + final String kind; + + /// Duration in minutes. + final double minutes; + + /// Distance, or null when the source has none. + final double? km; + + /// Human description. + final String label; + + /// Where it came from (`runnerup`, `screen-locker`). + final String source; + + /// ISO start time, when the source knows it. + final String? start; + + /// ISO end time, when the source knows it. The phone drops Health + /// Connect steps inside `start..end`, so a walk is not counted twice. + final String? end; +} + +/// An ISO time string, or null when absent or unparsable. +String? _stampOrNull(Object? value) => + value is String && DateTime.tryParse(value) != null ? value : null; diff --git a/app/lib/screens/body_screen.dart b/app/lib/screens/body_screen.dart new file mode 100644 index 0000000..7b7f01d --- /dev/null +++ b/app/lib/screens/body_screen.dart @@ -0,0 +1,213 @@ +/// The Body screen: the goal sentence first, then weight (+ graph), BMI, +/// ideal weight, BMR, measured activity and steps, the week planner, the +/// profile (at the top instead while incomplete), and body fat last. +/// +/// The phone half of the PC gate's Body tab (`_gatelock_body*.py`), reading +/// the same synced `body.json` (see `docs/DOCS-body.md`). **Informational +/// only**: nothing here writes the daily budget -- there is deliberately no +/// "apply" button. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/services/activity_kcal.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/body_calc.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/services/body_steps.dart'; +import 'package:diet_guard_app/services/health_steps.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:diet_guard_app/widgets/body/activity_card.dart'; +import 'package:diet_guard_app/widgets/body/body_section.dart'; +import 'package:diet_guard_app/widgets/body/body_stats_cards.dart'; +import 'package:diet_guard_app/widgets/body/goal_card.dart'; +import 'package:diet_guard_app/widgets/body/profile_card.dart'; +import 'package:diet_guard_app/widgets/body/week_plan_card.dart'; +import 'package:diet_guard_app/widgets/body/weight_card.dart'; +import 'package:flutter/material.dart'; + +/// Builds the person from the stored profile and newest weight, or null +/// when anything the formulas need is missing. +Biometrics? currentBiometrics(BodyService body, DateTime today) { + final birth = DateTime.tryParse(body.birth ?? ''); + final height = body.heightCm; + final sex = body.sex; + final weight = body.latestWeight; + if (birth == null || height == null || sex == null || weight == null) { + return null; + } + return Biometrics( + weightKg: weight.$2, + heightCm: height, + ageYears: ageOn(birth, today).toDouble(), + isMale: sex == 'm', + ); +} + +/// Steps outside workouts by day, as stored. +Map stepsByDay(BodyService body) => + body.document.steps.map((day, entry) => MapEntry(day, entry.n)); + +/// Mean daily exercise per formula (steps included), the steps' own share, +/// and the number of full days averaged -- like the PC's `_body_view`: the +/// two means are null without a person, or without any published workout +/// or step count. +(Map?, double?, int) bodyExercise( + BodyService body, + Biometrics? bio, + DateTime today, +) { + final sessions = body.sessions; + final steps = stepsByDay(body); + if (bio == null || (sessions == null && steps.isEmpty)) { + return (null, null, 0); + } + final byDay = steps.map((day, n) => MapEntry(day, stepKcal(n, bio))); + final weight = bio.weightKg; + final all = sessions ?? const []; + return ( + dailyExercise(all, weight, today, byDay), + extraShare(all, today, byDay), + windowDays(all, today, byDay), + ); +} + +/// Sums each day's non-deleted kcal. +Map kcalByDay(DayLog log) => { + for (final entry in log.entries) + entry.key: entry.value + .where((food) => !food.deleted) + .fold(0, (sum, food) => sum + food.kcal), +}; + +/// The Body screen. +class BodyScreen extends StatefulWidget { + /// Creates the screen. + const BodyScreen({super.key}); + + @override + State createState() => _BodyScreenState(); +} + +class _BodyScreenState extends State { + Map _logged = const {}; + + /// Decided once, when the screen opens: a complete profile sits at the + /// bottom, an incomplete one at the top. Re-deciding on every build would + /// yank the card away mid-edit the moment the last field is saved; it + /// moves the next time the screen is entered. + late final bool _profileAtTop = !BodyService.instance.profileComplete; + + /// Whether the Health Connect button shows: supported and not yet granted. + bool _canConnectSteps = false; + + @override + void initState() { + super.initState(); + unawaited(_loadLog()); + unawaited(_loadSteps()); + } + + /// Refreshes steps (only when already permitted -- never prompts) and + /// decides whether to offer the connect button. + Future _loadSteps() async { + final source = stepsSource; + if (!source.supported) return; + final granted = await source.hasPermission().catchError((_) => false); + if (granted) await refreshSteps(); + if (!mounted) return; + setState(() => _canConnectSteps = !granted); + } + + Future _connectSteps() async { + final granted = await stepsSource.requestPermission().catchError( + (_) => false, + ); + if (granted) await refreshSteps(); + if (!mounted) return; + setState(() => _canConnectSteps = !granted); + } + + Future _loadLog() async { + final log = await LogStorageService.instance.readLog(); + if (!mounted) return; + setState(() => _logged = kcalByDay(log)); + } + + /// Any profile/weight write changes every derived card; the cards read + /// [BodyService] in `build`, so a rebuild is all they need. + void _onChanged() => setState(() {}); + + @override + Widget build(BuildContext context) { + final body = BodyService.instance; + final today = DateTime.now(); + final bio = currentBiometrics(body, today); + final sessions = body.sessions; + final weight = body.latestWeight?.$2; + final (exercise, stepsKcal, window) = bodyExercise(body, bio, today); + final bodyFat = body.latestBodyFat?.$2; + final profile = ProfileCard(onSaved: _onChanged); + return Scaffold( + appBar: AppBar(title: const Text('Body')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.md), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: AppWidth.prose), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_profileAtTop) profile, + if (bio == null) + BodySection( + title: 'Daily calorie target', + children: [BodyNote(_missing(body))], + ) + else + GoalCard(bio: bio, exercise: exercise, bodyFatPct: bodyFat), + WeightCard(onChanged: _onChanged), + if (bio != null) ...[ + BmiCard(bio: bio), + IdealWeightCard(bio: bio), + BmrCard(bio: bio, bodyFatPct: bodyFat), + ], + ActivityCard( + sessions: sessions, + weightKg: weight, + exercise: exercise, + steps: stepsByDay(body), + stepsKcal: stepsKcal, + window: window, + onConnectSteps: _canConnectSteps ? _connectSteps : null, + ), + WeekPlanCard( + defaultTarget: AppSettingsService.dailyKcalGoal, + logged: _logged, + ), + if (!_profileAtTop) profile, + BodyFatCard(onChanged: _onChanged), + BodyNote('Age ${_age(body, today)} · synced with the PC.'), + ], + ), + ), + ), + ), + ); + } +} + +String _missing(BodyService body) { + if (body.latestWeight == null && body.birth == null) { + return 'Set your profile and enter your weight to see BMI, ideal ' + 'weight and calorie targets.'; + } + if (body.latestWeight == null) return 'Enter your weight to see the numbers.'; + return 'Set your profile (birth date, height, sex) to see the numbers.'; +} + +String _age(BodyService body, DateTime today) { + final birth = DateTime.tryParse(body.birth ?? ''); + return birth == null ? 'unknown' : '${ageOn(birth, today)}'; +} diff --git a/app/lib/screens/log_meal_actions.dart b/app/lib/screens/log_meal_actions.dart index ace0693..bb23531 100644 --- a/app/lib/screens/log_meal_actions.dart +++ b/app/lib/screens/log_meal_actions.dart @@ -32,7 +32,7 @@ Nutrition nutritionFromControllers(MacroControllers macros, String source) => source: source, ); -/// The four destination buttons in [LogMealScreen]'s app bar. +/// The five destination buttons in [LogMealScreen]'s app bar. /// /// Split out of `log_meal_screen.dart` for the repo's 250-line cap. Returned /// as a list to be spread into `AppBar.actions`, so the bar's structure and @@ -42,7 +42,13 @@ List logMealAppBarActions({ required VoidCallback onHistory, required VoidCallback onCalendar, required VoidCallback onSettings, + required VoidCallback onBody, }) => [ + IconButton( + icon: const Icon(Icons.monitor_weight), + tooltip: 'Body', + onPressed: onBody, + ), IconButton( icon: const Icon(Icons.restaurant_menu), tooltip: 'Food bank', diff --git a/app/lib/screens/log_meal_nav_mixin.dart b/app/lib/screens/log_meal_nav_mixin.dart index 436261c..b456a8d 100644 --- a/app/lib/screens/log_meal_nav_mixin.dart +++ b/app/lib/screens/log_meal_nav_mixin.dart @@ -1,3 +1,4 @@ +import 'package:diet_guard_app/screens/body_screen.dart'; import 'package:diet_guard_app/screens/calendar_screen.dart'; import 'package:diet_guard_app/screens/food_bank_screen.dart'; import 'package:diet_guard_app/screens/history_screen.dart'; @@ -5,7 +6,7 @@ import 'package:diet_guard_app/screens/settings_screen.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; -/// The four app-bar destinations reachable from [LogMealScreen]. +/// The five app-bar destinations reachable from [LogMealScreen]. /// /// Split out of `log_meal_screen.dart` for the repo's 250-line cap. The routes /// are unchanged -- each pushes exactly the [MaterialPageRoute] it did before. @@ -25,23 +26,30 @@ mixin LogMealNavMixin on State { /// Pushes the entry history screen. void onOpenHistory() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const HistoryScreen()), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const HistoryScreen())); } /// Pushes the adherence calendar screen. void onOpenCalendar() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const CalendarScreen()), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const CalendarScreen())); + } + + /// Pushes the Body screen (weight, BMI, calorie targets, week planner). + void onOpenBody() { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const BodyScreen())); } /// Pushes the food bank screen. void onOpenFoodBank() { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const FoodBankScreen()), - ); + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const FoodBankScreen())); } /// Pushes the settings screen, forwarding the HTTP client. diff --git a/app/lib/screens/log_meal_screen.dart b/app/lib/screens/log_meal_screen.dart index 58d24bb..67553b7 100644 --- a/app/lib/screens/log_meal_screen.dart +++ b/app/lib/screens/log_meal_screen.dart @@ -190,6 +190,7 @@ class _LogMealScreenState extends State onHistory: onOpenHistory, onCalendar: onOpenCalendar, onSettings: onOpenSettings, + onBody: onOpenBody, ), ], ), diff --git a/app/lib/screens/log_meal_sync_mixin.dart b/app/lib/screens/log_meal_sync_mixin.dart index 3ab2f4a..16e81bb 100644 --- a/app/lib/screens/log_meal_sync_mixin.dart +++ b/app/lib/screens/log_meal_sync_mixin.dart @@ -5,6 +5,7 @@ import 'package:diet_guard_app/models/slot.dart'; import 'package:diet_guard_app/services/background_tasks.dart'; import 'package:diet_guard_app/services/firebase_client.dart'; import 'package:diet_guard_app/services/github_client_factory.dart'; +import 'package:diet_guard_app/services/health_steps.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; import 'package:diet_guard_app/services/sync_health.dart'; @@ -91,11 +92,11 @@ mixin LogMealSyncMixin on State if (mounted) await refreshSyncHealth(); return; } - final client = createGitHubClient( - settings, - httpClient: syncHttpClient, - ); + final client = createGitHubClient(settings, httpClient: syncHttpClient); try { + // Foreground only (this mixin is UI), and never prompts: fresh + // Health Connect steps ride along in this tick's body.json. + await refreshSteps(); await runSync(await syncBackend(client)); await SyncHealth.recordSuccess(); } finally { diff --git a/app/lib/services/activity_kcal.dart b/app/lib/services/activity_kcal.dart new file mode 100644 index 0000000..7db7ac0 --- /dev/null +++ b/app/lib/services/activity_kcal.dart @@ -0,0 +1,227 @@ +/// Exercise energy per workout, by every formula the workout data supports. +/// +/// Port of `diet_guard/_activity_kcal.py`, gated by the shared +/// `tests/fixtures/body_calc.json`. Every formula returns **net** kcal -- +/// energy above resting -- because the measured TDEE adds exercise on top of +/// `BMR x 1.2`, which already pays for the resting hour. +/// +/// * **MET** (Compendium 2011): `(MET - 1) x kg x hours`, speed-interpolated +/// for runs and walks; lifting is code 02052 (resistance training) = 5.0. +/// * **ACSM** level-ground equations: net VO2 = 0.2 v (run) / 0.1 v (walk), +/// v in m/min; kcal = VO2 x kg x min x 5 / 1000. +/// * **Per-km**: 0.9 (run) / 0.5 (walk) kcal/kg/km net. +/// +/// ACSM and per-km need a distance, so every other session falls back to +/// its MET figure (listed in [SessionKcal.fallback]). +library; + +import 'package:diet_guard_app/models/workout_session.dart'; + +export 'package:diet_guard_app/models/workout_session.dart'; + +/// Formula names, in display order. +const List activityFormulas = ['MET', 'ACSM', 'Per-km']; + +/// The methods explained, shown under the Activity card. +const String methodsNote = + 'MET = standard activity values x your weight x time. ' + 'ACSM = American College of Sports Medicine running/walking equation ' + 'from your speed. Per-km = ~0.9 kcal per kg per km running, ~0.5 ' + 'walking. All are energy above resting.'; + +/// Measured activity averages at most the last two full weeks. +const int maxWindowDays = 14; + +const List<(double, double)> _runMetByKmh = [ + (6.4, 6), + (8, 8.3), + (8.4, 9), + (9.7, 9.8), + (10.8, 10.5), + (11.3, 11), + (12.1, 11.5), + (12.9, 11.8), + (13.8, 12.3), + (14.5, 12.8), + (16.1, 14.5), + (17.7, 16), + (19.3, 19), + (20.9, 19.8), + (22.5, 23), +]; +const List<(double, double)> _walkMetByKmh = [ + (3.2, 2.8), + (4, 3), + (4.8, 3.5), + (5.6, 4.3), + (6.4, 5), + (7.2, 7), + (8, 8.3), +]; +const Map _fixedMet = { + 'strength': 5, + 'run': 8, + 'walk': 3.5, + 'cycle': 7.5, + 'other': 4, +}; +const Map _acsmSlope = {'run': 0.2, 'walk': 0.1}; +const Map _netKcalPerKgKm = {'run': 0.9, 'walk': 0.5}; +const double _kcalPerLitreO2 = 5; + +/// Net kcal of one session per formula; [fallback] names MET stand-ins. +class SessionKcal { + /// Creates a result. + const SessionKcal(this.session, this.kcal, this.fallback); + + /// The session measured. + final Session session; + + /// Formula name -> net kcal. + final Map kcal; + + /// Formulas that could not apply and reuse the MET figure. + final Set fallback; +} + +double _interpolate(List<(double, double)> table, double kmh) { + if (kmh <= table.first.$1) return table.first.$2; + for (var i = 0; i + 1 < table.length; i++) { + final (x0, y0) = table[i]; + final (x1, y1) = table[i + 1]; + if (kmh <= x1) return y0 + (y1 - y0) * (kmh - x0) / (x1 - x0); + } + return table.last.$2; +} + +double? _speedKmh(Session session) { + final km = session.km; + if (km == null || km <= 0 || session.minutes <= 0) return null; + return km / (session.minutes / 60.0); +} + +/// The MET value for [session]: speed-based when possible, else fixed. +double metFor(Session session) { + final kmh = _speedKmh(session); + if (kmh != null && session.kind == 'run') { + return _interpolate(_runMetByKmh, kmh); + } + if (kmh != null && session.kind == 'walk') { + return _interpolate(_walkMetByKmh, kmh); + } + return _fixedMet[session.kind] ?? _fixedMet['other']!; +} + +/// Net kcal of [session] for a [weightKg] person, by every formula. +SessionKcal sessionKcal(Session session, double weightKg) { + final hours = session.minutes / 60.0; + final met = (metFor(session) - 1.0) * weightKg * hours; + final kcal = {'MET': met, 'ACSM': met, 'Per-km': met}; + final fallback = {'ACSM', 'Per-km'}; + final kmh = _speedKmh(session); + final km = session.km; + final slope = _acsmSlope[session.kind]; + if (kmh != null && km != null && slope != null) { + final metresPerMin = kmh * 1000.0 / 60.0; + final netVo2 = slope * metresPerMin; + kcal['ACSM'] = netVo2 * weightKg * session.minutes * _kcalPerLitreO2 / 1000; + kcal['Per-km'] = _netKcalPerKgKm[session.kind]! * weightKg * km; + fallback.clear(); + } + return SessionKcal(session, kcal, fallback); +} + +String _dateKey(DateTime day) { + String two(int v) => v.toString().padLeft(2, '0'); + return '${day.year.toString().padLeft(4, '0')}-${two(day.month)}' + '-${two(day.day)}'; +} + +DateTime _utcDay(DateTime day) => DateTime.utc(day.year, day.month, day.day); + +/// How many full past days [dailyExercise] averages over. +/// +/// [maxWindowDays], or fewer while the earliest past data (a session or an +/// [extraByDay] key before [today]) is younger than that; 0 when there is no +/// past data at all. +int windowDays( + List sessions, + DateTime today, [ + Map? extraByDay, +]) { + final last = _dateKey(today); + final days = [ + for (final session in sessions) + if (session.day.compareTo(last) < 0) session.day, + for (final day in (extraByDay ?? const {}).keys) + if (day.compareTo(last) < 0) day, + ]; + if (days.isEmpty) return 0; + final earliest = days.reduce((a, b) => a.compareTo(b) <= 0 ? a : b); + // Whole UTC days, so a DST change cannot shave an hour off the count. + final span = _utcDay( + today, + ).difference(DateTime.parse('${earliest}T00:00:00Z')).inDays; + return span < maxWindowDays ? span : maxWindowDays; +} + +/// Mean net exercise kcal per day over the full past days before [today]. +/// +/// The window ends yesterday (today is still in progress) and starts +/// [maxWindowDays] back -- or, while the data is younger than that, on the +/// first day that has any, so a week of history is averaged over that week +/// rather than diluted over 14 days ([windowDays]). The denominator is +/// calendar days within that span: a rest day burns no exercise kcal. With +/// no past data at all, every formula reads 0. +/// +/// [extraByDay] (day -> net kcal, e.g. steps outside workouts) is added to +/// every formula alike: it is not a workout any formula could measure. +Map dailyExercise( + List sessions, + double weightKg, + DateTime today, [ + Map? extraByDay, +]) { + final totals = {for (final name in activityFormulas) name: 0.0}; + final span = windowDays(sessions, today, extraByDay); + if (span == 0) return totals; + // Calendar arithmetic via the constructor, not Duration: subtracting N x + // 24 h across a DST change lands on 23:00 of the wrong day. + final first = _dateKey(DateTime(today.year, today.month, today.day - span)); + final last = _dateKey(today); + bool inWindow(String day) => + day.compareTo(first) >= 0 && day.compareTo(last) < 0; + for (final session in sessions) { + if (!inWindow(session.day)) continue; + sessionKcal(session, weightKg).kcal.forEach((name, value) { + totals[name] = totals[name]! + value; + }); + } + extraByDay?.forEach((day, kcal) { + if (!inWindow(day)) return; + for (final name in activityFormulas) { + totals[name] = totals[name]! + kcal; + } + }); + return totals.map((name, total) => MapEntry(name, total / span)); +} + +/// The part of every [dailyExercise] mean that [extraByDay] contributes, +/// over the very same window -- so a "steps add N kcal/day (included above)" +/// line always adds up to the total it claims to be part of. Mirrors the +/// steps share computed in `diet_guard/_body_view.py`. +double extraShare( + List sessions, + DateTime today, + Map extraByDay, +) { + final span = windowDays(sessions, today, extraByDay); + if (span == 0) return 0; + final first = _dateKey(DateTime(today.year, today.month, today.day - span)); + final last = _dateKey(today); + var sum = 0.0; + extraByDay.forEach((day, kcal) { + if (day.compareTo(first) >= 0 && day.compareTo(last) < 0) sum += kcal; + }); + return sum / span; +} diff --git a/app/lib/services/background_sync_service.dart b/app/lib/services/background_sync_service.dart index f23db95..50b445f 100644 --- a/app/lib/services/background_sync_service.dart +++ b/app/lib/services/background_sync_service.dart @@ -7,6 +7,7 @@ library; import 'dart:developer'; import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/body_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/firebase_client.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; @@ -62,6 +63,9 @@ Future backgroundSyncPush({http.Client? httpClient}) async { // neither publish this device's catering credential nor receive a peer's -- // and on the phone the background tick is most of the syncing there is. await KuchniaCredentialService.init(); + // `syncBody` no-ops without it, and the background tick is when the + // phone ingests the morning weigh-in. + await BodyService.init(); final SyncSettings settings; try { settings = await SyncSettings.load(); diff --git a/app/lib/services/body_calc.dart b/app/lib/services/body_calc.dart new file mode 100644 index 0000000..92d7230 --- /dev/null +++ b/app/lib/services/body_calc.dart @@ -0,0 +1,238 @@ +/// BMI, ideal-weight and resting-metabolism formulas -- pure arithmetic. +/// +/// Byte-for-byte port of `diet_guard/_body_calc.py`, gated by the shared +/// `tests/fixtures/body_calc.json` (`test/services/body_parity_test.dart`). +/// Rounding goes through [halfUp], never `.round()`: Dart rounds half away +/// from zero and Python's `round` is banker's, so the bare builtins would +/// print different numbers on the two devices for the same person. +library; + +import 'dart:math' as math; + +const double _cmPerInch = 2.54; +const double _fiveFeetIn = 60; + +/// Adult BMI categories are WHO's and do not apply to anyone still growing. +const int adultAge = 20; +const double _bmiPrimeDivisor = 25; +const double _healthyBmiLow = 18.5; +const double _healthyBmiHigh = 24.9; +const double _targetBmi = 22; + +// (upper bound exclusive, label), WHO adult classification. +const List<(double, String)> _bmiBands = [ + (16, 'severe thinness'), + (17, 'moderate thinness'), + (18.5, 'mild thinness'), + (25, 'normal'), + (30, 'overweight'), + (35, 'obese class I'), + (40, 'obese class II'), +]; +const String _topBand = 'obese class III'; + +/// BMI Prime explained, shown under the numbers on both surfaces. +const String bmiPrimeNote = + 'BMI Prime = BMI / 25: 1.00 is the top of the healthy range, ' + 'so 1.02 means 2% above it.'; + +/// Trefethen's BMI explained, shown under the numbers on both surfaces. +const String trefethenNote = + "Trefethen's new BMI = 1.3 x kg / m^2.5: corrects classic BMI, which " + 'over-reads tall people and under-reads short ones.'; + +/// Broca explained, shown under the ideal-weight list. +const String brocaNote = + 'Broca = height in cm - 100 (1871); it overshoots, so Broca (modified) ' + 'takes 10% off it for men and 15% for women.'; + +/// The lean-mass formula the calorie table switches to with a body fat %. +const String katchMcArdle = 'Katch-McArdle'; + +/// Rounds [value] half-up at [digits] decimals, identically to Python. +double halfUp(double value, [int digits = 0]) { + final scale = math.pow(10, digits).toDouble(); + return (value * scale + 0.5).floorToDouble() / scale; +} + +/// Body metrics that feed every formula. +/// +/// Mirrors Python's `_budget_biometrics.Biometrics`. +class Biometrics { + /// Creates a set of body metrics. + const Biometrics({ + required this.weightKg, + required this.heightCm, + required this.ageYears, + required this.isMale, + }); + + /// Body mass in kilograms. + final double weightKg; + + /// Height in centimetres. + final double heightCm; + + /// Age in completed years. + final double ageYears; + + /// True for the male constants. + final bool isMale; +} + +/// One named formula's result; [value] is null when it does not apply. +class Formula { + /// Creates a formula result. + const Formula(this.name, this.value); + + /// Display name. + final String name; + + /// The result, or null when undefined for this person. + final double? value; +} + +/// Body-mass index and its readings. +class BmiReport { + /// Creates a report. + const BmiReport({ + required this.bmi, + required this.prime, + required this.trefethen, + required this.category, + }); + + /// Classic BMI, kg / m^2. + final double bmi; + + /// BMI divided by 25. + final double prime; + + /// Trefethen's "new BMI", 1.3 * kg / m^2.5. + final double trefethen; + + /// WHO adult band, or a note that it does not apply (< 20 y). + final String category; +} + +/// Mifflin-St Jeor resting metabolic rate, kcal/day. +double mifflinStJeorBmr(Biometrics bio) { + final base = 10.0 * bio.weightKg + 6.25 * bio.heightCm - 5.0 * bio.ageYears; + return bio.isMale ? base + 5.0 : base - 161.0; +} + +String _category(double bmi, double ageYears) { + if (ageYears < adultAge) return 'adult categories do not apply under 20'; + for (final (upper, label) in _bmiBands) { + if (bmi < upper) return label; + } + return _topBand; +} + +/// Returns BMI, BMI Prime, Trefethen's BMI and the WHO category. +BmiReport bmiReport(Biometrics bio) { + final metres = bio.heightCm / 100.0; + final bmi = bio.weightKg / (metres * metres); + return BmiReport( + bmi: bmi, + prime: bmi / _bmiPrimeDivisor, + trefethen: 1.3 * bio.weightKg / math.pow(metres, 2.5), + category: _category(bmi, bio.ageYears), + ); +} + +double? _perInch( + double inches, + double base, + double perInch, { + required bool defined, +}) => defined ? base + perInch * inches : null; + +/// Every ideal-body-weight formula we can evaluate, in kg. +/// +/// Only Creff uses age. The BMI healthy range is [healthyRange], shown with +/// BMI; [idealSummary] averages these. +List idealWeights(Biometrics bio) { + final h = bio.heightCm; + final inches = h / _cmPerInch - _fiveFeetIn; + final ok = inches >= 0; + final male = bio.isMale; + final metres = h / 100.0; + return [ + Formula('Devine', _perInch(inches, male ? 50 : 45.5, 2.3, defined: ok)), + Formula( + 'Robinson', + _perInch(inches, male ? 52 : 49, male ? 1.9 : 1.7, defined: ok), + ), + Formula( + 'Miller', + _perInch(inches, male ? 56.2 : 53.1, male ? 1.41 : 1.36, defined: ok), + ), + Formula( + 'Hamwi', + _perInch(inches, male ? 48 : 45.5, male ? 2.7 : 2.2, defined: ok), + ), + Formula('Broca', h - 100.0), + Formula('Broca (modified)', (h - 100.0) * (male ? 0.9 : 0.85)), + Formula('Lorentz', h - 100.0 - (h - 150.0) / (male ? 4.0 : 2.0)), + Formula('Creff (age)', (h - 100.0 + bio.ageYears / 10.0) * 0.9), + Formula( + 'Peterson (BMI 22)', + 2.2 * _targetBmi + 3.5 * _targetBmi * (metres - 1.5), + ), + ]; +} + +/// The weights (kg) at BMI 18.5 and 24.9 for this height. +(double, double) healthyRange(Biometrics bio) { + final metres = bio.heightCm / 100.0; + return (_healthyBmiLow * metres * metres, _healthyBmiHigh * metres * metres); +} + +/// `(average, lowest, highest)` of the formulas that apply, or null. +(double, double, double)? idealSummary(List formulas) { + final values = [for (final f in formulas) ?f.value]; + if (values.isEmpty) return null; + final sum = values.fold(0, (a, b) => a + b); + return ( + sum / values.length, + values.reduce(math.min), + values.reduce(math.max), + ); +} + +/// Resting metabolic rate by every formula, kcal/day. +/// +/// Katch-McArdle and Cunningham work from lean mass, so they are null until +/// a body-fat % is logged. +List bmrFormulas(Biometrics bio, [double? bodyFatPct]) { + final w = bio.weightKg; + final h = bio.heightCm; + final a = bio.ageYears; + final revised = bio.isMale + ? 88.362 + 13.397 * w + 4.799 * h - 5.677 * a + : 447.593 + 9.247 * w + 3.098 * h - 4.330 * a; + final lean = bodyFatPct == null ? null : w * (1.0 - bodyFatPct / 100.0); + return [ + Formula('Mifflin-St Jeor', mifflinStJeorBmr(bio)), + Formula('Harris-Benedict (revised 1984)', revised), + Formula(katchMcArdle, lean == null ? null : 370.0 + 21.6 * lean), + Formula('Cunningham', lean == null ? null : 500.0 + 22.0 * lean), + ]; +} + +/// The BMR the calorie table uses: Katch-McArdle with body fat, else Mifflin. +Formula tableBmr(Biometrics bio, double? bodyFatPct) { + final formulas = bmrFormulas(bio, bodyFatPct); + return bodyFatPct != null ? formulas[2] : formulas[0]; +} + +/// Completed years between [birth] and [today]. +int ageOn(DateTime birth, DateTime today) { + var years = today.year - birth.year; + if (today.month < birth.month || + (today.month == birth.month && today.day < birth.day)) { + years -= 1; + } + return years; +} diff --git a/app/lib/services/body_energy.dart b/app/lib/services/body_energy.dart new file mode 100644 index 0000000..555be66 --- /dev/null +++ b/app/lib/services/body_energy.dart @@ -0,0 +1,206 @@ +/// The daily calorie target for one chosen goal -- the Body screen's top +/// line: "To **lose** **0.5** **kg** / **week** on **Moderate** eat +/// **2019** kcal". Every bold word is a [Goal] choice, remembered and synced +/// (`body_prefs.dart`). **Informational only**: nothing here writes the +/// budget. +/// +/// Port of `diet_guard/_body_energy.py`, gated by the shared +/// `tests/fixtures/body_calc.json`. TDEE is the table BMR x an activity +/// factor, or for a "Measured" choice `BMR x 1.2` plus the real mean daily +/// exercise by that formula. Target = `TDEE -/+ kg per day x 7700`; below +/// the 1200 kcal floor it is flagged, never clamped. +library; + +import 'package:diet_guard_app/services/activity_kcal.dart'; +import 'package:diet_guard_app/services/body_calc.dart'; + +/// kcal per kg of body fat. +const double kcalPerKg = 7700; + +/// The floor below which a target is flagged. +const int minTargetKcal = 1200; + +const double _sedentary = 1.2; + +/// A "Measured" choice is `measured:` (MET, ACSM, Per-km). +const String measuredPrefix = 'measured:'; + +/// Goal directions, in menu order. +const List directions = ['lose', 'maintain', 'gain']; + +/// Offered in the amount picker; any other typed number is accepted too. +const List presetAmounts = [0.25, 0.5, 0.75, 1]; + +/// Kilograms per weight unit. +const Map weightUnits = { + 'kg': 1, + 'lb': 0.45359237, + 'g': 0.001, + 'st': 6.35029318, +}; + +/// Days per time unit; a month is the mean Gregorian month. +const Map timeUnits = { + 'day': 1, + 'week': 7, + 'month': 30.436875, + 'year': 365.2425, +}; + +/// One selectable activity level and its TDEE multiplier. +class ActivityLevel { + /// Creates a level. + const ActivityLevel(this.key, this.label, this.factor, this.meaning); + + /// Stable key. + final String key; + + /// Display label. + final String label; + + /// BMR multiplier. + final double factor; + + /// What the level means, shown in the picker. + final String meaning; +} + +/// Every activity level, in display order. +const List activityLevels = [ + ActivityLevel('sedentary', 'Sedentary', 1.2, 'little or no exercise'), + ActivityLevel('light', 'Light', 1.375, 'exercise 1-3x a week'), + ActivityLevel('moderate', 'Moderate', 1.55, 'exercise 3-5x a week'), + ActivityLevel('very', 'Very active', 1.725, 'exercise 6-7x a week'), + ActivityLevel('extra', 'Extra active', 1.9, 'training twice a day'), +]; + +/// Every activity choice, in menu order: the five levels, then measured. +final List activityChoices = [ + for (final level in activityLevels) level.key, + for (final name in activityFormulas) '$measuredPrefix$name', +]; + +/// What the user wants: direction, amount per time, at an activity. +class Goal { + /// Creates a goal. + const Goal( + this.direction, + this.amount, + this.weightUnit, + this.timeUnit, + this.activity, + ); + + /// `lose`, `maintain` or `gain`. + final String direction; + + /// How much, in [weightUnit] per [timeUnit]. + final double amount; + + /// A [weightUnits] key. + final String weightUnit; + + /// A [timeUnits] key. + final String timeUnit; + + /// An [activityChoices] entry. + final String activity; + + /// Returns a copy with the given parts replaced. + Goal copyWith({ + String? direction, + double? amount, + String? weightUnit, + String? timeUnit, + String? activity, + }) => Goal( + direction ?? this.direction, + amount ?? this.amount, + weightUnit ?? this.weightUnit, + timeUnit ?? this.timeUnit, + activity ?? this.activity, + ); + + /// Signed change in kg/day: positive loses, negative gains. + double kgPerDay() { + if (direction == 'maintain') return 0; + final rate = amount * weightUnits[weightUnit]! / timeUnits[timeUnit]!; + return direction == 'lose' ? rate : -rate; + } +} + +/// The goal until the user picks one. +const Goal defaultGoal = Goal('lose', 0.5, 'kg', 'week', 'moderate'); + +/// The answer to a [Goal], or why there is none. +class Target { + /// Creates a target. + const Target({required this.kcal, required this.tdee, required this.bmrName}); + + /// Daily target, or null when the goal needs unpublished measured data. + final int? kcal; + + /// The maintenance figure it was derived from, when known. + final double? tdee; + + /// The resting-burn formula the TDEE scales. + final String bmrName; + + /// Whether the target is under the 1200 kcal floor. + bool get belowFloor { + final value = kcal; + return value != null && value < minTargetKcal; + } +} + +/// Python's `f"{x:g}"`: six significant digits, trailing zeros dropped. +String formatG(double value) { + if (value == value.truncateToDouble() && value.abs() < 1e15) { + return value.toInt().toString(); + } + var text = value.toStringAsPrecision(6); + if (text.contains('.') && !text.contains('e')) { + text = text + .replaceFirst(RegExp(r'0+$'), '') + .replaceFirst(RegExp(r'\.$'), ''); + } + return text; +} + +/// Menu text for an activity choice, e.g. `Measured (ACSM)`. +String activityLabel(String key) { + if (key.startsWith(measuredPrefix)) { + return 'Measured (${key.substring(measuredPrefix.length)})'; + } + for (final level in activityLevels) { + if (level.key == key) return level.label; + } + return key; +} + +double? _tdee(double bmr, String activity, Map? exercise) { + if (activity.startsWith(measuredPrefix)) { + final kcal = exercise?[activity.substring(measuredPrefix.length)]; + return kcal == null ? null : bmr * _sedentary + kcal; + } + for (final level in activityLevels) { + if (level.key == activity) return bmr * level.factor; + } + return null; +} + +/// The daily kcal that meets [goal] from a [bmr] (kcal/day). +Target goalTarget( + Goal goal, + double bmr, + String bmrName, + Map? exercise, +) { + final tdee = _tdee(bmr, goal.activity, exercise); + if (tdee == null) return Target(kcal: null, tdee: null, bmrName: bmrName); + return Target( + kcal: halfUp(tdee - goal.kgPerDay() * kcalPerKg).toInt(), + tdee: tdee, + bmrName: bmrName, + ); +} diff --git a/app/lib/services/body_prefs.dart b/app/lib/services/body_prefs.dart new file mode 100644 index 0000000..3372716 --- /dev/null +++ b/app/lib/services/body_prefs.dart @@ -0,0 +1,49 @@ +/// The calorie goal the user last picked -- remembered and synced. +/// +/// Stored as one extra field, `goal`, on the synced `profile` record (its +/// own stamp: saving it never counts as editing birth, height or sex), so the +/// sentence "To lose 0.5 kg / week on Moderate eat ..." reads the same on +/// the PC gate and the phone. Each part is validated on read: an unknown +/// direction, unit or activity falls back to that part of [defaultGoal]. +/// +/// Port of `diet_guard/_body_prefs.py`. +library; + +import 'package:diet_guard_app/services/body_energy.dart'; +import 'package:diet_guard_app/services/body_service.dart'; + +/// The profile field the goal is stored under. +const String goalField = 'goal'; + +/// The wire/disk form (short keys). +Map goalToJson(Goal goal) => { + 'dir': goal.direction, + 'amt': goal.amount, + 'wu': goal.weightUnit, + 'tu': goal.timeUnit, + 'act': goal.activity, +}; + +/// Parses a stored goal part by part, defaulting whatever is unusable. +Goal goalFromJson(Object? raw) { + if (raw is! Map) return defaultGoal; + final dir = raw['dir']; + final amt = raw['amt']; + final wu = raw['wu']; + final tu = raw['tu']; + final act = raw['act']; + return defaultGoal.copyWith( + direction: dir is String && directions.contains(dir) ? dir : null, + amount: amt is num && amt >= 0 ? amt.toDouble() : null, + weightUnit: wu is String && weightUnits.containsKey(wu) ? wu : null, + timeUnit: tu is String && timeUnits.containsKey(tu) ? tu : null, + activity: act is String && activityChoices.contains(act) ? act : null, + ); +} + +/// The remembered goal (the default until one is saved). +Goal savedGoal(BodyService body) => goalFromJson(body.profileValue(goalField)); + +/// Remembers (and syncs) [goal]. +Future setGoal(BodyService body, Goal goal) => + body.setProfileFields({goalField: goalToJson(goal)}); diff --git a/app/lib/services/body_service.dart b/app/lib/services/body_service.dart new file mode 100644 index 0000000..1a928ad --- /dev/null +++ b/app/lib/services/body_service.dart @@ -0,0 +1,225 @@ +/// Persistence for the Body document: profile, weight log, PC workouts. +/// +/// A singleton over the platform [DocumentStore], shaped like +/// `MealScheduleService`. Every setter stamps the edited part with "now", so +/// a manual edit outranks an ingested phone weigh-in of the same day in the +/// last-writer-wins merge (`sync_merge_body.dart`). Nothing here touches the +/// budget -- the Body screen is informational only. +/// +/// Mirrors the storage half of `diet_guard/_body_store.py`. +library; + +import 'dart:convert'; + +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/services/activity_kcal.dart'; +import 'package:diet_guard_app/services/body_calc.dart'; +import 'package:diet_guard_app/services/document_store.dart'; +import 'package:diet_guard_app/services/document_store_factory.dart'; +import 'package:flutter/foundation.dart'; + +/// Singleton owning the `body.json` document. +class BodyService { + BodyService._(this._store); + + /// Document name this service owns. + static const documentName = 'body.json'; + + static BodyService? _instance; + + /// Returns the initialized singleton; throws if [init] was not called. + static BodyService get instance => _instance!; + + /// True once [init] (or a test initialiser) has run. + static bool get isInitialized => _instance != null; + + final DocumentStore _store; + BodyDocument _doc = const BodyDocument(); + + /// The whole document, for the sync layer. + BodyDocument get document => _doc; + + /// Initialises the singleton against the platform document store. + static Future init() async { + if (_instance != null) return _instance!; + // coverage:ignore-start + final svc = BodyService._(await openDocumentStore()); + // coverage:ignore-end + await svc._load(); + _instance = svc; + return svc; + } + + /// Initialises from [store], for tests. + @visibleForTesting + static Future initForTesting(DocumentStore store) async { + final svc = BodyService._(store); + await svc._load(); + _instance = svc; + return svc; + } + + /// Drops the singleton, or -- given a [store] -- seeds an empty one over + /// it so a later [init] reuses it instead of reaching the platform store. + @visibleForTesting + static void resetForTesting({DocumentStore? store}) { + _instance = store == null ? null : BodyService._(store); + } + + Future _load() async { + final raw = await _store.read(documentName); + if (raw == null) return; + try { + _doc = BodyDocument.fromJson(jsonDecode(raw)); + } on FormatException { + // A corrupt document reads as empty, like the other services. + } + } + + Future _write(BodyDocument doc) async { + _doc = doc; + await _store.write(documentName, jsonEncode(doc.toJson())); + } + + /// Birth date as `YYYY-MM-DD`, or null. + String? get birth => _string(profileBirth); + + /// Height in cm, or null. + double? get heightCm { + final value = _doc.profile[profileHeight]?.value; + return value is num ? value.toDouble() : null; + } + + /// `m` / `f`, or null. + String? get sex { + final value = _string(profileSex); + return value == 'm' || value == 'f' ? value : null; + } + + /// Whether birth date, height and sex are all set -- the Body screen then + /// moves the profile card to the bottom. + bool get profileComplete => + DateTime.tryParse(birth ?? '') != null && heightCm != null && sex != null; + + String? _string(String name) { + final value = _doc.profile[name]?.value; + return value is String && value.isNotEmpty ? value : null; + } + + /// Sets the given profile fields, each stamped now; others are untouched. + Future setProfile({ + String? birth, + double? heightCm, + String? sex, + DateTime? now, + }) => setProfileFields({ + profileBirth: ?birth, + profileHeight: ?heightCm, + profileSex: ?sex, + }, now: now); + + /// Sets raw profile [fields], each stamped now; others keep their stamps. + Future setProfileFields( + Map fields, { + DateTime? now, + }) async { + if (fields.isEmpty) return; + final t = isoLocalSeconds(now ?? DateTime.now()); + final profile = Map.from(_doc.profile); + fields.forEach((name, value) => profile[name] = Stamped(value, t)); + await _write(_doc.copyWith(profile: profile)); + } + + /// A raw profile value, or null. + Object? profileValue(String name) => _doc.profile[name]?.value; + + /// Records [kg] for [day] (`YYYY-MM-DD`), stamped now. + Future setWeight(String day, double kg, {DateTime? now}) => + _putWeight(day, kg, now); + + /// Deletes [day]'s weight (a tombstone, so the delete syncs). + Future deleteWeight(String day, {DateTime? now}) => + _putWeight(day, null, now); + + Future _putWeight(String day, double? kg, DateTime? now) async { + final weights = Map.from(_doc.weights); + weights[day] = WeightEntry( + kg: kg, + src: 'manual', + t: isoLocalSeconds(now ?? DateTime.now()), + ); + await _write(_doc.copyWith(weights: weights)); + } + + /// Records [pct] body fat for [day], stamped now (one decimal, half-up). + Future setBodyFat(String day, double pct, {DateTime? now}) => + _putBodyFat(day, halfUp(pct, 1), now); + + /// Deletes [day]'s body fat (a tombstone, so the delete syncs). + Future deleteBodyFat(String day, {DateTime? now}) => + _putBodyFat(day, null, now); + + Future _putBodyFat(String day, double? pct, DateTime? now) async { + final bodyFat = Map.from(_doc.bodyFat); + bodyFat[day] = BodyFatEntry( + pct: pct, + t: isoLocalSeconds(now ?? DateTime.now()), + ); + await _write(_doc.copyWith(bodyFat: bodyFat)); + } + + /// Non-deleted body fat readings, oldest first, as `(YYYY-MM-DD, %)`. + List<(String, double)> get bodyFatSeries => + _series(_doc.bodyFat.map((day, entry) => MapEntry(day, entry.pct))); + + /// The newest non-deleted body fat %, or null. + (String, double)? get latestBodyFat { + final series = bodyFatSeries; + return series.isEmpty ? null : series.last; + } + + /// Applies ingested weigh-ins: each replaces its day only when the local + /// entry is missing or stamped **older**. Returns whether anything changed. + Future applyCandidates(Map candidates) async { + final weights = Map.from(_doc.weights); + var changed = false; + candidates.forEach((day, candidate) { + final local = weights[day]; + if (local == null || stampMillis(local.t) < stampMillis(candidate.t)) { + weights[day] = candidate; + changed = true; + } + }); + if (changed) await _write(_doc.copyWith(weights: weights)); + return changed; + } + + /// Replaces the document with a sync merge result. + Future applyMerged(BodyDocument merged) => _write(merged); + + /// Non-deleted weights, oldest first, as `(YYYY-MM-DD, kg)`. + List<(String, double)> get weightSeries => + _series(_doc.weights.map((day, entry) => MapEntry(day, entry.kg))); + + static List<(String, double)> _series(Map byDay) { + final days = byDay.keys.toList()..sort(); + return [ + for (final day in days) + if (byDay[day] case final value?) (day, value), + ]; + } + + /// The newest non-deleted weight, or null. + (String, double)? get latestWeight { + final series = weightSeries; + return series.isEmpty ? null : series.last; + } + + /// Workouts the PC published, parsed; null when it never published any. + List? get sessions { + final activity = _doc.activity; + if (activity == null) return null; + return [for (final raw in activity.sessions) ?Session.fromWire(raw)]; + } +} diff --git a/app/lib/services/body_steps.dart b/app/lib/services/body_steps.dart new file mode 100644 index 0000000..d87188f --- /dev/null +++ b/app/lib/services/body_steps.dart @@ -0,0 +1,30 @@ +/// Daily steps (from Health Connect, phone-written) as exercise kcal. +/// +/// Only steps above [stepBaseline] count: the `BMR x 1.2` sedentary +/// baseline the measured TDEE starts from already assumes a few thousand +/// steps of ordinary movement. The rest are walking at the per-km rate +/// (0.5 kcal per kg per km, net of resting) over a stride estimated from +/// height. Port of `diet_guard/_body_steps.py`. +library; + +import 'package:diet_guard_app/services/body_calc.dart'; + +/// Steps a day the sedentary baseline already covers. +const int stepBaseline = 4000; + +// Stride as a fraction of height (the common pedometer estimate). +const double _strideMale = 0.415; +const double _strideFemale = 0.413; +const double _walkKcalPerKgKm = 0.5; +const double _mPerKm = 1000; + +/// Estimated walking stride in metres. +double strideM(Biometrics bio) => + bio.heightCm / 100.0 * (bio.isMale ? _strideMale : _strideFemale); + +/// Net kcal of the steps above the baseline, as walking. +double stepKcal(int steps, Biometrics bio) { + final extra = steps > stepBaseline ? steps - stepBaseline : 0; + final km = extra * strideM(bio) / _mPerKm; + return km * _walkKcalPerKgKm * bio.weightKg; +} diff --git a/app/lib/services/health_steps.dart b/app/lib/services/health_steps.dart new file mode 100644 index 0000000..e309a22 --- /dev/null +++ b/app/lib/services/health_steps.dart @@ -0,0 +1,133 @@ +/// Daily steps from Android Health Connect -> the synced Body document. +/// +/// The phone is the only writer: it holds the Health Connect permission. +/// Each refresh reads the step intervals of the last [stepsWindowDays] days, +/// **drops every interval overlapping a published workout's `start..end`** +/// (so a recorded walk is not counted as both a workout and steps), sums the +/// rest per local day and stores `steps: {day: {n, t}}`, re-stamping a day +/// only when its count changed -- so an unchanged day re-syncs as a no-op. +/// +/// Foreground only: reading in the background isolate needs the extra +/// `READ_HEALTH_DATA_IN_BACKGROUND` permission, which this app does not ask +/// for. And never prompts on its own: [refreshSteps] only reads when the +/// permission is already granted; the Body screen's button asks. +/// +/// The platform edge is [StepsSource], picked by the conditional export in +/// `health_steps_source.dart` (Health Connect on Android, nothing on web). +library; + +import 'dart:developer'; + +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/services/activity_kcal.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/services/health_steps_source.dart'; + +/// How far back each refresh reads. +const int stepsWindowDays = 28; + +/// One Health Connect steps record: [count] steps between [from] and [to]. +class StepInterval { + /// Creates an interval. + const StepInterval(this.from, this.to, this.count); + + /// Start of the interval. + final DateTime from; + + /// End of the interval. + final DateTime to; + + /// Steps taken in it. + final int count; +} + +/// Where step intervals come from. +abstract class StepsSource { + /// Whether this platform can read steps at all. + bool get supported; + + /// Whether the read permission is already granted (never prompts). + Future hasPermission(); + + /// Asks the user for the read permission; true when granted. + Future requestPermission(); + + /// Every steps record between [from] and [to]. + Future> read(DateTime from, DateTime to); +} + +/// The source in use; tests replace it. +StepsSource stepsSource = createStepsSource(); + +/// Steps per local day, leaving out intervals overlapping any workout. +/// +/// Sessions without both `start` and `end` exclude nothing. +Map stepsOutsideWorkouts( + List intervals, + List sessions, +) { + final windows = [ + for (final session in sessions) + if (DateTime.tryParse(session.start ?? '') case final start?) + if (DateTime.tryParse(session.end ?? '') case final end?) (start, end), + ]; + final byDay = {}; + for (final interval in intervals) { + final overlaps = windows.any( + (window) => + interval.from.isBefore(window.$2) && interval.to.isAfter(window.$1), + ); + if (overlaps) continue; + final day = localDateKey(interval.from.toLocal()); + byDay[day] = (byDay[day] ?? 0) + interval.count; + } + return byDay; +} + +/// Stores [counts], re-stamping only the days whose count changed. +/// Returns whether anything was written. +Future applySteps( + BodyService body, + Map counts, { + DateTime? now, +}) async { + final steps = Map.from(body.document.steps); + final t = isoLocalSeconds(now ?? DateTime.now()); + var changed = false; + counts.forEach((day, n) { + if (steps[day]?.n == n) return; + steps[day] = StepsEntry(n: n, t: t); + changed = true; + }); + if (changed) await body.applyMerged(body.document.copyWith(steps: steps)); + return changed; +} + +/// Reads Health Connect and stores the daily counts, when permitted. +/// +/// Never throws and never prompts: a missing permission, an unsupported +/// platform or a Health Connect error just leaves the stored counts alone. +Future refreshSteps({DateTime? now}) async { + final source = stepsSource; + if (!BodyService.isInitialized || !source.supported) return false; + try { + if (!await source.hasPermission()) return false; + final end = now ?? DateTime.now(); + final start = DateTime(end.year, end.month, end.day - stepsWindowDays); + final body = BodyService.instance; + final counts = stepsOutsideWorkouts( + await source.read(start, end), + body.sessions ?? const [], + ); + return await applySteps(body, counts, now: now); + } on Object catch (error, stackTrace) { + log( + 'Health Connect steps refresh failed', + level: 900, + error: error, + stackTrace: stackTrace, + ); + return false; + } +} diff --git a/app/lib/services/health_steps_source.dart b/app/lib/services/health_steps_source.dart new file mode 100644 index 0000000..f84ca61 --- /dev/null +++ b/app/lib/services/health_steps_source.dart @@ -0,0 +1,7 @@ +/// Picks the [StepsSource] for this platform: Health Connect where +/// `dart:io` exists (Android), nothing on web -- the desktop app is a web +/// build and has no Health Connect. +library; + +export 'health_steps_source_io.dart' + if (dart.library.js_interop) 'health_steps_source_web.dart'; diff --git a/app/lib/services/health_steps_source_io.dart b/app/lib/services/health_steps_source_io.dart new file mode 100644 index 0000000..d1d4e33 --- /dev/null +++ b/app/lib/services/health_steps_source_io.dart @@ -0,0 +1,65 @@ +/// Native half of `health_steps_source.dart`: Android Health Connect steps. +/// +/// Anything but Android reports unsupported (the Linux `flutter test` VM +/// included), so tests never reach the plugin's platform channel. +library; + +import 'dart:io'; + +import 'package:diet_guard_app/services/health_steps.dart'; +import 'package:health/health.dart'; + +const List _types = [HealthDataType.STEPS]; +const List _access = [HealthDataAccess.READ]; + +/// Health Connect on Android, nothing elsewhere. +StepsSource createStepsSource() => HealthConnectStepsSource(); + +/// Reads `StepsRecord`s through the `health` plugin. +class HealthConnectStepsSource implements StepsSource { + Health? _health; + + @override + bool get supported => Platform.isAndroid; + + // The rest talks to the plugin's platform channel, which `flutter test` + // has no binding for -- same reason `firebase_backend.dart` is excluded. + // coverage:ignore-start + Future _plugin() async { + final existing = _health; + if (existing != null) return existing; + final health = Health(); + await health.configure(); + return _health = health; + } + + @override + Future hasPermission() async => + await (await _plugin()).hasPermissions(_types, permissions: _access) ?? + false; + + @override + Future requestPermission() async => await (await _plugin()) + .requestAuthorization(_types, permissions: _access); + + @override + Future> read(DateTime from, DateTime to) async { + // Already de-duplicated by the plugin (`removeDuplicates`). + final points = await (await _plugin()).getHealthDataFromTypes( + types: _types, + startTime: from, + endTime: to, + ); + return [ + for (final point in points) + if (point.value case final NumericHealthValue value) + StepInterval( + point.dateFrom, + point.dateTo, + value.numericValue.toInt(), + ), + ]; + } + + // coverage:ignore-end +} diff --git a/app/lib/services/health_steps_source_web.dart b/app/lib/services/health_steps_source_web.dart new file mode 100644 index 0000000..49514e9 --- /dev/null +++ b/app/lib/services/health_steps_source_web.dart @@ -0,0 +1,25 @@ +/// Web half of `health_steps_source.dart`: no Health Connect in a browser. +library; + +import 'package:diet_guard_app/services/health_steps.dart'; + +/// A source that never reads anything. +StepsSource createStepsSource() => const NoStepsSource(); + +/// Reports "unsupported" and reads nothing. +class NoStepsSource implements StepsSource { + /// Creates the source. + const NoStepsSource(); + + @override + bool get supported => false; + + @override + Future hasPermission() async => false; + + @override + Future requestPermission() async => false; + + @override + Future> read(DateTime from, DateTime to) async => []; +} diff --git a/app/lib/services/sync_body.dart b/app/lib/services/sync_body.dart new file mode 100644 index 0000000..ca718bc --- /dev/null +++ b/app/lib/services/sync_body.dart @@ -0,0 +1,35 @@ +/// The Body document's part of a sync tick. +/// +/// Its own file because `sync_service.dart` is near the repo's 250-line cap. +/// First ingests the wake-alarm weigh-ins (never throws), then pulls peers' +/// `body.json`, merges last-writer-wins per field, applies the result locally +/// and pushes. Mirrors `diet_guard/_sync_body.py`. +library; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/services/sync_device_id.dart'; +import 'package:diet_guard_app/services/sync_merge_body.dart'; +import 'package:diet_guard_app/services/wake_alarm_weights.dart'; + +/// Runs the Body sync against [client], under [devicesDir]. +/// +/// No-ops when [BodyService] is uninitialised (a widget test, or an isolate +/// that never loaded it), so it can neither publish nor clobber anything. +Future syncBody(RemoteStore client, {required String devicesDir}) async { + if (!BodyService.isInitialized) return; + await ingestWakeAlarmWeights(client); + final merged = await syncLog( + client: client, + deviceId: currentSyncDeviceId, + legacyDeviceId: legacySyncDeviceId, + pathPrefix: devicesDir, + localLog: bodyToLog(BodyService.instance.document), + encode: encodeBodyForPush, + decode: parseRemoteBody, + filename: 'body.json', + commitMessage: 'diet_guard_app sync', + ); + if (merged.isEmpty) return; + await BodyService.instance.applyMerged(logToBody(merged)); +} diff --git a/app/lib/services/sync_merge_body.dart b/app/lib/services/sync_merge_body.dart new file mode 100644 index 0000000..fa7f1d9 --- /dev/null +++ b/app/lib/services/sync_merge_body.dart @@ -0,0 +1,172 @@ +/// Body document <-> crdt_sync Record adapters. +/// +/// Wire format (see `docs/DOCS-body.md`), one Log per device-pushed +/// `body.json`: +/// +/// * `profile`: fields `birth`, `height_cm`, `sex`, `tbl_act`, `tbl_rates`, +/// each with its own clock, so editing height on one device cannot revert +/// a birth date edited on the other. Unknown fields are relayed as-is. +/// * `w:`: field `kg` = `{"kg": number|null, "src": string}`. +/// * `bf:`: field `pct` = `{"pct": number|null}`. +/// * `st:`: field `n` = `{"n": int}` (phone-written steps). +/// * `activity`: field `sessions` = the PC's published workouts. +/// +/// Every clock's wall time is the part's own `t` stamp -- the same +/// determinism trick as `sync_merge_budget.dart`, so re-syncing an unchanged +/// document is a no-op. KEEP IN SYNC WITH `diet_guard/sync_merge/_body.py`. +library; + +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/services/sync_device_id.dart'; + +/// The profile record's id. +const profileRecordId = 'profile'; + +/// Weight record id prefix. +const weightRecordPrefix = 'w:'; + +/// Body-fat record id prefix. +const bodyFatRecordPrefix = 'bf:'; + +/// Steps record id prefix. +const stepsRecordPrefix = 'st:'; + +/// The activity record's id. +const activityRecordId = 'activity'; + +Hlc _hlc(String t) => + Hlc.newTick(currentSyncDeviceId, wallTimeMsOverride: stampMillis(t)); + +/// Rebuilds an edit stamp from a merged clock: local ISO, with milliseconds +/// only when the clock has them, so a whole-second stamp round-trips +/// byte-for-byte and re-derives the same clock. +String stampFromHlc(Hlc hlc) { + final moment = DateTime.fromMillisecondsSinceEpoch(hlc.wallTimeMs); + if (hlc.wallTimeMs % 1000 == 0) return isoLocalSeconds(moment); + return moment.toUtc().toIso8601String(); +} + +/// Converts the local document into a [Log]. +Log bodyToLog(BodyDocument doc) { + final log = {}; + if (doc.profile.isNotEmpty) { + log[profileRecordId] = Record( + id: profileRecordId, + fields: { + for (final entry in doc.profile.entries) + entry.key: (entry.value.value, _hlc(entry.value.t)), + }, + ); + } + for (final entry in doc.weights.entries) { + final id = '$weightRecordPrefix${entry.key}'; + log[id] = Record( + id: id, + fields: { + 'kg': ( + {'kg': entry.value.kg, 'src': entry.value.src}, + _hlc(entry.value.t), + ), + }, + ); + } + for (final entry in doc.bodyFat.entries) { + final id = '$bodyFatRecordPrefix${entry.key}'; + log[id] = Record( + id: id, + fields: { + 'pct': ({'pct': entry.value.pct}, _hlc(entry.value.t)), + }, + ); + } + for (final entry in doc.steps.entries) { + final id = '$stepsRecordPrefix${entry.key}'; + log[id] = Record( + id: id, + fields: { + 'n': ({'n': entry.value.n}, _hlc(entry.value.t)), + }, + ); + } + final activity = doc.activity; + if (activity != null) { + log[activityRecordId] = Record( + id: activityRecordId, + fields: {'sessions': (activity.sessions, _hlc(activity.t))}, + ); + } + return log; +} + +/// Every profile field, as merged; the service validates on read. +Map _profile(Record? record) => { + if (record != null) + for (final entry in record.fields.entries) + entry.key: Stamped(entry.value.$1, stampFromHlc(entry.value.$2)), +}; + +/// Converts a merged [Log] back into a document. Malformed parts are skipped. +BodyDocument logToBody(Log log) { + final weights = {}; + for (final record in log.values) { + if (record.deleted || !record.id.startsWith(weightRecordPrefix)) continue; + final day = record.id.substring(weightRecordPrefix.length); + final field = record.fields['kg']; + if (!isDateKey(day) || field == null) continue; + final entry = parseWeightValue(field.$1, stampFromHlc(field.$2)); + if (entry != null) weights[day] = entry; + } + final bodyFat = {}; + for (final record in log.values) { + if (record.deleted || !record.id.startsWith(bodyFatRecordPrefix)) continue; + final day = record.id.substring(bodyFatRecordPrefix.length); + final field = record.fields['pct']; + if (!isDateKey(day) || field == null) continue; + final entry = parseBodyFatValue(field.$1, stampFromHlc(field.$2)); + if (entry != null) bodyFat[day] = entry; + } + final steps = {}; + for (final record in log.values) { + if (record.deleted || !record.id.startsWith(stepsRecordPrefix)) continue; + final day = record.id.substring(stepsRecordPrefix.length); + final field = record.fields['n']; + if (!isDateKey(day) || field == null) continue; + final entry = parseStepsValue(field.$1, stampFromHlc(field.$2)); + if (entry != null) steps[day] = entry; + } + final activityField = log[activityRecordId]?.fields['sessions']; + final sessions = activityField?.$1; + return BodyDocument( + profile: _profile(log[profileRecordId]), + weights: weights, + bodyFat: bodyFat, + steps: steps, + activity: activityField == null || sessions is! List + ? null + : ActivityData( + sessions: List.from(sessions), + t: stampFromHlc(activityField.$2), + ), + ); +} + +/// Parses one device's pushed `body.json`. Throws [FormatException] or +/// [TypeError] on a corrupt push, which `syncLog` treats as unparsable. +Log parseRemoteBody(String text) { + final raw = jsonDecode(text); + if (raw is! Map) { + throw const FormatException('top-level body payload is not an object'); + } + return raw.cast().map( + (id, data) => MapEntry(id, Record.fromJson(data as Map)), + ); +} + +/// Serializes a merged body [Log] for push. +String encodeBodyForPush(Log log) => jsonEncode({ + for (final entry in log.entries) entry.key: entry.value.toJson(), +}); diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 650d457..7ab926f 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -20,6 +20,7 @@ import 'package:diet_guard_app/services/foodbank_service.dart'; import 'package:diet_guard_app/services/kuchnia_credential_service.dart'; import 'package:diet_guard_app/services/log_storage_service.dart'; import 'package:diet_guard_app/services/meal_schedule_service.dart'; +import 'package:diet_guard_app/services/sync_body.dart'; import 'package:diet_guard_app/services/sync_device_id.dart'; import 'package:diet_guard_app/services/sync_merge.dart'; import 'package:diet_guard_app/services/sync_merge_kuchnia.dart'; @@ -62,6 +63,7 @@ Future runSync(RemoteStore client, {SyncStateStore? stateStore}) async { await _syncFoodBank(client); await _syncManualBank(client); await _syncKuchniaCredential(client); + await syncBody(client, devicesDir: _devicesDir); return merged; } diff --git a/app/lib/services/wake_alarm_weights.dart b/app/lib/services/wake_alarm_weights.dart new file mode 100644 index 0000000..e4d270e --- /dev/null +++ b/app/lib/services/wake_alarm_weights.dart @@ -0,0 +1,111 @@ +/// Morning weigh-ins from the wake-alarm app, as Body weight candidates. +/// +/// The wake-alarm phone app publishes +/// `wake-alarm-sync/devices//alarm.json`: a crdt_sync Log whose +/// `alarm` record carries `fields[name] = [value, hlc]`. Two fields hold +/// weights: +/// +/// * `latest_weight_kg` = `{"date", "kg"}` -- stamped with **that field's** +/// clock, so a re-typed weigh-in is newer and wins; +/// * `morning_sessions` = recent sessions with `date` and `weight_kg` -- +/// stamped `T00:00:00+00:00`, so they only fill gaps: the list is +/// republished every morning with a new clock, and using that clock would +/// overwrite every manual correction daily. +/// +/// Pure: raw JSON in, candidates out; [ingestWakeAlarmWeights] is the impure +/// edge. KEEP IN SYNC WITH the PC's ingestion in `diet_guard/_body_ingest.py`. +library; + +import 'dart:convert'; +import 'dart:developer'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/services/sync_merge_body.dart'; + +/// Where the wake-alarm app publishes, one directory per device. +const wakeAlarmDevicesDir = 'wake-alarm-sync/devices'; + +const _latestField = 'latest_weight_kg'; +const _sessionsField = 'morning_sessions'; + +List? _pair(Map fields, String name) { + final pair = fields[name]; + return pair is List && pair.length >= 2 ? pair : null; +} + +void _offer(Map out, Object? day, Object? kg, String t) { + if (!isDateKey(day) || !plausibleKg(kg)) return; + final key = day! as String; + final current = out[key]; + if (current != null && stampMillis(current.t) >= stampMillis(t)) return; + out[key] = WeightEntry(kg: (kg! as num).toDouble(), src: 'phone', t: t); +} + +void _fromLog(Map out, Object? log) { + if (log is! Map) return; + final record = log['alarm']; + final fields = record is Map ? record['fields'] : null; + if (fields is! Map) return; + final latest = _pair(fields, _latestField); + final value = latest?[0]; + final hlcText = latest?[1]; + if (value is Map && hlcText is String) { + try { + final stamp = stampFromHlc(Hlc.fromStr(hlcText)); + _offer(out, value['date'], value['kg'], stamp); + } on FormatException { + // An unreadable clock makes the weigh-in unrankable; skip it. + } + } + final sessions = _pair(fields, _sessionsField)?[0]; + if (sessions is! List) return; + for (final session in sessions) { + if (session is! Map) continue; + final day = session['date']; + _offer(out, day, session['weight_kg'], '${day}T00:00:00+00:00'); + } +} + +/// Weight candidates by day from every device's raw `alarm.json`, newest +/// stamp per day. Unparsable devices are skipped. +Map wakeAlarmCandidates(List<(String, String)> published) { + final out = {}; + for (final (_, raw) in published) { + try { + _fromLog(out, jsonDecode(raw)); + } on FormatException { + continue; + } + } + return out; +} + +/// Reads every wake-alarm device's record and applies its weigh-ins. +/// +/// Never throws: a missing or unreadable wake-alarm namespace must not fail +/// the diet sync tick it runs inside. Returns whether anything changed. +Future ingestWakeAlarmWeights(RemoteStore client) async { + if (!BodyService.isInitialized) return false; + try { + final published = <(String, String)>[]; + for (final device in await client.listDirectory(wakeAlarmDevicesDir)) { + final text = await client.getFileText( + '$wakeAlarmDevicesDir/$device/alarm.json', + ); + if (text != null) published.add((device, text)); + } + return await BodyService.instance.applyCandidates( + wakeAlarmCandidates(published), + ); + } on Object catch (error, stackTrace) { + log( + 'wake-alarm weigh-in ingestion failed; continuing the sync', + level: 900, + error: error, + stackTrace: stackTrace, + ); + return false; + } +} diff --git a/app/lib/services/week_plan.dart b/app/lib/services/week_plan.dart new file mode 100644 index 0000000..fe4d25e --- /dev/null +++ b/app/lib/services/week_plan.dart @@ -0,0 +1,128 @@ +/// Weekly-average planner: what to eat on the rest of the week's days. +/// +/// Port of `diet_guard/_week_plan.py`, gated by the shared +/// `tests/fixtures/body_calc.json`. The week is Monday-Sunday around +/// `today`. A day is known when the user typed a number for it, or when it is +/// already over and has meals in the log; every other day -- today included -- +/// shares `(target x 7 - known) / remaining`. A past day with no log is +/// `unlogged` and planned like a remaining day (an unlogged day is not a +/// zero-kcal day). Informational only: it never touches the budget. +library; + +import 'package:diet_guard_app/services/body_calc.dart'; +import 'package:diet_guard_app/services/body_energy.dart'; + +/// Weekday labels, Monday first. +const List weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +/// One day of the plan. +class DayPlan { + /// Creates a day. + const DayPlan({ + required this.day, + required this.weekday, + required this.kcal, + required this.source, + }); + + /// `YYYY-MM-DD`. + final String day; + + /// `Mon` .. `Sun`. + final String weekday; + + /// What was eaten (known) or should be eaten (planned). + final int kcal; + + /// `typed`, `logged`, `plan` or `unlogged`. + final String source; +} + +/// The whole week. +class WeekPlan { + /// Creates a plan. + const WeekPlan({ + required this.days, + required this.targetAvg, + required this.perRemaining, + required this.weekAvg, + }); + + /// Monday .. Sunday. + final List days; + + /// The weekly average aimed for. + final int targetAvg; + + /// kcal for each open day, or null when none remain. + final int? perRemaining; + + /// The average the plan produces. + final double weekAvg; + + /// Whether the remaining days would have to go under 1200 kcal. + bool get belowFloor { + final per = perRemaining; + return per != null && per < minTargetKcal; + } +} + +String _key(DateTime day) { + String two(int v) => v.toString().padLeft(2, '0'); + return '${day.year.toString().padLeft(4, '0')}-${two(day.month)}' + '-${two(day.day)}'; +} + +/// The Monday of [today]'s week, at local midnight. +DateTime weekStart(DateTime today) => + DateTime(today.year, today.month, today.day - (today.weekday - 1)); + +/// Plans the rest of [today]'s week. +/// +/// [logged] maps `YYYY-MM-DD` to kcal eaten (only days before [today] are +/// used); [typed] maps weekday index (0 = Monday) to a typed kcal, which wins +/// over the log and may name any day. +WeekPlan planWeek( + DateTime today, + int targetAvg, + Map logged, + Map typed, +) { + final monday = weekStart(today); + final todayKey = _key(today); + String dayKey(int index) => + _key(DateTime(monday.year, monday.month, monday.day + index)); + final known = {}; + for (var index = 0; index < 7; index++) { + final key = dayKey(index); + final typedKcal = typed[index]; + final loggedKcal = logged[key]; + if (typedKcal != null) { + known[index] = (typedKcal, 'typed'); + } else if (key.compareTo(todayKey) < 0 && loggedKcal != null) { + known[index] = (halfUp(loggedKcal).toInt(), 'logged'); + } + } + final remaining = 7 - known.length; + final eaten = known.values.fold(0, (sum, day) => sum + day.$1); + final per = remaining == 0 + ? null + : halfUp((targetAvg * 7 - eaten) / remaining).toInt(); + final days = [ + for (var index = 0; index < 7; index++) + DayPlan( + day: dayKey(index), + weekday: weekdays[index], + kcal: known[index]?.$1 ?? per ?? 0, + source: + known[index]?.$2 ?? + (dayKey(index).compareTo(todayKey) < 0 ? 'unlogged' : 'plan'), + ), + ]; + return WeekPlan( + days: days, + targetAvg: targetAvg, + perRemaining: per, + weekAvg: days.fold(0, (sum, day) => sum + day.kcal) / 7, + ); +} diff --git a/app/lib/widgets/body/activity_card.dart b/app/lib/widgets/body/activity_card.dart new file mode 100644 index 0000000..10c32c7 --- /dev/null +++ b/app/lib/widgets/body/activity_card.dart @@ -0,0 +1,127 @@ +/// Recent workouts the PC published, with exercise kcal per formula. +library; + +import 'package:diet_guard_app/services/activity_kcal.dart'; +import 'package:diet_guard_app/services/body_steps.dart'; +import 'package:diet_guard_app/widgets/body/body_section.dart'; +import 'package:flutter/material.dart'; + +/// How many of the newest sessions to list. +const int activityListLimit = 10; + +/// Shows the 14-day daily means and the newest sessions. +class ActivityCard extends StatelessWidget { + /// Creates the card. + const ActivityCard({ + required this.sessions, + required this.weightKg, + required this.exercise, + this.steps = const {}, + this.stepsKcal, + this.onConnectSteps, + this.window = maxWindowDays, + super.key, + }); + + /// How many full past days [exercise] averages (see `windowDays`). + final int window; + + /// Published sessions, or null when the PC never published any. + final List? sessions; + + /// Weight used for the kcal formulas, or null when unknown. + final double? weightKg; + + /// The 14-day daily means per formula (steps included), or null. + final Map? exercise; + + /// Steps outside workouts by day. + final Map steps; + + /// Mean daily net kcal of the counted steps alone, or null. + final double? stepsKcal; + + /// Asks for the Health Connect permission; null hides the button (not + /// supported here, or already granted). + final VoidCallback? onConnectSteps; + + @override + Widget build(BuildContext context) { + final list = sessions ?? const []; + final kg = weightKg; + final means = exercise; + final newest = [...list]..sort((a, b) => b.day.compareTo(a.day)); + final stepKcal = stepsKcal; + final lastDay = steps.isEmpty + ? null + : steps.keys.reduce((a, b) => a.compareTo(b) > 0 ? a : b); + return BodySection( + title: 'Activity', + children: [ + if (sessions == null && steps.isEmpty) + const BodyNote('No activity data yet -- published by the PC.'), + if (means != null) ...[ + BodyNote('Mean daily exercise, last $window full days:'), + for (final name in activityFormulas) + BodyStatRow(name, '${whole(means[name] ?? 0)} kcal/day'), + ], + if (lastDay != null && stepKcal != null) + BodyNote( + 'Steps outside workouts: ${steps[lastDay]} on $lastDay; above ' + '$stepBaseline/day they add ${whole(stepKcal)} kcal/day ' + '(included above).', + ), + if (onConnectSteps != null) + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + icon: const Icon(Icons.directions_walk), + label: const Text('Connect Health Connect steps'), + onPressed: onConnectSteps, + ), + ), + if (sessions != null && kg == null) + const BodyNote('Enter your weight to estimate exercise kcal.'), + if (sessions != null && kg != null) ...[ + const Divider(), + if (newest.isEmpty) + const BodyNote('No workouts in the last 28 days.'), + for (final session in newest.take(activityListLimit)) + _SessionTile(result: sessionKcal(session, kg)), + if (newest.isNotEmpty) + const BodyNote('~ = no distance, MET estimate used instead.'), + const BodyNote(methodsNote), + ], + ], + ); + } +} + +class _SessionTile extends StatelessWidget { + const _SessionTile({required this.result}); + + final SessionKcal result; + + @override + Widget build(BuildContext context) { + final session = result.session; + final km = session.km; + final distance = km == null ? '' : ', ${oneDecimal(km)} km'; + final kcal = [ + for (final name in activityFormulas) + _formulaKcal(name, result.kcal[name]!, result.fallback.contains(name)), + ].join(' · '); + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text('${session.day} ${session.label}'), + subtitle: Text( + '${whole(session.minutes)} min$distance\n$kcal kcal', + ), + isThreeLine: true, + ); + } +} + +String _formulaKcal(String name, double kcal, bool fallback) => + '$name ${whole(kcal)}${fallback ? '~' : ''}'; diff --git a/app/lib/widgets/body/body_section.dart b/app/lib/widgets/body/body_section.dart new file mode 100644 index 0000000..5ca15b1 --- /dev/null +++ b/app/lib/widgets/body/body_section.dart @@ -0,0 +1,104 @@ +/// A titled card, the building block of every Body screen section. +library; + +import 'package:diet_guard_app/services/body_calc.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:flutter/material.dart'; + +/// A card with a title row and [children] stacked below it. +class BodySection extends StatelessWidget { + /// Creates a section. + const BodySection({ + required this.title, + required this.children, + super.key, + }); + + /// Section heading. + final String title; + + /// Section content. + final List children; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.only(bottom: AppSpacing.md), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: AppSpacing.sm), + ...children, + ], + ), + ), + ); + } +} + +/// A label on the left, a value on the right; one line of a stats card. +class BodyStatRow extends StatelessWidget { + /// Creates a row. + const BodyStatRow(this.label, this.value, {this.color, super.key}); + + /// What the value is. + final String label; + + /// The formatted value. + final String value; + + /// Optional value color (e.g. a warning). + final Color? color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs / 2), + child: Row( + children: [ + Expanded(child: Text(label)), + Text( + value, + style: TextStyle( + color: color, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} + +/// A muted one-line explanation (missing data, notes). +class BodyNote extends StatelessWidget { + /// Creates a note. + const BodyNote(this.text, {super.key}); + + /// The note. + final String text; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text( + text, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ); + } +} + +/// `74.99` -> `"75.0 kg"`, null -> `"n/a"`. Half-up, like the PC. +String formatKg(double? kg) => kg == null ? 'n/a' : '${oneDecimal(kg)} kg'; + +/// [value] to one decimal, rounded half-up like the PC prints it. +String oneDecimal(double value) => halfUp(value, 1).toStringAsFixed(1); + +/// [value] as a whole number, rounded half-up like the PC prints it. +String whole(double value) => halfUp(value).toInt().toString(); diff --git a/app/lib/widgets/body/body_stats_cards.dart b/app/lib/widgets/body/body_stats_cards.dart new file mode 100644 index 0000000..5355d8b --- /dev/null +++ b/app/lib/widgets/body/body_stats_cards.dart @@ -0,0 +1,105 @@ +/// BMI, ideal-weight and BMR cards -- read-only views of `body_calc.dart`. +library; + +import 'package:diet_guard_app/services/body_calc.dart'; +import 'package:diet_guard_app/widgets/body/body_section.dart'; +import 'package:flutter/material.dart'; + +/// BMI, BMI Prime, Trefethen's BMI and the WHO category. +class BmiCard extends StatelessWidget { + /// Creates the card for [bio]. + const BmiCard({required this.bio, super.key}); + + /// The person. + final Biometrics bio; + + @override + Widget build(BuildContext context) { + final report = bmiReport(bio); + final (low, high) = healthyRange(bio); + final normal = report.category == 'normal'; + return BodySection( + title: 'BMI', + children: [ + BodyStatRow( + 'BMI', + oneDecimal(report.bmi), + color: normal ? null : Theme.of(context).colorScheme.error, + ), + BodyStatRow('Category', report.category), + BodyStatRow('BMI Prime', halfUp(report.prime, 2).toStringAsFixed(2)), + BodyStatRow('Trefethen (new BMI)', oneDecimal(report.trefethen)), + BodyStatRow( + 'Healthy weight for your height (BMI 18.5-24.9)', + '${oneDecimal(low)}-${oneDecimal(high)} kg', + ), + const BodyNote(bmiPrimeNote), + const BodyNote(trefethenNote), + ], + ); + } +} + +/// Every ideal-weight formula; `n/a` where one is undefined. +class IdealWeightCard extends StatelessWidget { + /// Creates the card for [bio]. + const IdealWeightCard({required this.bio, super.key}); + + /// The person. + final Biometrics bio; + + @override + Widget build(BuildContext context) { + final formulas = idealWeights(bio); + final undefined = formulas.any((f) => f.value == null); + final summary = idealSummary(formulas); + return BodySection( + title: 'Ideal weight', + children: [ + for (final formula in formulas) + BodyStatRow(formula.name, formatKg(formula.value)), + if (summary != null) ...[ + const Divider(), + BodyStatRow('Average of all', formatKg(summary.$1)), + BodyStatRow( + 'Ideal range', + '${oneDecimal(summary.$2)}-${oneDecimal(summary.$3)} kg', + ), + ], + const BodyNote(brocaNote), + if (undefined) + const BodyNote( + 'Devine, Robinson, Miller and Hamwi are undefined below 152.4 cm.', + ), + ], + ); + } +} + +/// Resting metabolic rate by every formula; lean-mass ones need body fat. +class BmrCard extends StatelessWidget { + /// Creates the card for [bio]. + const BmrCard({required this.bio, this.bodyFatPct, super.key}); + + /// The person. + final Biometrics bio; + + /// The newest body fat %, or null. + final double? bodyFatPct; + + @override + Widget build(BuildContext context) { + return BodySection( + title: 'Resting metabolism (BMR)', + children: [ + for (final formula in bmrFormulas(bio, bodyFatPct)) + BodyStatRow( + formula.name, + formula.value == null + ? 'n/a -- log a body-fat %' + : '${whole(formula.value!)} kcal', + ), + ], + ); + } +} diff --git a/app/lib/widgets/body/goal_card.dart b/app/lib/widgets/body/goal_card.dart new file mode 100644 index 0000000..5a24b05 --- /dev/null +++ b/app/lib/widgets/body/goal_card.dart @@ -0,0 +1,127 @@ +/// The Body screen's top line: "To [lose] [0.5] [kg] / [week] on +/// [Moderate] eat 2019 kcal". Every bracket is tappable and opens a picker; +/// the choice is saved (and synced) immediately. Informational only. +library; + +import 'dart:async'; + +import 'package:diet_guard_app/services/body_calc.dart'; +import 'package:diet_guard_app/services/body_energy.dart'; +import 'package:diet_guard_app/services/body_prefs.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:diet_guard_app/widgets/body/body_section.dart'; +import 'package:diet_guard_app/widgets/body/goal_pickers.dart'; +import 'package:flutter/material.dart'; + +/// The goal sentence and its answer. +class GoalCard extends StatefulWidget { + /// Creates the card. + const GoalCard({ + required this.bio, + this.exercise, + this.bodyFatPct, + super.key, + }); + + /// The person. + final Biometrics bio; + + /// Mean daily net exercise per formula (steps included), or null. + final Map? exercise; + + /// The newest body fat %, which switches the base BMR, or null. + final double? bodyFatPct; + + @override + State createState() => _GoalCardState(); +} + +class _GoalCardState extends State { + late Goal _goal = savedGoal(BodyService.instance); + + Future _pick(Future Function(BuildContext, Goal) picker) async { + final picked = await picker(context, _goal); + if (picked == null || !mounted) return; + setState(() => _goal = picked); + await setGoal(BodyService.instance, picked); + } + + Widget _choice(String text, Future Function(BuildContext, Goal) p) { + final scheme = Theme.of(context).colorScheme; + return InkWell( + onTap: () => unawaited(_pick(p)), + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: Text( + text, + style: TextStyle( + color: scheme.primary, + fontWeight: FontWeight.w600, + decoration: TextDecoration.underline, + decorationColor: scheme.primary, + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final bmr = tableBmr(widget.bio, widget.bodyFatPct); + final target = goalTarget(_goal, bmr.value!, bmr.name, widget.exercise); + final kcal = target.kcal; + final tdee = target.tdee; + final maintain = _goal.direction == 'maintain'; + return BodySection( + title: 'Daily calorie target', + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: AppSpacing.xs, + children: [ + const Text('To'), + _choice(_goal.direction, pickDirection), + if (!maintain) ...[ + _choice(formatG(_goal.amount), pickAmount), + _choice(_goal.weightUnit, pickWeightUnit), + const Text('/'), + _choice(_goal.timeUnit, pickTimeUnit), + ], + const Text('on'), + _choice(activityLabel(_goal.activity), pickActivity), + if (kcal != null) ...[ + const Text('eat '), + Text( + '$kcal', + style: theme.textTheme.headlineMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + const Text(' kcal'), + ], + ], + ), + const SizedBox(height: AppSpacing.sm), + if (kcal == null) + const BodyNote('no workouts published yet (the PC publishes them).'), + if (target.belowFloor) + Text( + 'That is below the $minTargetKcal kcal floor.', + style: TextStyle(color: theme.colorScheme.error), + ), + if (tdee != null) + BodyNote( + 'Maintenance ${whole(tdee)} kcal (${target.bmrName} resting ' + 'burn x activity); 1 kg of fat = ${formatG(kcalPerKg)} kcal.', + ), + const BodyNote( + 'Informational only — the budget is never changed from here.', + ), + ], + ); + } +} diff --git a/app/lib/widgets/body/goal_pickers.dart b/app/lib/widgets/body/goal_pickers.dart new file mode 100644 index 0000000..f77e7fc --- /dev/null +++ b/app/lib/widgets/body/goal_pickers.dart @@ -0,0 +1,149 @@ +/// The pickers behind each tappable word of the goal sentence. +/// +/// Each returns the edited [Goal], or null when dismissed. +library; + +import 'package:diet_guard_app/services/body_energy.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +Future _sheet( + BuildContext context, + String title, + List<(String, String, String?)> options, + String selected, +) => showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + ListTile(title: Text(title)), + for (final (value, label, subtitle) in options) + ListTile( + title: Text(label), + subtitle: subtitle == null ? null : Text(subtitle), + selected: value == selected, + trailing: value == selected ? const Icon(Icons.check) : null, + onTap: () => Navigator.of(context).pop(value), + ), + ], + ), + ), +); + +/// Lose / maintain / gain. +Future pickDirection(BuildContext context, Goal goal) async { + final value = await _sheet(context, 'Direction', [ + for (final d in directions) (d, d, null), + ], goal.direction); + return value == null ? null : goal.copyWith(direction: value); +} + +/// Kilograms, pounds, grams or stones. +Future pickWeightUnit(BuildContext context, Goal goal) async { + final value = await _sheet(context, 'Weight unit', [ + for (final u in weightUnits.keys) (u, u, null), + ], goal.weightUnit); + return value == null ? null : goal.copyWith(weightUnit: value); +} + +/// Per day, week, month or year. +Future pickTimeUnit(BuildContext context, Goal goal) async { + final value = await _sheet(context, 'Per', [ + for (final u in timeUnits.keys) (u, u, null), + ], goal.timeUnit); + return value == null ? null : goal.copyWith(timeUnit: value); +} + +/// The five levels, then the three measured formulas. +Future pickActivity(BuildContext context, Goal goal) async { + final meaning = {for (final l in activityLevels) l.key: l.meaning}; + final value = await _sheet(context, 'Activity', [ + for (final key in activityChoices) + ( + key, + activityLabel(key), + meaning[key] ?? 'BMR x 1.2 + your real workouts and steps', + ), + ], goal.activity); + return value == null ? null : goal.copyWith(activity: value); +} + +/// A preset amount, or any typed non-negative number. +Future pickAmount(BuildContext context, Goal goal) async { + final value = await showDialog( + context: context, + builder: (context) => _AmountDialog(goal: goal), + ); + return value == null ? null : goal.copyWith(amount: value); +} + +/// Owns its text controller, so the controller outlives the dialog's exit +/// animation instead of being disposed while the field still paints. +class _AmountDialog extends StatefulWidget { + const _AmountDialog({required this.goal}); + + final Goal goal; + + @override + State<_AmountDialog> createState() => _AmountDialogState(); +} + +class _AmountDialogState extends State<_AmountDialog> { + late final _controller = TextEditingController( + text: formatG(widget.goal.amount), + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final typed = double.tryParse(_controller.text.replaceAll(',', '.')); + if (typed != null && typed >= 0) Navigator.of(context).pop(typed); + } + + @override + Widget build(BuildContext context) { + final goal = widget.goal; + return AlertDialog( + title: Text('How much (${goal.weightUnit} / ${goal.timeUnit})'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + spacing: AppSpacing.xs, + children: [ + for (final preset in presetAmounts) + ChoiceChip( + label: Text(formatG(preset)), + selected: preset == goal.amount, + onSelected: (_) => Navigator.of(context).pop(preset), + ), + ], + ), + TextField( + controller: _controller, + decoration: const InputDecoration(labelText: 'Other amount'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9.,]')), + ], + onSubmitted: (_) => _submit(), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton(onPressed: _submit, child: const Text('OK')), + ], + ); + } +} diff --git a/app/lib/widgets/body/profile_card.dart b/app/lib/widgets/body/profile_card.dart new file mode 100644 index 0000000..92a7b66 --- /dev/null +++ b/app/lib/widgets/body/profile_card.dart @@ -0,0 +1,119 @@ +/// Birth date, height and sex -- the synced profile the formulas need. +library; + +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:diet_guard_app/widgets/body/body_section.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Edits the profile; calls [onSaved] after a successful save. +class ProfileCard extends StatefulWidget { + /// Creates the card. + const ProfileCard({required this.onSaved, super.key}); + + /// Called once the profile has been written. + final VoidCallback onSaved; + + @override + State createState() => _ProfileCardState(); +} + +class _ProfileCardState extends State { + final _height = TextEditingController(); + String? _birth; + String? _sex; + String? _status; + + @override + void initState() { + super.initState(); + final body = BodyService.instance; + _birth = body.birth; + _sex = body.sex; + final height = body.heightCm; + if (height != null) _height.text = oneDecimal(height); + } + + @override + void dispose() { + _height.dispose(); + super.dispose(); + } + + Future _pickBirth() async { + final now = DateTime.now(); + final initial = DateTime.tryParse(_birth ?? '') ?? DateTime(now.year - 30); + final picked = await showDatePicker( + context: context, + firstDate: DateTime(1900), + lastDate: now, + initialDate: initial, + initialDatePickerMode: DatePickerMode.year, + ); + if (picked == null || !mounted) return; + setState(() => _birth = localDateKey(picked)); + } + + Future _save() async { + final height = double.tryParse(_height.text.replaceAll(',', '.')); + if (height == null || height < 50 || height > 260) { + setState(() => _status = 'Height must be 50-260 cm.'); + return; + } + await BodyService.instance.setProfile( + birth: _birth, + heightCm: height, + sex: _sex, + ); + if (!mounted) return; + setState(() => _status = 'Saved.'); + widget.onSaved(); + } + + @override + Widget build(BuildContext context) { + return BodySection( + title: 'Profile', + children: [ + Row( + children: [ + Expanded(child: Text('Born: ${_birth ?? 'not set'}')), + TextButton.icon( + icon: const Icon(Icons.cake), + label: const Text('Birth date'), + onPressed: _pickBirth, + ), + ], + ), + TextField( + controller: _height, + decoration: const InputDecoration(labelText: 'Height (cm)'), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9.,]')), + ], + ), + const SizedBox(height: AppSpacing.sm), + SegmentedButton( + segments: const [ + ButtonSegment(value: 'm', label: Text('Male')), + ButtonSegment(value: 'f', label: Text('Female')), + ], + selected: {?_sex}, + emptySelectionAllowed: true, + onSelectionChanged: (value) => + setState(() => _sex = value.isEmpty ? null : value.first), + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded(child: BodyNote(_status ?? 'Syncs to the PC.')), + FilledButton(onPressed: _save, child: const Text('Save profile')), + ], + ), + ], + ); + } +} diff --git a/app/lib/widgets/body/week_plan_card.dart b/app/lib/widgets/body/week_plan_card.dart new file mode 100644 index 0000000..1b5689a --- /dev/null +++ b/app/lib/widgets/body/week_plan_card.dart @@ -0,0 +1,132 @@ +/// Weekly-average planner: "I ate N on day X -- what now for the rest?" +/// +/// Info only. Past days fill in from the food log; typing a number for any +/// day overrides it (a planned feast on Saturday too). +library; + +import 'package:diet_guard_app/services/week_plan.dart'; +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:diet_guard_app/widgets/body/body_section.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// The planner. +class WeekPlanCard extends StatefulWidget { + /// Creates the card. + const WeekPlanCard({ + required this.defaultTarget, + required this.logged, + this.today, + super.key, + }); + + /// The average to aim for until the user types one (the daily budget). + final int defaultTarget; + + /// `YYYY-MM-DD` -> kcal eaten, from the food log. + final Map logged; + + /// Overrides "today" for tests. + final DateTime? today; + + @override + State createState() => _WeekPlanCardState(); +} + +class _WeekPlanCardState extends State { + final _target = TextEditingController(); + final List _days = List.generate( + 7, + (_) => TextEditingController(), + ); + + @override + void dispose() { + _target.dispose(); + for (final controller in _days) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final target = int.tryParse(_target.text) ?? widget.defaultTarget; + final typed = { + for (var i = 0; i < 7; i++) + i: ?int.tryParse(_days[i].text), + }; + final plan = planWeek( + widget.today ?? DateTime.now(), + target, + widget.logged, + typed, + ); + final scheme = Theme.of(context).colorScheme; + return BodySection( + title: 'Week planner (info only)', + children: [ + TextField( + controller: _target, + decoration: InputDecoration( + labelText: 'Weekly average target (kcal/day)', + hintText: '${widget.defaultTarget}', + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: AppSpacing.sm), + for (var i = 0; i < 7; i++) + Row( + children: [ + SizedBox(width: 48, child: Text(plan.days[i].weekday)), + Expanded( + child: Text( + '${plan.days[i].kcal} kcal ${_sourceLabel(plan.days[i])}', + style: TextStyle( + color: plan.days[i].source == 'plan' && plan.belowFloor + ? scheme.error + : null, + ), + ), + ), + SizedBox( + width: 96, + child: TextField( + controller: _days[i], + decoration: const InputDecoration( + hintText: 'ate…', + isDense: true, + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + onChanged: (_) => setState(() {}), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + BodyNote(_summary(plan)), + ], + ); + } +} + +String _sourceLabel(DayPlan day) => switch (day.source) { + 'typed' => '(typed)', + 'logged' => '(logged)', + 'unlogged' => '(not logged -- type it)', + _ => '(plan)', +}; + +String _summary(WeekPlan plan) { + final per = plan.perRemaining; + if (per == null) { + return 'Every day is known: week average ' + '${whole(plan.weekAvg)} kcal.'; + } + final floor = plan.belowFloor ? ' -- below the 1200 kcal floor!' : ''; + return 'Eat $per kcal on each open day to average ' + '${plan.targetAvg}$floor'; +} diff --git a/app/lib/widgets/body/weight_card.dart b/app/lib/widgets/body/weight_card.dart new file mode 100644 index 0000000..10f6171 --- /dev/null +++ b/app/lib/widgets/body/weight_card.dart @@ -0,0 +1,214 @@ +/// Dated-value entry (any day, save or delete) with its history chart. +/// +/// One widget serves both logs -- weight and body fat -- so the two cards +/// cannot drift apart; [WeightCard] and [BodyFatCard] only configure it. +library; + +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/widgets/body/body_section.dart'; +import 'package:diet_guard_app/widgets/body/weight_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Weight: 20-400 kg; wake-alarm weigh-ins land here too. +class WeightCard extends StatelessWidget { + /// Creates the card. + const WeightCard({required this.onChanged, super.key}); + + /// Called once a value has been written or deleted. + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + final body = BodyService.instance; + return DatedValueCard( + title: 'Weight', + fieldLabel: 'Weight (kg)', + unit: 'kg', + min: 20, + max: 400, + hint: 'Morning weigh-ins from wake-alarm appear here.', + latest: body.latestWeight, + series: body.weightSeries, + onSave: body.setWeight, + onDelete: body.deleteWeight, + onChanged: onChanged, + ); + } +} + +/// Body fat: 3-70 %; the newest reading switches the table to Katch-McArdle. +class BodyFatCard extends StatelessWidget { + /// Creates the card. + const BodyFatCard({required this.onChanged, super.key}); + + /// Called once a value has been written or deleted. + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + final body = BodyService.instance; + return DatedValueCard( + title: 'Body fat', + fieldLabel: 'Body fat (%)', + unit: '%', + min: minBodyFat, + max: maxBodyFat, + hint: 'With a body fat %, targets use the lean-mass (Katch-McArdle) BMR.', + latest: body.latestBodyFat, + series: body.bodyFatSeries, + onSave: body.setBodyFat, + onDelete: body.deleteBodyFat, + onChanged: onChanged, + ); + } +} + +/// Enters/deletes a day's value and charts the history. +class DatedValueCard extends StatefulWidget { + /// Creates the card. + const DatedValueCard({ + required this.title, + required this.fieldLabel, + required this.unit, + required this.min, + required this.max, + required this.hint, + required this.latest, + required this.series, + required this.onSave, + required this.onDelete, + required this.onChanged, + super.key, + }); + + /// Section heading. + final String title; + + /// Input label. + final String fieldLabel; + + /// Unit suffix (`kg`, `%`). + final String unit; + + /// Smallest accepted value. + final double min; + + /// Largest accepted value. + final double max; + + /// Note shown until something is saved. + final String hint; + + /// Newest `(day, value)`, or null. + final (String, double)? latest; + + /// Every live `(day, value)`, oldest first. + final List<(String, double)> series; + + /// Writes a value for a day. + final Future Function(String day, double value) onSave; + + /// Deletes a day. + final Future Function(String day) onDelete; + + /// Called once a value has been written or deleted. + final VoidCallback onChanged; + + @override + State createState() => _DatedValueCardState(); +} + +class _DatedValueCardState extends State { + final _value = TextEditingController(); + String _day = localDateKey(DateTime.now()); + String? _status; + + @override + void dispose() { + _value.dispose(); + super.dispose(); + } + + String _fmt(double value) => '${oneDecimal(value)} ${widget.unit}'; + + Future _pickDay() async { + final picked = await showDatePicker( + context: context, + firstDate: DateTime(2000), + lastDate: DateTime.now(), + initialDate: DateTime.parse(_day), + ); + if (picked == null || !mounted) return; + setState(() => _day = localDateKey(picked)); + } + + Future _save() async { + final value = double.tryParse(_value.text.replaceAll(',', '.')); + if (value == null || value < widget.min || value > widget.max) { + setState( + () => _status = + '${widget.title} must be ${whole(widget.min)}-' + '${whole(widget.max)} ${widget.unit}.', + ); + return; + } + await widget.onSave(_day, value); + if (!mounted) return; + _value.clear(); + setState(() => _status = 'Saved ${_fmt(value)} for $_day.'); + widget.onChanged(); + } + + Future _delete() async { + await widget.onDelete(_day); + if (!mounted) return; + setState(() => _status = 'Deleted $_day.'); + widget.onChanged(); + } + + @override + Widget build(BuildContext context) { + final latest = widget.latest; + return BodySection( + title: widget.title, + children: [ + BodyStatRow( + 'Latest', + latest == null ? 'none yet' : '${_fmt(latest.$2)} (${latest.$1})', + ), + Row( + children: [ + Expanded( + child: TextField( + controller: _value, + decoration: InputDecoration(labelText: widget.fieldLabel), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp('[0-9.,]')), + ], + ), + ), + TextButton(onPressed: _pickDay, child: Text(_day)), + ], + ), + Row( + children: [ + Expanded(child: BodyNote(_status ?? widget.hint)), + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Delete this day', + onPressed: _delete, + ), + FilledButton(onPressed: _save, child: const Text('Save')), + ], + ), + WeightChart(series: widget.series), + ], + ); + } +} diff --git a/app/lib/widgets/body/weight_chart.dart b/app/lib/widgets/body/weight_chart.dart new file mode 100644 index 0000000..4d84e98 --- /dev/null +++ b/app/lib/widgets/body/weight_chart.dart @@ -0,0 +1,137 @@ +/// The weight-history line chart with 30d / 90d / 1y / all range chips. +/// +/// X is days since the first plotted point, so gaps between weigh-ins are +/// drawn to scale rather than evenly spaced. +library; + +import 'package:diet_guard_app/ui/theme.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +/// Selectable chart ranges, `(label, days)`; null days = everything. +const List<(String, int?)> weightChartRanges = [ + ('30d', 30), + ('90d', 90), + ('1y', 365), + ('All', null), +]; + +/// Filters [series] (oldest first) to the last [days] before [today]. +List<(String, double)> seriesInRange( + List<(String, double)> series, + int? days, + DateTime today, +) { + if (days == null) return series; + final cutoff = DateTime(today.year, today.month, today.day - days); + return [ + for (final point in series) + if (!DateTime.parse(point.$1).isBefore(cutoff)) point, + ]; +} + +/// A line chart of weights with its range selector. +class WeightChart extends StatefulWidget { + /// Creates the chart for [series] (`(YYYY-MM-DD, kg)`, oldest first). + const WeightChart({required this.series, this.today, super.key}); + + /// Weights to plot. + final List<(String, double)> series; + + /// Overrides "today" for tests. + final DateTime? today; + + @override + State createState() => _WeightChartState(); +} + +class _WeightChartState extends State { + int? _days = 90; + + @override + Widget build(BuildContext context) { + final points = seriesInRange( + widget.series, + _days, + widget.today ?? DateTime.now(), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: AppSpacing.sm, + children: [ + for (final (label, days) in weightChartRanges) + ChoiceChip( + label: Text(label), + selected: _days == days, + onSelected: (_) => setState(() => _days = days), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: 200, + child: points.length < 2 + ? const Center(child: Text('Need two readings for a graph.')) + : _chart(context, points), + ), + ], + ); + } + + Widget _chart(BuildContext context, List<(String, double)> points) { + final scheme = Theme.of(context).colorScheme; + final origin = DateTime.parse(points.first.$1); + double x(String day) => + DateTime.parse(day).difference(origin).inHours / 24.0; + final spots = [for (final (day, kg) in points) FlSpot(x(day), kg)]; + final kgs = [for (final (_, kg) in points) kg]; + final low = kgs.reduce((a, b) => a < b ? a : b); + final high = kgs.reduce((a, b) => a > b ? a : b); + final span = x(points.last.$1).clamp(1, double.infinity).toDouble(); + return LineChart( + LineChartData( + minY: (low - 1).floorToDouble(), + maxY: (high + 1).ceilToDouble(), + gridData: const FlGridData(drawVerticalLine: false), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: true, reservedSize: 40), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: span / 3, + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + _dayLabel(origin, value), + style: const TextStyle(fontSize: AppTextSize.caption), + ), + ), + ), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: spots, + color: scheme.primary, + barWidth: 3, + dotData: FlDotData(show: spots.length <= 60), + ), + ], + ), + ); + } +} + +String _dayLabel(DateTime origin, double offset) { + final day = DateTime(origin.year, origin.month, origin.day + offset.round()); + String two(int v) => v.toString().padLeft(2, '0'); + return '${two(day.day)}.${two(day.month)}'; +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 2a2a255..67c8ebf 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + carp_serializable: + dependency: transitive + description: + name: carp_serializable + sha256: "4a78993c03577902f9996e29982c406d5d3c838fa174f18cc0eb53f49b352abd" + url: "https://pub.dev" + source: hosted + version: "3.0.0" characters: dependency: transitive description: @@ -106,6 +114,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.13" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + url: "https://pub.dev" + source: hosted + version: "8.1.0" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" fake_async: dependency: transitive description: @@ -146,6 +178,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 + url: "https://pub.dev" + source: hosted + version: "1.2.0" flutter: dependency: "direct main" description: flutter @@ -314,6 +354,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.3" + health: + dependency: "direct main" + description: + name: health + sha256: "8dd9625c9c26379a5753ad24c33af1d3dc5ce1f36f19c03e17c676c2f8fdbb80" + url: "https://pub.dev" + source: hosted + version: "13.3.2" hooks: dependency: transitive description: @@ -354,6 +402,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.9.1" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" jni: dependency: transitive description: @@ -864,6 +920,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + url: "https://pub.dev" + source: hosted + version: "3.0.3" workmanager: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 036cc7a..2eb1c9c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: diet_guard_app description: "Companion phone app for diet_guard: log meals on the go." publish_to: 'none' -version: 1.0.95+1 +version: 1.0.96+1 environment: sdk: ^3.12.2 @@ -14,6 +14,7 @@ dependencies: ref: crdt_sync_dart-v0.11.1 path: crdt_sync_dart crypto: 3.0.7 + fl_chart: 1.2.0 flutter: sdk: flutter flutter_local_notifications: 22.3.1 @@ -27,6 +28,7 @@ dependencies: ref: github_device_auth-v0.3.1 path: github_device_auth google_sign_in: 7.2.0 + health: 13.3.2 http: 1.6.0 idb_shim: 2.9.9 path: 1.9.1 diff --git a/app/test/screens/body_screen_goal_test.dart b/app/test/screens/body_screen_goal_test.dart new file mode 100644 index 0000000..e042ce0 --- /dev/null +++ b/app/test/screens/body_screen_goal_test.dart @@ -0,0 +1,99 @@ +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/services/body_energy.dart'; +import 'package:diet_guard_app/services/body_prefs.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'body_screen_support.dart'; + +void main() { + late MemoryDocStore store; + + setUp(() async { + store = MemoryDocStore(); + await setUpBodyServices(store); + }); + tearDown(tearDownBodyServices); + + testWidgets('each word of the goal is a picker, saved at once', ( + tester, + ) async { + await seedPerson(store); + await pumpBody(tester); + Future choose(String word, String option) async { + await tester.tap(find.text(word).first); + await tester.pumpAndSettle(); + await tester.tap(find.text(option).last); + await tester.pumpAndSettle(); + } + + await choose('lose', 'gain'); + await choose('kg', 'lb'); + await choose('week', 'month'); + await choose('Moderate', 'Measured (ACSM)'); + await tester.tap(find.text('0.5')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ChoiceChip, '1')); + await tester.pumpAndSettle(); + expect(goalToJson(savedGoal(BodyService.instance)), { + 'dir': 'gain', + 'amt': 1.0, + 'wu': 'lb', + 'tu': 'month', + 'act': 'measured:ACSM', + }); + expect( + BodyService.instance.document.profile[profileBirth]!.t, + '2026-09-01T10:00:00+02:00', + ); + + // A typed amount; junk is refused and the dialog stays open. + final other = find.widgetWithText(TextField, 'Other amount'); + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + await tester.enterText(other, ''); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('OK'), findsOneWidget); + await tester.enterText(other, '2,5'); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(savedGoal(BodyService.instance).amount, 2.5); + + // Cancel changes nothing; neither does dismissing a sheet. + await tester.tap(find.text('2.5')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + await tester.tap(find.text('gain')); + await tester.pumpAndSettle(); + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + expect(savedGoal(BodyService.instance).amount, 2.5); + + await choose('gain', 'maintain'); + expect(find.text('lb'), findsNothing); + }); + + testWidgets('measured without data, and a goal below the floor', ( + tester, + ) async { + final body = BodyService.instance; + await body.setProfile(birth: '1990-01-01', heightCm: 180, sex: 'm'); + await body.setWeight('2026-09-20', 80); + await setGoal(body, const Goal('lose', 1, 'kg', 'week', 'measured:MET')); + await pumpBody(tester); + expect(find.textContaining('no workouts published yet'), findsOneWidget); + await tester.tap(find.text('Measured (MET)')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sedentary').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('1')); + await tester.pumpAndSettle(); + await tester.enterText(find.widgetWithText(TextField, 'Other amount'), '3'); + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.textContaining('below the 1200 kcal floor'), findsOneWidget); + }); +} diff --git a/app/test/screens/body_screen_steps_test.dart b/app/test/screens/body_screen_steps_test.dart new file mode 100644 index 0000000..3ce1d95 --- /dev/null +++ b/app/test/screens/body_screen_steps_test.dart @@ -0,0 +1,65 @@ +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/services/health_steps.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'body_screen_support.dart'; +import '../services/health_steps_test.dart' show FakeStepsSource; + +void main() { + final original = stepsSource; + late MemoryDocStore store; + + setUp(() async { + store = MemoryDocStore(); + await setUpBodyServices(store); + }); + + tearDown(() { + stepsSource = original; + tearDownBodyServices(); + }); + + testWidgets('connect asks once; granted reads steps and hides it', ( + tester, + ) async { + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + final fake = FakeStepsSource( + granted: false, + intervals: [ + StepInterval(yesterday, yesterday.add(const Duration(hours: 1)), 6500), + ], + )..grantOnRequest = false; + stepsSource = fake; + await pumpBody(tester); + expect(fake.lastRead, isNull, reason: 'never reads before permission'); + + await tester.tap(find.text('Connect Health Connect steps')); + await tester.pumpAndSettle(); + expect(find.text('Connect Health Connect steps'), findsOneWidget); + + fake.grantOnRequest = true; + await tester.tap(find.text('Connect Health Connect steps')); + await tester.pumpAndSettle(); + expect(find.text('Connect Health Connect steps'), findsNothing); + expect( + BodyService.instance.document.steps[localDateKey(yesterday)]!.n, + 6500, + ); + }); + + testWidgets('already granted: refreshes on open, no button', (tester) async { + final fake = FakeStepsSource(); + stepsSource = fake; + await pumpBody(tester); + expect(fake.lastRead, isNotNull); + expect(find.text('Connect Health Connect steps'), findsNothing); + }); + + testWidgets('a Health Connect error just hides nothing', (tester) async { + stepsSource = FakeStepsSource()..failWith = StateError('gone'); + await pumpBody(tester); + expect(find.text('Connect Health Connect steps'), findsOneWidget); + }); +} diff --git a/app/test/screens/body_screen_support.dart b/app/test/screens/body_screen_support.dart new file mode 100644 index 0000000..2bf2e1a --- /dev/null +++ b/app/test/screens/body_screen_support.dart @@ -0,0 +1,89 @@ +// Shared setup for the Body screen test files (split for the 250-line cap). + +import 'dart:convert'; + +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/screens/body_screen.dart'; +import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/services/log_storage_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../services/body_test_support.dart'; + +export '../services/body_test_support.dart'; + +/// Points every service the screen reads at [store]; returns a teardown. +Future setUpBodyServices(MemoryDocStore store) async { + LogStorageService.resetForTesting(store: store); + AppSettingsService.resetForTesting(store: store); + await BodyService.initForTesting(store); +} + +/// Undoes [setUpBodyServices]. +void tearDownBodyServices() { + BodyService.resetForTesting(); + LogStorageService.resetForTesting(); + AppSettingsService.resetForTesting(); +} + +/// Pumps the Body screen on a view tall enough to lay out every card. +Future pumpBody(WidgetTester tester) async { + tester.view.physicalSize = const Size(1200, 12000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(const MaterialApp(home: BodyScreen())); + await tester.pumpAndSettle(); +} + +/// Yesterday's local date key. +String yesterdayKey() => + localDateKey(DateTime.now().subtract(const Duration(days: 1))); + +/// An 80 kg, 180 cm, 30-year-old man with a run, a lift and 9000 steps +/// yesterday; optionally a saved [goal]. +Future seedPerson(MemoryDocStore store, {Object? goal}) async { + final today = DateTime.now(); + const t = '2026-09-01T10:00:00+02:00'; + store.documents[BodyService.documentName] = jsonEncode({ + 'profile': { + 'birth': {'v': localDateKey(DateTime(today.year - 30, 1, 1)), 't': t}, + 'height_cm': {'v': 180, 't': t}, + 'sex': {'v': 'm', 't': t}, + if (goal != null) 'goal': {'v': goal, 't': t}, + }, + 'weights': { + '2026-09-20': {'kg': 81, 'src': 'phone', 't': '2026-09-20T07:00:00Z'}, + yesterdayKey(): {'kg': 80, 'src': 'manual', 't': '2026-09-20T07:00:00Z'}, + }, + 'steps': { + yesterdayKey(): {'n': 9000, 't': t}, + }, + 'activity': { + 't': t, + 'sessions': [ + { + 'day': yesterdayKey(), + 'kind': 'run', + 'min': 60, + 'km': 10, + 'label': 'Evening run', + 'src': 'runnerup', + }, + { + 'day': yesterdayKey(), + 'kind': 'strength', + 'min': 80, + 'label': 'StrongLifts A', + 'src': 'screen-locker', + }, + ], + }, + }); + await BodyService.initForTesting(store); +} + +/// The top edge of [finder]'s first match. +double topOf(WidgetTester tester, Finder finder) => + tester.getTopLeft(finder.first).dy; diff --git a/app/test/screens/body_screen_test.dart b/app/test/screens/body_screen_test.dart new file mode 100644 index 0000000..02d9852 --- /dev/null +++ b/app/test/screens/body_screen_test.dart @@ -0,0 +1,154 @@ +import 'package:diet_guard_app/models/local_time.dart'; +import 'package:diet_guard_app/screens/body_screen.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/widgets/body/profile_card.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'body_screen_support.dart'; + +void main() { + late MemoryDocStore store; + + setUp(() async { + store = MemoryDocStore(); + await setUpBodyServices(store); + }); + tearDown(tearDownBodyServices); + + testWidgets('with nothing set the profile is on top and asks for data', ( + tester, + ) async { + await pumpBody(tester); + expect(find.textContaining('Set your profile and enter'), findsOneWidget); + expect(find.textContaining('No activity data yet'), findsOneWidget); + expect(find.text('Need two readings for a graph.'), findsNWidgets(2)); + expect(find.textContaining('Age unknown'), findsOneWidget); + expect( + topOf(tester, find.byType(ProfileCard)), + lessThan(topOf(tester, find.text('Daily calorie target'))), + ); + }); + + testWidgets('a weight without a profile asks for the profile', ( + tester, + ) async { + await BodyService.instance.setWeight('2026-09-20', 80); + await pumpBody(tester); + expect(find.textContaining('Set your profile (birth'), findsOneWidget); + }); + + testWidgets('a profile without a weight asks for the weight', (tester) async { + await BodyService.instance.setProfile( + birth: '1990-01-01', + heightCm: 180, + sex: 'f', + ); + await pumpBody(tester); + expect(find.text('Enter your weight to see the numbers.'), findsOneWidget); + }); + + testWidgets('a full profile: goal first, profile then body fat last', ( + tester, + ) async { + await seedPerson(store); + await pumpBody(tester); + final goalTop = topOf(tester, find.text('Daily calorie target')); + expect(goalTop, lessThan(topOf(tester, find.text('Weight')))); + final profileTop = topOf(tester, find.byType(ProfileCard)); + expect( + profileTop, + greaterThan(topOf(tester, find.text('Week planner (info only)'))), + ); + expect(topOf(tester, find.text('Body fat')), greaterThan(profileTop)); + // 80 kg, 180 cm, 30 y, male, lose 0.5 kg/week on Moderate: + // 1780 x 1.55 - 550 = 2209. + expect(find.text('2209'), findsOneWidget); + expect( + find.textContaining('Maintenance 2759 kcal (Mifflin-St Jeor'), + findsOneWidget, + ); + expect(find.text('24.7'), findsOneWidget); + expect(find.text('59.9-80.7 kg'), findsOneWidget); + expect(find.text('Average of all'), findsOneWidget); + expect(find.textContaining('Broca = height'), findsOneWidget); + expect(find.text('1780 kcal'), findsOneWidget); + expect(find.text('n/a -- log a body-fat %'), findsNWidgets(2)); + // 5000 steps over baseline x 0.747 m x 0.5 x 80 kg = 149.4 kcal, and + // all the data is from yesterday, so the window is that one day. + expect( + find.textContaining('Steps outside workouts: 9000 on ${yesterdayKey()}'), + findsOneWidget, + ); + expect(find.textContaining('add 149 kcal/day'), findsOneWidget); + expect(find.text('Mean daily exercise, last 1 full days:'), findsOneWidget); + expect(find.textContaining('Evening run'), findsOneWidget); + expect(find.textContaining('StrongLifts A'), findsOneWidget); + expect(find.textContaining('Age 30'), findsOneWidget); + expect(find.text('Connect Health Connect steps'), findsNothing); + }); + + testWidgets('a body fat switches the target to Katch-McArdle', ( + tester, + ) async { + await seedPerson(store); + await pumpBody(tester); + final field = find.widgetWithText(TextField, 'Body fat (%)'); + await tester.enterText(field, '99'); + await tester.tap(find.text('Save').at(1)); + await tester.pumpAndSettle(); + expect(find.text('Body fat must be 3-70 %.'), findsOneWidget); + + await tester.enterText(field, '18,5'); + await tester.tap(find.text('Save').at(1)); + await tester.pumpAndSettle(); + expect(BodyService.instance.latestBodyFat?.$2, 18.5); + expect(find.textContaining('(Katch-McArdle resting'), findsOneWidget); + expect(find.text('1778 kcal'), findsOneWidget); + expect(find.text('2206'), findsOneWidget); + }); + + testWidgets('saving and deleting a weight updates the numbers', ( + tester, + ) async { + await seedPerson(store); + await pumpBody(tester); + final field = find.widgetWithText(TextField, 'Weight (kg)'); + await tester.enterText(field, '5'); + await tester.tap(find.text('Save').first); + await tester.pumpAndSettle(); + expect(find.text('Weight must be 20-400 kg.'), findsOneWidget); + + await tester.enterText(field, '90,5'); + await tester.tap(find.text('Save').first); + await tester.pumpAndSettle(); + final today = localDateKey(DateTime.now()); + expect(BodyService.instance.latestWeight, (today, 90.5)); + expect(find.text('27.9'), findsOneWidget); // 90.5 / 1.8^2 + + await tester.tap(find.byTooltip('Delete this day').first); + await tester.pumpAndSettle(); + expect(BodyService.instance.latestWeight?.$1, yesterdayKey()); + }); + + testWidgets('the profile form validates height and saves', (tester) async { + await pumpBody(tester); + final field = find.widgetWithText(TextField, 'Height (cm)'); + await tester.enterText(field, '9'); + await tester.tap(find.text('Save profile')); + await tester.pumpAndSettle(); + expect(find.text('Height must be 50-260 cm.'), findsOneWidget); + + await tester.enterText(field, '172.5'); + await tester.tap(find.text('Female')); + await tester.tap(find.text('Save profile')); + await tester.pumpAndSettle(); + expect(BodyService.instance.heightCm, 172.5); + expect(BodyService.instance.sex, 'f'); + expect(find.text('Saved.'), findsOneWidget); + }); + + test('kcalByDay of an empty log is empty', () { + expect(kcalByDay(const {}), isEmpty); + }); +} diff --git a/app/test/screens/log_meal_screen_nav_test.dart b/app/test/screens/log_meal_screen_nav_test.dart index dbdd883..4481a4f 100644 --- a/app/test/screens/log_meal_screen_nav_test.dart +++ b/app/test/screens/log_meal_screen_nav_test.dart @@ -1,12 +1,14 @@ import 'dart:io'; import 'package:diet_guard_app/models/food_entry.dart'; +import 'package:diet_guard_app/screens/body_screen.dart'; import 'package:diet_guard_app/screens/calendar_screen.dart'; import 'package:diet_guard_app/screens/food_bank_screen.dart'; import 'package:diet_guard_app/screens/history_screen.dart'; import 'package:diet_guard_app/screens/log_meal_screen.dart'; import 'package:diet_guard_app/screens/settings_screen.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/body_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; @@ -17,6 +19,8 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; +import '../services/body_test_support.dart'; + /// Stub launcher that records the URL instead of opening it -- same pattern /// as settings_screen_test.dart's fake, duplicated locally per this /// codebase's convention of small per-file fakes over a shared mock. @@ -43,7 +47,6 @@ class _FakeUrlLauncher extends UrlLauncherPlatform /// it as a real image instead of throwing on bogus bytes. void main() { - late Directory tempDir; setUp(() async { @@ -51,9 +54,7 @@ void main() { LogStorageService.resetForTesting(store: FileDocumentStore(tempDir)); FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { @@ -89,9 +90,7 @@ void main() { expect(find.byType(HistoryScreen), findsOneWidget); }); }); - testWidgets('the calendar icon navigates to CalendarScreen', ( - tester, - ) async { + testWidgets('the calendar icon navigates to CalendarScreen', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); await settle(tester); @@ -102,6 +101,19 @@ void main() { expect(find.byType(CalendarScreen), findsOneWidget); }); }); + testWidgets('the body icon navigates to BodyScreen', (tester) async { + await tester.runAsync(() async { + await BodyService.initForTesting(MemoryDocStore()); + await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); + await settle(tester); + + await tester.tap(find.byTooltip('Body')); + await settle(tester); + + expect(find.byType(BodyScreen), findsOneWidget); + BodyService.resetForTesting(); + }); + }); testWidgets('the settings icon navigates to SettingsScreen', (tester) async { await tester.runAsync(() async { await tester.pumpWidget(const MaterialApp(home: LogMealScreen())); diff --git a/app/test/screens/log_meal_screen_sync_test.dart b/app/test/screens/log_meal_screen_sync_test.dart index e930438..ee82c32 100644 --- a/app/test/screens/log_meal_screen_sync_test.dart +++ b/app/test/screens/log_meal_screen_sync_test.dart @@ -36,27 +36,26 @@ void main() { await tester.pumpAndSettle(); } - testWidgets( - 'does not push when sync is unconfigured (defaults to off)', - (tester) async { - SharedPreferences.setMockInitialValues({}); - installFakeSecureStorage(); - var puts = 0; - final mock = MockClient((req) async { - if (req.method == 'PUT') puts++; - return http.Response('', 404); - }); + testWidgets('does not push when sync is unconfigured (defaults to off)', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + var puts = 0; + final mock = MockClient((req) async { + if (req.method == 'PUT') puts++; + return http.Response('', 404); + }); - await tester.runAsync(() async { - await tester.pumpWidget( - MaterialApp(home: LogMealScreen(httpClient: mock)), - ); - await settle(tester); + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp(home: LogMealScreen(httpClient: mock)), + ); + await settle(tester); - expect(puts, 0); - }); - }, - ); + expect(puts, 0); + }); + }); testWidgets('pushes on launch when sync is configured', (tester) async { SharedPreferences.setMockInitialValues({ @@ -170,7 +169,13 @@ void main() { await tester.enterText(find.byType(TextField).at(0), 'push-on-log'); await tester.pump(); await tester.tap(find.byTooltip('Log meal')); - await settle(tester); + // Wait for the push itself rather than a fixed 200 ms: under the + // CPU-capped pre-commit gate the real async hop can take longer, and + // a wall-clock guess made this flaky there (passes 5/5 standalone). + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (puts < 2 && DateTime.now().isBefore(deadline)) { + await settle(tester); + } // The new meal is pushed right away, not left for a lifecycle event. expect(puts, 2); diff --git a/app/test/services/activity_kcal_share_test.dart b/app/test/services/activity_kcal_share_test.dart new file mode 100644 index 0000000..7d042e1 --- /dev/null +++ b/app/test/services/activity_kcal_share_test.dart @@ -0,0 +1,36 @@ +import 'package:diet_guard_app/services/activity_kcal.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final today = DateTime(2026, 9, 28); + const run = Session( + day: '2026-09-22', + kind: 'run', + minutes: 60, + km: 6, + label: 'Run', + source: 'runnerup', + ); + + group('extraShare', () { + test('is zero with no past data', () { + expect(extraShare(const [], today, const {'2026-09-28': 50}), 0); + }); + + test('uses the same window as dailyExercise, so it adds up', () { + const steps = {'2026-09-26': 60.0, '2026-09-01': 999.0}; + // The 09-01 step day stretches the window to the full 14 days but is + // itself outside it; only 09-26 counts. + final share = extraShare(const [run], today, steps); + expect(share, closeTo(60 / 14, 1e-9)); + final withSteps = dailyExercise(const [run], 80, today, steps); + final without = dailyExercise( + const [run], + 80, + today, + {'2026-09-01': 0.0}, + ); + expect(withSteps['MET']! - without['MET']!, closeTo(share, 1e-9)); + }); + }); +} diff --git a/app/test/services/background_sync_service_test.dart b/app/test/services/background_sync_service_test.dart index 03fdf13..599ad02 100644 --- a/app/test/services/background_sync_service_test.dart +++ b/app/test/services/background_sync_service_test.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/body_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/background_sync_service.dart'; import 'package:diet_guard_app/services/foodbank_service.dart'; @@ -33,13 +34,10 @@ void main() { // .init() call short-circuits instead of hitting the real (unmocked in // this test) path_provider channel. AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); MealScheduleService.resetForTesting(store: FileDocumentStore(tempDir)); - KuchniaCredentialService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BodyService.resetForTesting(store: FileDocumentStore(tempDir)); + KuchniaCredentialService.resetForTesting(store: FileDocumentStore(tempDir)); }); tearDown(() async { @@ -48,6 +46,7 @@ void main() { AppSettingsService.resetForTesting(); BudgetHistoryService.resetForTesting(); MealScheduleService.resetForTesting(); + BodyService.resetForTesting(); KuchniaCredentialService.resetForTesting(); await tempDir.delete(recursive: true); }); @@ -90,10 +89,10 @@ void main() { expect(ok, isTrue); // syncLog always pushes, even an empty merged result: food_log.json, - // budget.json, food_bank.json, food_bank_manual.json, kuchnia.json. - // Five data files plus this device's revision, which is what lets a - // later tick skip an unchanged peer. - expect(puts, 6); + // budget.json, food_bank.json, food_bank_manual.json, kuchnia.json, + // body.json. Six data files plus this device's revision, which is what + // lets a later tick skip an unchanged peer. + expect(puts, 7); }); test('pushes under the persisted uuid, not the legacy role id', () async { diff --git a/app/test/services/body_parity_test.dart b/app/test/services/body_parity_test.dart new file mode 100644 index 0000000..ca092fd --- /dev/null +++ b/app/test/services/body_parity_test.dart @@ -0,0 +1,220 @@ +/// The Dart half of the cross-language Body parity gate. +/// +/// `diet_guard/tests/test_body_parity.py` asserts the same expectations +/// against the same `tests/fixtures/body_calc.json` (built from the Python +/// reference by `scripts/build_body_fixture.py`), so the PC and the phone +/// print the same BMI, ideal weights, calorie table, exercise kcal and week +/// plan for the same person. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:diet_guard_app/services/activity_kcal.dart'; +import 'package:diet_guard_app/services/body_calc.dart'; +import 'package:diet_guard_app/services/body_energy.dart'; +import 'package:diet_guard_app/services/body_prefs.dart'; +import 'package:diet_guard_app/services/body_steps.dart'; +import 'package:diet_guard_app/services/week_plan.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Directory get _repoRoot { + var dir = Directory.current; + while (!File('${dir.path}/pubspec.yaml').existsSync()) { + final parent = dir.parent; + if (parent.path == dir.path) { + fail('could not locate app/ from ${Directory.current.path}'); + } + dir = parent; + } + return dir.parent; +} + +Map _loadFixture() => + jsonDecode( + File( + '${_repoRoot.path}/tests/fixtures/body_calc.json', + ).readAsStringSync(), + ) + as Map; + +void _close(Object? actual, Object? expected, String what) { + if (expected == null) { + expect(actual, isNull, reason: what); + return; + } + expect(actual, isNotNull, reason: what); + expect( + actual! as num, + closeTo((expected as num).toDouble(), 1e-9), + reason: what, + ); +} + +Biometrics _bio(Map raw) => Biometrics( + weightKg: (raw['weight_kg'] as num).toDouble(), + heightCm: (raw['height_cm'] as num).toDouble(), + ageYears: (raw['age_years'] as num).toDouble(), + isMale: raw['is_male'] as bool, +); + +Session _session(Map raw) => Session( + day: raw['day'] as String, + kind: raw['kind'] as String, + minutes: (raw['minutes'] as num).toDouble(), + km: (raw['km'] as num?)?.toDouble(), + label: raw['label'] as String, + source: raw['source'] as String, + start: raw['start'] as String?, + end: raw['end'] as String?, +); + +void main() { + final fixture = _loadFixture(); + final today = DateTime.parse(fixture['today'] as String); + final steps = (fixture['steps'] as Map).map( + (day, n) => MapEntry(day, n as int), + ); + final sessions = (fixture['sessions'] as List) + .map((raw) => _session(raw as Map)) + .toList(); + + group('people', () { + final people = fixture['people'] as List; + for (var index = 0; index < people.length; index++) { + final person = people[index] as Map; + final bio = _bio(person['bio'] as Map); + test('person $index: BMI, ideal weight, BMR', () { + final bmi = bmiReport(bio); + final expected = person['bmi'] as Map; + _close(bmi.bmi, expected['bmi'], 'bmi'); + _close(bmi.prime, expected['prime'], 'prime'); + _close(bmi.trefethen, expected['trefethen'], 'trefethen'); + expect(bmi.category, expected['category']); + final fat = (person['body_fat_pct'] as num).toDouble(); + for (final (name, list) in [ + ('ideal', idealWeights(bio)), + ('bmr', bmrFormulas(bio)), + ('bmr_with_fat', bmrFormulas(bio, fat)), + ]) { + final rows = person[name] as List; + expect(list, hasLength(rows.length)); + for (var i = 0; i < rows.length; i++) { + final row = rows[i] as List; + expect(list[i].name, row[0]); + _close(list[i].value, row[1], '$name ${row[0]}'); + } + } + }); + test('person $index: healthy range and ideal summary', () { + final (low, high) = healthyRange(bio); + final range = person['healthy_range'] as List; + _close(low, range[0], 'healthy low'); + _close(high, range[1], 'healthy high'); + final summary = idealSummary(idealWeights(bio))!; + final expected = person['ideal_summary'] as List; + _close(summary.$1, expected[0], 'ideal avg'); + _close(summary.$2, expected[1], 'ideal min'); + _close(summary.$3, expected[2], 'ideal max'); + }); + test('person $index: steps, exercise and goal targets', () { + _close(strideM(bio), person['stride_m'], 'stride'); + final stepKcals = (person['step_kcal'] as Map); + final byDay = {}; + for (final entry in steps.entries) { + byDay[entry.key] = stepKcal(entry.value, bio); + _close(byDay[entry.key], stepKcals[entry.key], 'steps ${entry.key}'); + } + final exercise = dailyExercise(sessions, bio.weightKg, today, byDay); + for (final (key, actual) in [ + ('exercise', exercise), + ('exercise_no_steps', dailyExercise(sessions, bio.weightKg, today)), + // History younger than 14 days: averaged from its first day. + ( + 'exercise_short', + dailyExercise( + [ + for (final x in sessions) + if (x.day.compareTo('2026-09-24') >= 0) x, + ], + bio.weightKg, + today, + ), + ), + // Only today's in-progress session: no past data, all zero. + ( + 'exercise_today_only', + dailyExercise( + [ + for (final x in sessions) + if (x.day == '2026-09-28') x, + ], + bio.weightKg, + today, + ), + ), + ]) { + final expected = person[key] as Map; + expect(actual.keys.toList(), expected.keys.toList()); + for (final name in expected.keys) { + _close(actual[name], expected[name], '$key $name'); + } + } + for (final raw in person['targets'] as List) { + final row = raw as Map; + final fat = (row['body_fat_pct'] as num?)?.toDouble(); + final goal = goalFromJson(row['goal']); + expect(goalToJson(goal), row['goal']); + final bmr = tableBmr(bio, fat); + final target = goalTarget(goal, bmr.value!, bmr.name, exercise); + final what = '${row['goal']} fat=$fat'; + expect(target.kcal, row['kcal'], reason: what); + _close(target.tdee, row['tdee'], what); + expect(target.bmrName, row['bmr_name'], reason: what); + } + }); + } + }); + + test('session kcal at 80 kg', () { + final expected = fixture['session_kcal_80kg'] as List; + for (var i = 0; i < sessions.length; i++) { + final result = sessionKcal(sessions[i], 80); + final row = expected[i] as Map; + final kcal = row['kcal'] as Map; + expect(result.kcal.keys.toList(), kcal.keys.toList()); + for (final name in kcal.keys) { + _close(result.kcal[name], kcal[name], 'session $i $name'); + } + expect(result.fallback.toList()..sort(), row['fallback']); + } + }); + + test('week plans', () { + for (final raw in fixture['plans'] as List) { + final plan = raw as Map; + final result = planWeek( + DateTime.parse(plan['today'] as String), + plan['target'] as int, + (plan['logged'] as Map).map( + (k, v) => MapEntry(k, (v as num).toDouble()), + ), + (plan['typed'] as Map).map( + (k, v) => MapEntry(int.parse(k), v as int), + ), + ); + final expected = plan['expected'] as Map; + expect(result.targetAvg, expected['target_avg']); + expect(result.perRemaining, expected['per_remaining']); + _close(result.weekAvg, expected['week_avg'], 'week_avg'); + final days = expected['days'] as List; + for (var i = 0; i < 7; i++) { + final day = days[i] as Map; + expect(result.days[i].day, day['day']); + expect(result.days[i].weekday, day['weekday']); + expect(result.days[i].kcal, day['kcal']); + expect(result.days[i].source, day['source']); + } + } + }); +} diff --git a/app/test/services/body_prefs_test.dart b/app/test/services/body_prefs_test.dart new file mode 100644 index 0000000..87c0ad5 --- /dev/null +++ b/app/test/services/body_prefs_test.dart @@ -0,0 +1,75 @@ +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/services/body_energy.dart'; +import 'package:diet_guard_app/services/body_prefs.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'body_test_support.dart'; + +void main() { + late BodyService body; + + setUp(() async => body = await BodyService.initForTesting(MemoryDocStore())); + tearDown(BodyService.resetForTesting); + + test('unset reads as the default goal', () { + final goal = savedGoal(body); + expect(goalToJson(goal), goalToJson(defaultGoal)); + }); + + test('each part falls back to the default on its own', () { + final goal = goalFromJson({ + 'dir': 'gain', + 'amt': -1, + 'wu': 'furlong', + 'tu': 'month', + 'act': 'measured:ACSM', + }); + expect(goal.direction, 'gain'); + expect(goal.amount, defaultGoal.amount); + expect(goal.weightUnit, 'kg'); + expect(goal.timeUnit, 'month'); + expect(goal.activity, 'measured:ACSM'); + expect(goalFromJson({'amt': true, 'act': 3}).activity, 'moderate'); + expect(goalToJson(goalFromJson('nope')), goalToJson(defaultGoal)); + }); + + test('saving syncs one field and never re-stamps the profile', () async { + await body.setProfile( + birth: '1990-01-01', + now: DateTime.parse('2026-09-01T10:00:00'), + ); + const goal = Goal('gain', 2, 'lb', 'month', 'extra'); + await setGoal(body, goal); + expect(body.profileValue(goalField), goalToJson(goal)); + expect(goalToJson(savedGoal(body)), goalToJson(goal)); + expect(body.document.profile[profileBirth]!.t, startsWith('2026-09-01T10')); + }); + + test('goal arithmetic: units, maintain and measured without data', () { + expect(const Goal('maintain', 9, 'kg', 'day', 'light').kgPerDay(), 0); + expect( + const Goal('gain', 7, 'kg', 'week', 'light').kgPerDay(), + closeTo(-1, 1e-12), + ); + final missing = goalTarget( + const Goal('lose', 1, 'kg', 'week', 'measured:MET'), + 1800, + 'Mifflin-St Jeor', + null, + ); + expect(missing.kcal, isNull); + expect(missing.tdee, isNull); + expect(missing.belowFloor, isFalse); + expect( + goalTarget(defaultGoal.copyWith(activity: '?'), 1800, 'x', null).kcal, + isNull, + ); + expect(activityLabel('measured:Per-km'), 'Measured (Per-km)'); + expect(activityLabel('very'), 'Very active'); + expect(activityLabel('?'), '?'); + expect(formatG(1 / 3), '0.333333'); + expect(formatG(0.25), '0.25'); + expect(formatG(1e20), '1.00000e+20'); + }); +} diff --git a/app/test/services/body_service_test.dart b/app/test/services/body_service_test.dart new file mode 100644 index 0000000..964ac7c --- /dev/null +++ b/app/test/services/body_service_test.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; + +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'body_test_support.dart'; + +final _t0 = DateTime.parse('2026-09-27T08:00:00'); + +void main() { + late MemoryDocStore store; + + setUp(() async { + store = MemoryDocStore(); + await BodyService.initForTesting(store); + }); + tearDown(BodyService.resetForTesting); + + test('an empty store reads as an empty document', () { + final body = BodyService.instance; + expect(body.birth, isNull); + expect(body.heightCm, isNull); + expect(body.sex, isNull); + expect(body.latestWeight, isNull); + expect(body.sessions, isNull); + expect(BodyService.isInitialized, isTrue); + }); + + test('a corrupt document reads as empty', () async { + store.documents[BodyService.documentName] = '{not json'; + final body = await BodyService.initForTesting(store); + expect(body.document.weights, isEmpty); + }); + + test('setProfile stamps only the fields given and persists', () async { + final body = BodyService.instance; + await body.setProfile(birth: '1995-03-01', heightCm: 180, now: _t0); + await body.setProfile(sex: 'm', now: _t0.add(const Duration(hours: 1))); + expect(body.birth, '1995-03-01'); + expect(body.heightCm, 180); + expect(body.sex, 'm'); + final reloaded = await BodyService.initForTesting(store); + expect( + reloaded.document.profile[profileBirth]!.t, + startsWith('2026-09-27T08'), + ); + expect( + reloaded.document.profile[profileSex]!.t, + startsWith('2026-09-27T09'), + ); + }); + + test('weights: set, delete (tombstone), series and latest', () async { + final body = BodyService.instance; + await body.setWeight('2026-09-25', 81.5, now: _t0); + await body.setWeight('2026-09-27', 80.9, now: _t0); + await body.setWeight('2026-09-26', 81.2, now: _t0); + expect(body.weightSeries.map((p) => p.$1), [ + '2026-09-25', + '2026-09-26', + '2026-09-27', + ]); + await body.deleteWeight('2026-09-27', now: _t0); + expect(body.latestWeight, ('2026-09-26', 81.2)); + expect(body.document.weights['2026-09-27']!.kg, isNull); + final saved = jsonDecode(store.documents[BodyService.documentName]!) as Map; + expect((saved['weights'] as Map)['2026-09-27'], containsPair('kg', null)); + }); + + test('applyCandidates only replaces missing or older entries', () async { + final body = BodyService.instance; + await body.setWeight('2026-09-26', 82, now: _t0); + final changed = await body.applyCandidates({ + '2026-09-26': const WeightEntry( + kg: 70, + src: 'phone', + t: '2026-09-26T00:00:00+00:00', + ), + '2026-09-25': const WeightEntry( + kg: 81, + src: 'phone', + t: '2026-09-25T00:00:00+00:00', + ), + }); + expect(changed, isTrue); + expect(body.document.weights['2026-09-26']!.kg, 82); + expect(body.document.weights['2026-09-25']!.src, 'phone'); + final newer = await body.applyCandidates({ + '2026-09-26': const WeightEntry( + kg: 79, + src: 'phone', + t: '2026-09-28T07:00:00+00:00', + ), + }); + expect(newer, isTrue); + expect(body.document.weights['2026-09-26']!.kg, 79); + expect(await body.applyCandidates(const {}), isFalse); + }); + + test('sessions parse the published activity, skipping malformed', () async { + store.documents[BodyService.documentName] = jsonEncode({ + 'activity': { + 't': '2026-09-27T10:00:00+02:00', + 'sessions': [ + { + 'day': '2026-09-26', + 'kind': 'run', + 'min': 30, + 'km': 5, + 'label': 'Run', + 'src': 'runnerup', + }, + {'day': '2026-09-26', 'kind': 'strength', 'min': 60}, + {'day': 3}, + 'junk', + {'day': '2026-09-26', 'kind': 'run', 'min': 30, 'km': 'far'}, + ], + }, + }); + final body = await BodyService.initForTesting(store); + final sessions = body.sessions!; + expect(sessions, hasLength(2)); + expect(sessions[0].km, 5); + expect(sessions[1].label, 'strength'); + expect(sessions[1].source, ''); + }); + + test('applyMerged replaces the document', () async { + await BodyService.instance.applyMerged( + const BodyDocument( + weights: {'2026-09-20': WeightEntry(kg: 90, src: 'init', t: 'x')}, + ), + ); + expect(BodyService.instance.latestWeight, ('2026-09-20', 90.0)); + }); + + test('document parsing drops malformed parts', () { + final doc = BodyDocument.fromJson({ + 'profile': { + 'birth': {'v': '1990-01-01'}, + 'sex': {'v': 'f', 't': 'x'}, + }, + 'weights': { + 'yesterday': {'kg': 80, 't': 'x'}, + '2026-01-01': {'kg': 5, 't': 'x'}, + '2026-01-02': {'kg': 80}, + '2026-01-03': {'kg': 80, 't': 'x'}, + }, + 'activity': {'sessions': 'nope', 't': 'x'}, + }); + expect(doc.profile.keys, ['sex']); + expect(doc.weights.keys, ['2026-01-03']); + expect(doc.weights['2026-01-03']!.src, 'manual'); + expect(doc.activity, isNull); + expect(BodyDocument.fromJson('nope').weights, isEmpty); + }); + + test('body fat: set (rounded), delete, series and latest', () async { + final body = BodyService.instance; + await body.setBodyFat('2026-09-25', 20.04, now: _t0); + await body.setBodyFat('2026-09-26', 19.55, now: _t0); + expect(body.latestBodyFat, ('2026-09-26', 19.6)); + await body.deleteBodyFat('2026-09-26', now: _t0); + expect(body.latestBodyFat, ('2026-09-25', 20.0)); + expect(body.document.bodyFat['2026-09-26']!.pct, isNull); + final reloaded = await BodyService.initForTesting(store); + expect(reloaded.bodyFatSeries, [('2026-09-25', 20.0)]); + }); + + test('profileComplete needs a real date, a height and m/f', () async { + final body = BodyService.instance; + expect(body.profileComplete, isFalse); + await body.setProfile(birth: 'soon', heightCm: 180, sex: 'x'); + expect(body.profileComplete, isFalse); + expect(body.sex, isNull); + await body.setProfile(birth: '1990-01-01', sex: 'f'); + expect(body.profileComplete, isTrue); + }); + + test( + 'setProfileFields ignores an empty map and keeps other stamps', + () async { + final body = BodyService.instance; + await body.setProfile(heightCm: 180, now: _t0); + await body.setProfileFields(const {}); + await body.setProfileFields({ + 'tbl_act': ['light'], + }, now: _t0.add(const Duration(days: 1))); + expect(body.profileValue('tbl_act'), ['light']); + expect(body.document.profile[profileHeight]!.t, startsWith('2026-09-27')); + }, + ); +} diff --git a/app/test/services/body_test_support.dart b/app/test/services/body_test_support.dart new file mode 100644 index 0000000..cd676db --- /dev/null +++ b/app/test/services/body_test_support.dart @@ -0,0 +1,74 @@ +// Shared fakes for the Body feature's tests: an in-memory DocumentStore and +// an in-memory RemoteStore (path -> text), enough for `syncLog` and the +// wake-alarm ingestion, which only list directories and read/write files. + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/services/document_store.dart'; + +/// Documents kept in a map. +class MemoryDocStore implements DocumentStore { + /// Stored documents by name. + final Map documents = {}; + + @override + Future read(String name) async => documents[name]; + + @override + Future write(String name, String contents) async { + documents[name] = contents; + } +} + +/// A RemoteStore over a flat `path -> text` map. +class MemoryRemote implements RemoteStore { + /// Creates the store, optionally pre-seeded with [files]. + MemoryRemote([Map? files]) : files = {...?files}; + + /// Every file, by full path. + final Map files; + + /// Set to make every call throw, to test failure handling. + Object? failWith; + + void _maybeFail() { + final error = failWith; + if (error != null) throw error; + } + + @override + Future> listDirectory(String path) async { + _maybeFail(); + final prefix = '$path/'; + return { + for (final key in files.keys) + if (key.startsWith(prefix)) key.substring(prefix.length).split('/')[0], + }.toList(); + } + + @override + Future getFileText(String path) async { + _maybeFail(); + return files[path]; + } + + @override + Future putFileText( + String path, + String text, { + required String message, + }) async { + _maybeFail(); + files[path] = text; + } + + @override + Future deleteFile(String path, {String message = ''}) async { + files.remove(path); + } + + @override + Future canAccessRemote() async => true; + + @override + void close() {} +} diff --git a/app/test/services/due_slot_check_test.dart b/app/test/services/due_slot_check_test.dart index d1d8290..265196b 100644 --- a/app/test/services/due_slot_check_test.dart +++ b/app/test/services/due_slot_check_test.dart @@ -8,6 +8,7 @@ import 'dart:io'; import 'package:diet_guard_app/models/nutrition.dart'; import 'package:diet_guard_app/services/app_settings_service.dart'; +import 'package:diet_guard_app/services/body_service.dart'; import 'package:diet_guard_app/services/budget_history_service.dart'; import 'package:diet_guard_app/services/document_store_io.dart'; import 'package:diet_guard_app/services/due_slot_check.dart'; @@ -49,13 +50,10 @@ void main() { // channel, exactly as background_sync_service_test.dart does. FoodBankService.resetForTesting(store: FileDocumentStore(tempDir)); AppSettingsService.resetForTesting(store: FileDocumentStore(tempDir)); - BudgetHistoryService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BudgetHistoryService.resetForTesting(store: FileDocumentStore(tempDir)); MealScheduleService.resetForTesting(store: FileDocumentStore(tempDir)); - KuchniaCredentialService.resetForTesting( - store: FileDocumentStore(tempDir), - ); + BodyService.resetForTesting(store: FileDocumentStore(tempDir)); + KuchniaCredentialService.resetForTesting(store: FileDocumentStore(tempDir)); SharedPreferences.setMockInitialValues({}); installFakeSecureStorage(); notificationLog = installFakeAndroidNotifications(); @@ -71,6 +69,7 @@ void main() { BudgetHistoryService.resetForTesting(); KuchniaCredentialService.resetForTesting(); MealScheduleService.resetForTesting(); + BodyService.resetForTesting(); NotificationService.resetForTesting(); await tempDir.delete(recursive: true); }); @@ -114,9 +113,7 @@ void main() { // on specific slots (which depend on the actual time the test runs). await checkAndNotify(); expect( - notificationLog.where( - (c) => c.method == 'show' || c.method == 'cancel', - ), + notificationLog.where((c) => c.method == 'show' || c.method == 'cancel'), isNotEmpty, ); }); @@ -164,10 +161,7 @@ void main() { expect(requests, 0); // Still reconciles notifications from the local log alone. - expect( - notificationLog.where((c) => c.method == 'show'), - isNotEmpty, - ); + expect(notificationLog.where((c) => c.method == 'show'), isNotEmpty); }); test('a meal pulled from another device cancels its reminder', () async { diff --git a/app/test/services/health_steps_test.dart b/app/test/services/health_steps_test.dart new file mode 100644 index 0000000..7968c04 --- /dev/null +++ b/app/test/services/health_steps_test.dart @@ -0,0 +1,147 @@ +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/services/activity_kcal.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/services/health_steps.dart'; +import 'package:diet_guard_app/services/health_steps_source_io.dart'; +import 'package:diet_guard_app/services/health_steps_source_web.dart' as web; +import 'package:flutter_test/flutter_test.dart'; + +import 'body_test_support.dart'; + +/// A scripted Health Connect. +class FakeStepsSource implements StepsSource { + FakeStepsSource({this.granted = true, this.intervals = const []}); + + bool granted; + bool grantOnRequest = true; + List intervals; + Object? failWith; + (DateTime, DateTime)? lastRead; + + @override + bool get supported => true; + + @override + Future hasPermission() async { + final error = failWith; + if (error != null) throw error; + return granted; + } + + @override + Future requestPermission() async => granted = grantOnRequest; + + @override + Future> read(DateTime from, DateTime to) async { + lastRead = (from, to); + return intervals; + } +} + +DateTime _at(String iso) => DateTime.parse(iso); + +StepInterval _steps(String from, String to, int n) => + StepInterval(_at(from), _at(to), n); + +void main() { + final original = stepsSource; + tearDown(() { + stepsSource = original; + BodyService.resetForTesting(); + }); + + test('intervals inside a workout window are dropped', () { + const walk = Session( + day: '2026-09-26', + kind: 'walk', + minutes: 60, + km: 5, + label: 'walk', + source: 'screen-locker', + start: '2026-09-26T18:00:00', + end: '2026-09-26T19:00:00', + ); + const noWindow = Session( + day: '2026-09-26', + kind: 'strength', + minutes: 60, + km: null, + label: 'lift', + source: 'screen-locker', + ); + final counts = stepsOutsideWorkouts( + [ + _steps('2026-09-26T08:00:00', '2026-09-26T09:00:00', 3000), + _steps('2026-09-26T18:30:00', '2026-09-26T18:45:00', 2000), + _steps('2026-09-26T17:50:00', '2026-09-26T18:05:00', 500), + _steps('2026-09-26T19:00:00', '2026-09-26T19:10:00', 400), + _steps('2026-09-27T10:00:00', '2026-09-27T11:00:00', 7000), + ], + [walk, noWindow], + ); + expect(counts, {'2026-09-26': 3400, '2026-09-27': 7000}); + }); + + test('session start/end parse tolerantly from the wire', () { + final parsed = Session.fromWire({ + 'day': '2026-09-26', + 'kind': 'walk', + 'min': 60, + 'start': '2026-09-26T18:00:00+02:00', + 'end': 'soon', + })!; + expect(parsed.start, '2026-09-26T18:00:00+02:00'); + expect(parsed.end, isNull); + }); + + test('applySteps re-stamps only changed days', () async { + final body = await BodyService.initForTesting(MemoryDocStore()); + final t0 = _at('2026-09-27T08:00:00'); + expect(await applySteps(body, {'2026-09-26': 100}, now: t0), isTrue); + final stamp = body.document.steps['2026-09-26']!.t; + final later = t0.add(const Duration(hours: 1)); + expect(await applySteps(body, {'2026-09-26': 100}, now: later), isFalse); + expect(body.document.steps['2026-09-26']!.t, stamp); + await applySteps(body, {'2026-09-26': 150}, now: later); + expect(body.document.steps['2026-09-26']!.n, 150); + }); + + test('refreshSteps reads 28 days when permitted, never prompts', () async { + await BodyService.initForTesting(MemoryDocStore()); + final fake = FakeStepsSource( + granted: false, + intervals: [_steps('2026-09-26T08:00:00', '2026-09-26T09:00:00', 42)], + ); + stepsSource = fake; + final now = _at('2026-09-28T12:00:00'); + expect(await refreshSteps(now: now), isFalse); + expect(fake.lastRead, isNull); + fake.granted = true; + expect(await refreshSteps(now: now), isTrue); + expect(fake.lastRead!.$1, DateTime(2026, 8, 31)); + expect(BodyService.instance.document.steps['2026-09-26']!.n, 42); + fake.failWith = StateError('Health Connect gone'); + expect(await refreshSteps(now: now), isFalse); + }); + + test('refreshSteps no-ops without the body service', () async { + stepsSource = FakeStepsSource(); + expect(await refreshSteps(), isFalse); + }); + + test('platform sources: web and non-Android report unsupported', () async { + const source = web.NoStepsSource(); + expect(source.supported, isFalse); + expect(await source.hasPermission(), isFalse); + expect(await source.requestPermission(), isFalse); + expect(await source.read(DateTime(2026), DateTime(2026, 2)), isEmpty); + expect(web.createStepsSource().supported, isFalse); + expect(createStepsSource().supported, isFalse); + expect(HealthConnectStepsSource().supported, isFalse); + stepsSource = HealthConnectStepsSource(); + await BodyService.initForTesting(MemoryDocStore()); + expect(await refreshSteps(), isFalse); + expect(BodyService.instance.document.steps, isEmpty); + expect(const StepsEntry(n: 1, t: 'x').toJson(), {'n': 1, 't': 'x'}); + }); +} diff --git a/app/test/services/sync_body_test.dart b/app/test/services/sync_body_test.dart new file mode 100644 index 0000000..3771a27 --- /dev/null +++ b/app/test/services/sync_body_test.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; + +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/services/sync_body.dart'; +import 'package:diet_guard_app/services/sync_device_id.dart'; +import 'package:diet_guard_app/services/sync_merge_body.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'body_test_support.dart'; + +const _dir = 'diet-guard-sync/devices'; + +void main() { + tearDown(BodyService.resetForTesting); + + test('no-ops when the body service is not initialised', () async { + final remote = MemoryRemote(); + await syncBody(remote, devicesDir: _dir); + expect(remote.files, isEmpty); + }); + + test('merges a peer, ingests the weigh-in, pushes the result', () async { + await BodyService.initForTesting(MemoryDocStore()); + await BodyService.instance.setProfile( + heightCm: 180, + now: DateTime.parse('2026-09-01T10:00:00'), + ); + final peer = BodyDocument( + profile: const { + profileBirth: Stamped('1995-03-01', '2026-09-02T10:00:00+02:00'), + }, + activity: const ActivityData( + sessions: [ + {'day': '2026-09-26', 'kind': 'strength', 'min': 80}, + ], + t: '2026-09-27T10:00:00+02:00', + ), + ); + final remote = MemoryRemote({ + '$_dir/pc-uuid/body.json': encodeBodyForPush(bodyToLog(peer)), + 'wake-alarm-sync/devices/phone/alarm.json': jsonEncode({ + 'alarm': { + 'fields': { + 'morning_sessions': [ + [ + {'date': '2026-09-27', 'weight_kg': 80.5}, + ], + '2026-09-27T05:00:00.000Z-0000-phone', + ], + }, + }, + }), + }); + + await syncBody(remote, devicesDir: _dir); + + final body = BodyService.instance; + expect(body.birth, '1995-03-01'); + expect(body.heightCm, 180); + expect(body.latestWeight, ('2026-09-27', 80.5)); + expect(body.sessions, hasLength(1)); + final pushed = remote.files['$_dir/$currentSyncDeviceId/body.json']; + expect(pushed, isNotNull); + final pushedDoc = logToBody(parseRemoteBody(pushed!)); + expect(pushedDoc.weights.keys, ['2026-09-27']); + expect(pushedDoc.profile.keys.toSet(), {profileBirth, profileHeight}); + }); +} diff --git a/app/test/services/sync_merge_body_test.dart b/app/test/services/sync_merge_body_test.dart new file mode 100644 index 0000000..b9d51f1 --- /dev/null +++ b/app/test/services/sync_merge_body_test.dart @@ -0,0 +1,216 @@ +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/services/sync_merge_body.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _sessions = [ + {'day': '2026-09-26', 'kind': 'run', 'min': 30, 'km': 5}, +]; + +BodyDocument _doc({ + String heightT = '2026-09-01T10:00:00+02:00', + String birthT = '2026-09-01T10:00:00+02:00', + double height = 180, + String birth = '1995-03-01', + Map weights = const {}, + ActivityData? activity, +}) => BodyDocument( + profile: { + profileHeight: Stamped(height, heightT), + profileBirth: Stamped(birth, birthT), + profileSex: const Stamped('m', '2026-09-01T10:00:00+02:00'), + }, + weights: weights, + activity: activity, +); + +void main() { + test('round-trips a document through the wire format', () { + final doc = _doc( + weights: const { + '2026-09-26': WeightEntry( + kg: 81.2, + src: 'phone', + t: '2026-09-26T07:12:03+02:00', + ), + '2026-09-27': WeightEntry( + kg: null, + src: 'manual', + t: '2026-09-27T09:00:00+02:00', + ), + }, + activity: const ActivityData( + sessions: _sessions, + t: '2026-09-27T10:00:00+02:00', + ), + ); + final wire = encodeBodyForPush(bodyToLog(doc)); + final back = logToBody(parseRemoteBody(wire)); + expect(back.profile[profileHeight]!.value, 180.0); + expect(back.profile[profileBirth]!.value, '1995-03-01'); + expect(back.profile[profileSex]!.value, 'm'); + expect( + stampMillis(back.profile[profileHeight]!.t), + stampMillis('2026-09-01T10:00:00+02:00'), + ); + expect(back.weights['2026-09-26']!.kg, 81.2); + expect(back.weights['2026-09-26']!.src, 'phone'); + expect(back.weights['2026-09-27']!.kg, isNull); + expect(back.activity!.sessions, _sessions); + // Re-deriving from the round-tripped stamps gives the same clocks, so + // an unchanged document re-pushes byte-identical text. Compared as + // text: Record equality is shallow over map/list values. + expect(encodeBodyForPush(bodyToLog(back)), wire); + }); + + test('profile fields merge independently, last writer wins each', () { + final phone = _doc(height: 181, heightT: '2026-09-20T10:00:00+02:00'); + final pc = _doc(birth: '1994-03-01', birthT: '2026-09-21T10:00:00+02:00'); + final merged = logToBody(mergeLogs(bodyToLog(phone), bodyToLog(pc))); + expect(merged.profile[profileHeight]!.value, 181.0); + expect(merged.profile[profileBirth]!.value, '1994-03-01'); + }); + + test('an empty document is an empty log', () { + expect(bodyToLog(const BodyDocument()), isEmpty); + final back = logToBody(const {}); + expect(back.profile, isEmpty); + expect(back.activity, isNull); + }); + + test('malformed wire parts are skipped', () { + final hlc = Hlc.newTick('n', wallTimeMsOverride: 1000); + final log = { + 'profile': Record( + id: 'profile', + fields: {'height_cm': ('tall', hlc), 'birth': (3, hlc)}, + ), + 'w:bad': Record( + id: 'w:bad', + fields: { + 'kg': ({'kg': 80}, hlc), + }, + ), + 'w:2026-01-01': Record(id: 'w:2026-01-01', fields: const {}), + 'w:2026-01-02': Record( + id: 'w:2026-01-02', + fields: { + 'kg': ({'kg': 1000}, hlc), + }, + ), + 'w:2026-01-03': Record( + id: 'w:2026-01-03', + fields: { + 'kg': ({'kg': 80}, hlc), + }, + deleted: true, + ), + 'bf:bad': Record( + id: 'bf:bad', + fields: { + 'pct': ({'pct': 20}, hlc), + }, + ), + 'bf:2026-01-01': Record(id: 'bf:2026-01-01', fields: const {}), + 'bf:2026-01-02': Record( + id: 'bf:2026-01-02', + fields: { + 'pct': ({'pct': 90}, hlc), + }, + ), + 'bf:2026-01-03': Record( + id: 'bf:2026-01-03', + fields: { + 'pct': ({'pct': 20}, hlc), + }, + deleted: true, + ), + 'st:bad': Record( + id: 'st:bad', + fields: { + 'n': ({'n': 5}, hlc), + }, + ), + 'st:2026-01-01': Record(id: 'st:2026-01-01', fields: const {}), + 'st:2026-01-02': Record( + id: 'st:2026-01-02', + fields: { + 'n': ({'n': -1}, hlc), + }, + ), + 'st:2026-01-03': Record( + id: 'st:2026-01-03', + fields: { + 'n': ({'n': 5}, hlc), + }, + deleted: true, + ), + 'activity': Record(id: 'activity', fields: {'sessions': ('x', hlc)}), + }; + final doc = logToBody(log); + // Profile fields are relayed as merged; the service validates on read. + expect(doc.profile.keys.toSet(), {'height_cm', 'birth'}); + expect(doc.weights, isEmpty); + expect(doc.bodyFat, isEmpty); + expect(doc.steps, isEmpty); + expect(doc.activity, isNull); + }); + + test('body fat, steps and the goal round-trip; goal keeps its own stamp', () { + const doc = BodyDocument( + profile: { + profileHeight: Stamped(180, '2026-09-01T10:00:00+02:00'), + 'goal': Stamped({'dir': 'gain'}, '2026-09-05T10:00:00+02:00'), + }, + steps: { + '2026-09-26': StepsEntry(n: 9123, t: '2026-09-26T21:00:00+02:00'), + }, + bodyFat: { + '2026-09-26': BodyFatEntry(pct: 18.5, t: '2026-09-26T08:00:00+02:00'), + '2026-09-27': BodyFatEntry(pct: null, t: '2026-09-27T08:00:00+02:00'), + }, + ); + final wire = encodeBodyForPush(bodyToLog(doc)); + final raw = jsonDecode(wire) as Map; + expect( + (raw['bf:2026-09-26'] as Map)['fields'], + containsPair('pct', [ + {'pct': 18.5}, + isA(), + ]), + ); + final back = logToBody(parseRemoteBody(wire)); + expect(back.bodyFat['2026-09-26']!.pct, 18.5); + expect(back.bodyFat['2026-09-27']!.pct, isNull); + expect(back.profile['goal']!.value, {'dir': 'gain'}); + expect(back.steps['2026-09-26']!.n, 9123); + expect( + (raw['st:2026-09-26'] as Map)['fields'], + containsPair('n', [ + {'n': 9123}, + isA(), + ]), + ); + expect( + stampMillis(back.profile['goal']!.t), + isNot(stampMillis(back.profile[profileHeight]!.t)), + ); + expect(encodeBodyForPush(bodyToLog(back)), wire); + }); + + test('stamps keep milliseconds only when the clock has them', () { + expect( + stampFromHlc(Hlc.newTick('n', wallTimeMsOverride: 1500)), + '1970-01-01T00:00:01.500Z', + ); + final whole = stampFromHlc(Hlc.newTick('n', wallTimeMsOverride: 2000)); + expect(stampMillis(whole), 2000); + expect(whole, isNot(contains('.'))); + }); + + test('a non-object payload is rejected', () { + expect(() => parseRemoteBody('[]'), throwsFormatException); + }); +} diff --git a/app/test/services/wake_alarm_weights_test.dart b/app/test/services/wake_alarm_weights_test.dart new file mode 100644 index 0000000..e40ea17 --- /dev/null +++ b/app/test/services/wake_alarm_weights_test.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; + +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:diet_guard_app/models/body_document.dart'; +import 'package:diet_guard_app/services/body_service.dart'; +import 'package:diet_guard_app/services/wake_alarm_weights.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'body_test_support.dart'; + +String _hlc(String iso) => Hlc.newTick( + 'phone-uuid', + wallTimeMsOverride: DateTime.parse(iso).millisecondsSinceEpoch, +).toStr(); + +String _alarm({Object? latest, String? latestHlc, Object? sessions}) => + jsonEncode({ + 'alarm': { + 'id': 'alarm', + 'deleted': false, + 'fields': { + 'latest_weight_kg': ?(latest == null + ? null + : [latest, latestHlc ?? _hlc('2026-09-27T05:30:00Z')]), + 'morning_sessions': ?(sessions == null + ? null + : [sessions, _hlc('2026-09-27T05:31:00Z')]), + }, + }, + }); + +void main() { + test('latest weigh-in is stamped by its own clock', () { + final out = wakeAlarmCandidates([ + ('phone', _alarm(latest: {'date': '2026-09-27', 'kg': 80.4})), + ]); + expect(out['2026-09-27']!.kg, 80.4); + expect(out['2026-09-27']!.src, 'phone'); + expect( + stampMillis(out['2026-09-27']!.t), + DateTime.parse('2026-09-27T05:30:00Z').millisecondsSinceEpoch, + ); + }); + + test('sessions fill gaps at midnight and lose to the latest field', () { + final out = wakeAlarmCandidates([ + ( + 'phone', + _alarm( + latest: {'date': '2026-09-27', 'kg': 80.4}, + sessions: [ + {'date': '2026-09-27', 'weight_kg': 99}, + {'date': '2026-09-26', 'weight_kg': 81}, + {'date': '2026-09-25', 'weight_kg': null}, + {'date': '2026-09-24', 'weight_kg': 5}, + {'date': 'monday', 'weight_kg': 81}, + 'junk', + ], + ), + ), + ]); + expect(out.keys.toSet(), {'2026-09-27', '2026-09-26'}); + expect(out['2026-09-27']!.kg, 80.4); + expect(out['2026-09-26']!.t, '2026-09-26T00:00:00+00:00'); + }); + + test('the newest stamp per day wins across devices', () { + final out = wakeAlarmCandidates([ + ( + 'a', + _alarm( + latest: {'date': '2026-09-27', 'kg': 80}, + latestHlc: _hlc('2026-09-27T05:00:00Z'), + ), + ), + ( + 'b', + _alarm( + latest: {'date': '2026-09-27', 'kg': 79}, + latestHlc: _hlc('2026-09-27T06:00:00Z'), + ), + ), + ( + 'c', + _alarm( + latest: {'date': '2026-09-27', 'kg': 78}, + latestHlc: _hlc('2026-09-27T04:00:00Z'), + ), + ), + ]); + expect(out['2026-09-27']!.kg, 79); + }); + + test('unusable devices are skipped', () { + final out = wakeAlarmCandidates([ + ('bad-json', '{nope'), + ('not-map', '[]'), + ('no-fields', jsonEncode({'alarm': {}})), + ( + 'bad-hlc', + _alarm(latest: {'date': '2026-09-27', 'kg': 80}, latestHlc: 'x'), + ), + ('bad-sessions', _alarm(sessions: 'x')), + ('bool-kg', _alarm(latest: {'date': '2026-09-27', 'kg': true})), + ]); + expect(out, isEmpty); + }); + + group('ingestWakeAlarmWeights', () { + tearDown(BodyService.resetForTesting); + + test('no-ops when the body service is not initialised', () async { + expect(await ingestWakeAlarmWeights(MemoryRemote()), isFalse); + }); + + test('applies every device\'s weigh-ins', () async { + await BodyService.initForTesting(MemoryDocStore()); + final remote = MemoryRemote({ + '$wakeAlarmDevicesDir/phone-uuid/alarm.json': _alarm( + latest: {'date': '2026-09-27', 'kg': 80.4}, + ), + '$wakeAlarmDevicesDir/other/state.json': '{}', + }); + expect(await ingestWakeAlarmWeights(remote), isTrue); + expect(BodyService.instance.latestWeight, ('2026-09-27', 80.4)); + }); + + test('never throws out of the sync tick', () async { + await BodyService.initForTesting(MemoryDocStore()); + final remote = MemoryRemote()..failWith = RemoteSyncError('offline'); + expect(await ingestWakeAlarmWeights(remote), isFalse); + }); + }); +} diff --git a/app/test/widgets/body/body_widgets_test.dart b/app/test/widgets/body/body_widgets_test.dart new file mode 100644 index 0000000..ef371cd --- /dev/null +++ b/app/test/widgets/body/body_widgets_test.dart @@ -0,0 +1,173 @@ +import 'package:diet_guard_app/services/activity_kcal.dart'; +import 'package:diet_guard_app/services/body_calc.dart'; +import 'package:diet_guard_app/services/week_plan.dart'; +import 'package:diet_guard_app/widgets/body/activity_card.dart'; +import 'package:diet_guard_app/widgets/body/body_stats_cards.dart'; +import 'package:diet_guard_app/widgets/body/week_plan_card.dart'; +import 'package:diet_guard_app/widgets/body/weight_chart.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + + +Future _pump(WidgetTester tester, Widget child) async { + tester.view.physicalSize = const Size(1200, 4000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: SingleChildScrollView(child: child)), + ), + ); + await tester.pumpAndSettle(); +} + +const _short = Biometrics( + weightKg: 50, + heightCm: 150, + ageYears: 17, + isMale: false, +); + +void main() { + group('helpers', () { + test('halfUp rounds half away from banker', () { + expect(halfUp(2.5), 3); + expect(halfUp(0.125, 2), 0.13); + }); + + test('ageOn counts completed years', () { + final birth = DateTime(1995, 3, 1); + expect(ageOn(birth, DateTime(2026, 2, 28)), 30); + expect(ageOn(birth, DateTime(2026, 3, 1)), 31); + expect(ageOn(birth, DateTime(2026, 3, 2)), 31); + expect(ageOn(DateTime(1995, 3, 15), DateTime(2026, 3, 2)), 30); + }); + + test('weekStart is the local Monday', () { + expect(weekStart(DateTime(2026, 10, 4, 18)), DateTime(2026, 9, 28)); + }); + + test('an unknown session kind uses the generic MET', () { + const s = Session( + day: '2026-09-27', + kind: 'yoga', + minutes: 60, + km: null, + label: 'yoga', + source: '', + ); + expect(metFor(s), 4); + }); + + test('seriesInRange keeps the last N days', () { + final series = [('2026-01-01', 80.0), ('2026-09-20', 79.0)]; + final today = DateTime(2026, 9, 27); + expect(seriesInRange(series, 30, today), [('2026-09-20', 79.0)]); + expect(seriesInRange(series, null, today), series); + }); + }); + + testWidgets('a short teenager sees n/a formulas and the age note', ( + tester, + ) async { + await _pump( + tester, + const Column( + children: [BmiCard(bio: _short), IdealWeightCard(bio: _short)], + ), + ); + expect(find.text('n/a'), findsNWidgets(4)); + expect(find.textContaining('undefined below 152.4 cm'), findsOneWidget); + expect(find.text('adult categories do not apply under 20'), findsOneWidget); + }); + + testWidgets('activity card states', (tester) async { + await _pump( + tester, + const ActivityCard(sessions: [], weightKg: null, exercise: null), + ); + expect(find.textContaining('Enter your weight'), findsOneWidget); + await _pump( + tester, + const ActivityCard( + sessions: [], + weightKg: 80, + exercise: {'MET': 0, 'ACSM': 0, 'Per-km': 0}, + ), + ); + expect(find.textContaining('No workouts in the last 28 days'), findsOne); + expect(find.text('0 kcal/day'), findsNWidgets(3)); + var tapped = false; + await _pump( + tester, + ActivityCard( + sessions: null, + weightKg: null, + exercise: null, + steps: const {'2026-09-26': 3000, '2026-09-27': 12000}, + stepsKcal: 20, + onConnectSteps: () => tapped = true, + ), + ); + expect(find.textContaining('No activity data yet'), findsNothing); + expect( + find.textContaining('12000 on 2026-09-27; above 4000/day they add 20'), + findsOneWidget, + ); + await tester.tap(find.text('Connect Health Connect steps')); + expect(tapped, isTrue); + }); + + testWidgets('the chart plots two weigh-ins and switches range', ( + tester, + ) async { + await _pump( + tester, + WeightChart( + series: const [('2026-06-01', 82.0), ('2026-09-20', 79.5)], + today: DateTime(2026, 9, 27), + ), + ); + expect(find.text('Need two readings for a graph.'), findsOneWidget); + await tester.tap(find.text('All')); + await tester.pumpAndSettle(); + expect(find.byType(LineChart), findsOneWidget); + }); + + testWidgets('the week planner fills logged days and honours typing', ( + tester, + ) async { + await _pump( + tester, + WeekPlanCard( + defaultTarget: 2000, + logged: const {'2026-09-28': 2500}, + today: DateTime(2026, 9, 30), + ), + ); + // Mon logged, Tue unlogged: (14000 - 2500) / 6 = 1917. + expect(find.textContaining('2500 kcal (logged)'), findsOneWidget); + expect(find.textContaining('(not logged -- type it)'), findsOneWidget); + expect(find.textContaining('Eat 1917 kcal'), findsOneWidget); + + final dayFields = find.widgetWithText(TextField, 'ate…'); + await tester.enterText(dayFields.at(1), '1800'); + await tester.pumpAndSettle(); + expect(find.textContaining('1800 kcal (typed)'), findsOneWidget); + expect(find.textContaining('Eat 1940 kcal'), findsOneWidget); + + await tester.enterText( + find.widgetWithText(TextField, 'Weekly average target (kcal/day)'), + '900', + ); + await tester.pumpAndSettle(); + expect(find.textContaining('below the 1200 kcal floor'), findsOneWidget); + + for (var i = 0; i < 7; i++) { + await tester.enterText(dayFields.at(i), '2000'); + } + await tester.pumpAndSettle(); + expect(find.textContaining('Every day is known'), findsOneWidget); + }); +} diff --git a/diet_guard/_activity_kcal.py b/diet_guard/_activity_kcal.py new file mode 100644 index 0000000..2690fca --- /dev/null +++ b/diet_guard/_activity_kcal.py @@ -0,0 +1,222 @@ +"""Exercise energy per workout, by every formula the workout data supports. + +Pure arithmetic over :class:`Session` values; where sessions come from is +:mod:`diet_guard._activity_sources`. Mirrored in +``app/lib/services/activity_kcal.dart`` and gated by the shared +``tests/fixtures/body_calc.json``. + +Every formula returns **net** kcal -- energy above resting. The measured +TDEE adds exercise on top of ``BMR x 1.2``, which already pays for the resting +hour, so a gross figure would count it twice. + +* **MET** (Ainsworth 2011 Compendium): ``(MET - 1) x kg x hours``. Running and + walking read MET off the Compendium's speed table (linear between rows); + lifting is code 02052 (resistance training, squats) = 5.0 -- between + 02054 (multiple exercises, 8-15 reps, 3.5) and 02050 (power lifting, + vigorous, 6.0), since a 5x5 session is mostly rest between heavy sets. +* **ACSM** metabolic equations, level ground: VO2 = 0.2 v + 3.5 (running) or + 0.1 v + 3.5 (walking), v in m/min; net kcal = (VO2 - 3.5) x kg x min x 5/1000. +* **Per-km**: running ~0.9 kcal/kg/km net (the classic "1 kcal/kg/km" is + gross), walking ~0.5 kcal/kg/km net. + +ACSM and per-km need a distance, so they only exist for runs and walks that +carry one; every other session falls back to its MET figure (``fallback``) so +all three daily totals cover the same workouts. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +import itertools + +__all__ = [ + "FORMULAS", + "METHODS_NOTE", + "WINDOW_DAYS", + "Session", + "SessionKcal", + "daily_exercise", + "met_for", + "session_kcal", + "window_days", +] + +FORMULAS: tuple[str, ...] = ("MET", "ACSM", "Per-km") +# Shown under the Activity panel on both surfaces. +METHODS_NOTE = ( + "MET = standard activity values x your weight x time. " + "ACSM = American College of Sports Medicine running/walking equation from " + "your speed. Per-km = ~0.9 kcal per kg per km running, ~0.5 walking. " + "All are energy above resting." +) +# Measured activity averages the last two full weeks. +WINDOW_DAYS = 14 + +_RUN_MET_BY_KMH: tuple[tuple[float, float], ...] = ( + (6.4, 6.0), + (8.0, 8.3), + (8.4, 9.0), + (9.7, 9.8), + (10.8, 10.5), + (11.3, 11.0), + (12.1, 11.5), + (12.9, 11.8), + (13.8, 12.3), + (14.5, 12.8), + (16.1, 14.5), + (17.7, 16.0), + (19.3, 19.0), + (20.9, 19.8), + (22.5, 23.0), +) +_WALK_MET_BY_KMH: tuple[tuple[float, float], ...] = ( + (3.2, 2.8), + (4.0, 3.0), + (4.8, 3.5), + (5.6, 4.3), + (6.4, 5.0), + (7.2, 7.0), + (8.0, 8.3), +) +# Compendium codes: 02052 resistance training, 12150 running general, +# 17200 walking general-ish, 01015 bicycling general, generic moderate. +_FIXED_MET = {"strength": 5.0, "run": 8.0, "walk": 3.5, "cycle": 7.5, "other": 4.0} +_ACSM_SLOPE = {"run": 0.2, "walk": 0.1} +_NET_KCAL_PER_KG_KM = {"run": 0.9, "walk": 0.5} +_KCAL_PER_LITRE_O2 = 5.0 + + +@dataclass(frozen=True) +class Session: + """One workout, reduced to what the energy formulas need. + + Attributes: + day: Local ``YYYY-MM-DD`` the workout happened on. + kind: ``strength``, ``run``, ``walk``, ``cycle`` or ``other``. + minutes: Duration. + km: Distance, or None when the source has none. + label: Human description for the list. + source: Where it came from (``runnerup``, ``screen-locker``). + start: Local ISO start time, when the source knows it. + end: Local ISO end time, when the source knows it. The phone drops + Health Connect steps inside ``start..end`` so a walk is not + counted as both a workout and steps. + """ + + day: str + kind: str + minutes: float + km: float | None + label: str + source: str + start: str | None = None + end: str | None = None + + +@dataclass(frozen=True) +class SessionKcal: + """Net kcal of one session per formula; ``fallback`` names MET stand-ins.""" + + session: Session + kcal: dict[str, float] + fallback: frozenset[str] + + +def _interpolate(table: tuple[tuple[float, float], ...], kmh: float) -> float: + """Linear interpolation of MET at ``kmh``, clamped to the table's ends.""" + if kmh <= table[0][0]: + return table[0][1] + for (x0, y0), (x1, y1) in itertools.pairwise(table): + if kmh <= x1: + return y0 + (y1 - y0) * (kmh - x0) / (x1 - x0) + return table[-1][1] + + +def _speed_kmh(session: Session) -> float | None: + """Average speed, or None without a usable distance and duration.""" + if session.km is None or session.km <= 0 or session.minutes <= 0: + return None + return session.km / (session.minutes / 60.0) + + +def met_for(session: Session) -> float: + """The MET value for ``session``: speed-based when possible, else fixed.""" + kmh = _speed_kmh(session) + if kmh is not None and session.kind == "run": + return _interpolate(_RUN_MET_BY_KMH, kmh) + if kmh is not None and session.kind == "walk": + return _interpolate(_WALK_MET_BY_KMH, kmh) + return _FIXED_MET.get(session.kind, _FIXED_MET["other"]) + + +def session_kcal(session: Session, weight_kg: float) -> SessionKcal: + """Net kcal of ``session`` for a ``weight_kg`` person, by every formula.""" + hours = session.minutes / 60.0 + met = (met_for(session) - 1.0) * weight_kg * hours + kcal = {"MET": met, "ACSM": met, "Per-km": met} + fallback = {"ACSM", "Per-km"} + kmh = _speed_kmh(session) + if kmh is not None and session.km is not None and session.kind in _ACSM_SLOPE: + metres_per_min = kmh * 1000.0 / 60.0 + net_vo2 = _ACSM_SLOPE[session.kind] * metres_per_min + kcal["ACSM"] = net_vo2 * weight_kg * session.minutes * _KCAL_PER_LITRE_O2 / 1000 + kcal["Per-km"] = _NET_KCAL_PER_KG_KM[session.kind] * weight_kg * session.km + fallback.clear() + return SessionKcal(session=session, kcal=kcal, fallback=frozenset(fallback)) + + +def daily_exercise( + sessions: tuple[Session, ...], + weight_kg: float, + today: date, + extra_by_day: dict[str, float] | None = None, +) -> dict[str, float]: + """Mean net exercise kcal per day over the last :data:`WINDOW_DAYS`. + + Only full past days count: today is still in progress, so the window + ends yesterday. It starts :data:`WINDOW_DAYS` days back -- or, while the + data is younger than that, on the first day that has any, so a week of + history is averaged over that week rather than diluted over 14 days. The + denominator is calendar days within that span: a rest day burns no + exercise kcal, which is exactly what a daily average must reflect. With + no past data at all, every formula reads 0. + + ``extra_by_day`` (day -> net kcal, e.g. steps outside workouts) is added + to every formula alike: it is not a workout any formula could measure. + """ + extra = extra_by_day or {} + span = window_days(sessions, today, extra) + totals = dict.fromkeys(FORMULAS, 0.0) + if not span: + return totals + first = (today - timedelta(days=span)).isoformat() + last = today.isoformat() + for session in sessions: + if first <= session.day < last: + for name, value in session_kcal(session, weight_kg).kcal.items(): + totals[name] += value + for day, kcal in extra.items(): + if first <= day < last: + for name in totals: + totals[name] += kcal + return {name: total / span for name, total in totals.items()} + + +def window_days( + sessions: tuple[Session, ...], + today: date, + extra_by_day: dict[str, float] | None = None, +) -> int: + """How many full past days :func:`daily_exercise` averages over. + + :data:`WINDOW_DAYS`, or fewer while the earliest past data (a session or + an ``extra_by_day`` key before ``today``) is younger than that; 0 when + there is no past data at all. + """ + last = today.isoformat() + days = [s.day for s in sessions if s.day < last] + days += [d for d in (extra_by_day or {}) if d < last] + if not days: + return 0 + return min((today - date.fromisoformat(min(days))).days, WINDOW_DAYS) diff --git a/diet_guard/_activity_sources.py b/diet_guard/_activity_sources.py new file mode 100644 index 0000000..82532c2 --- /dev/null +++ b/diet_guard/_activity_sources.py @@ -0,0 +1,205 @@ +"""Collect the last weeks' workouts from RunnerUp and screen-locker. + +PC-only: both sources live on this machine. The result is published into +the Body document (``_body_store.set_activity``) so the phone shows the same +sessions. Dedup rules, so one workout counts once (``docs/DOCS-body.md``): + +* RunnerUp TCX files are the source for runs; screen-locker's + ``runnerup_verified`` entry for a day is used only when no TCX exists for it. +* ``phone_verified`` is the phone's report of the same StrongLifts session the + PC logs as ``pc_workout_verified``; it counts only on a day without one. + +Both sources are optional and read defensively: a missing ``screen_locker`` +package, an unreadable log, or a malformed TCX drops that source (or file), +never the tick. +""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta +from importlib import import_module +import logging +from pathlib import Path +import re +from xml.etree.ElementTree import ParseError + +from defusedxml.ElementTree import parse as parse_xml + +from diet_guard._activity_kcal import Session +from diet_guard._manual_workout_parse import logged_window, manual_session + +_logger = logging.getLogger(__name__) + +RUNNERUP_DIRS: tuple[Path, ...] = ( + Path.home() / "data/cloud/RunnerUp", + Path.home() / "data/cloud/RunnerUp/processed", +) +WINDOW_DAYS = 28 +_TCX_NS = {"t": "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"} +# "Auto-scanned: Running: 5.6 km in 60 min" -- the sport sits between colons. +_AUTO_SCAN_COLONS = 2 +_MINUTES_RE = re.compile(r"\((\d+(?:\.\d+)?) min") +_SPORT_KINDS = { + "running": "run", + "run": "run", + "walking": "walk", + "walk": "walk", + "hiking": "walk", + "biking": "cycle", + "cycling": "cycle", + "stationary bike": "cycle", + "strength": "strength", + "gym": "strength", + "weights": "strength", +} + + +def _kind(name: object) -> str: + """Map a sport/activity name onto a :class:`Session` kind.""" + return _SPORT_KINDS.get(str(name).strip().lower(), "other") + + +def _tcx_session(path: Path) -> tuple[str, Session] | None: + """``(activity id, session)`` from one TCX file, or None if unusable.""" + try: + root = parse_xml(path).getroot() + except (ParseError, OSError) as exc: + _logger.warning("Unreadable TCX %s (%s); skipped", path, exc) + return None + activity = root.find("t:Activities/t:Activity", _TCX_NS) + ident = activity.findtext("t:Id", "", _TCX_NS) if activity is not None else "" + if activity is None or not ident: + return None + seconds = metres = 0.0 + for lap in activity.findall("t:Lap", _TCX_NS): + seconds += float(lap.findtext("t:TotalTimeSeconds", "0", _TCX_NS)) + metres += float(lap.findtext("t:DistanceMeters", "0", _TCX_NS)) + started = datetime.fromisoformat(ident).astimezone() + sport = activity.get("Sport", "Other") + session = Session( + day=started.date().isoformat(), + kind=_kind(sport), + minutes=seconds / 60.0, + km=metres / 1000.0 if metres > 0 else None, + label=f"{sport} {metres / 1000:.1f} km", + source="runnerup", + start=started.isoformat(timespec="seconds"), + end=(started + timedelta(seconds=seconds)).isoformat(timespec="seconds"), + ) + return ident, session + + +def runnerup_sessions(since: str) -> list[Session]: + """Every TCX session on or after ``since``, once per activity id.""" + found: dict[str, Session] = {} + for folder in RUNNERUP_DIRS: + for path in sorted(folder.glob("*.tcx")) if folder.is_dir() else []: + parsed = _tcx_session(path) + if parsed is not None and parsed[1].day >= since: + found.setdefault(parsed[0], parsed[1]) + return sorted(found.values(), key=lambda s: s.day) + + +def screen_locker_log_file() -> Path | None: + """screen-locker's ``log.json``, or None when the package is absent.""" + try: + package = import_module("screen_locker") + except ImportError: + return None + path = Path(str(package.__file__)).parent / "log.json" + return path if path.is_file() else None + + +def _minutes(data: dict[str, object]) -> float | None: + """A workout's duration from ``duration_minutes`` or its ``(N min`` text.""" + raw = data.get("duration_minutes") + if raw is not None: + try: + return float(str(raw)) + except ValueError: + pass + # phone_verified entries carry no duration field, only "(86 min" prose. + match = _MINUTES_RE.search(str(data.get("source", ""))) + return float(match.group(1)) if match else None + + +def _km(data: dict[str, object]) -> float | None: + """``distance_km`` when present and numeric.""" + try: + return float(str(data["distance_km"])) + except KeyError, ValueError: + return None + + +def _day_sessions( + day: str, entries: list[dict[str, object]], tcx_days: set[str] +) -> list[Session]: + """The sessions one screen-locker day contributes, after dedup.""" + pairs = [(e, e.get("workout_data")) for e in entries] + items = [(e, d) for e, d in pairs if isinstance(d, dict)] + types = {d.get("type") for _, d in items} + out: list[Session] = [] + for entry, item in items: + kind_type = item.get("type") + minutes = _minutes(item) + if minutes is None or minutes <= 0: + continue + label = str(item.get("source", kind_type)) + if kind_type == "pc_workout_verified" or ( + kind_type == "phone_verified" and "pc_workout_verified" not in types + ): + start, end = logged_window(entry.get("timestamp"), minutes) + out.append( + Session( + day, "strength", minutes, None, label, "screen-locker", start, end + ) + ) + elif kind_type == "manual_workout": + out.append(manual_session(day, item, minutes, label)) + elif kind_type == "runnerup_verified" and day not in tcx_days: + sport = ( + label.split(":")[1] + if label.count(":") >= _AUTO_SCAN_COLONS + else "Running" + ) + out.append( + Session(day, _kind(sport), minutes, _km(item), label, "screen-locker") + ) + return out + + +def screen_locker_sessions(since: str, tcx_days: set[str]) -> list[Session]: + """Sessions from screen-locker's log on or after ``since``; [] if absent.""" + path = screen_locker_log_file() + if path is None: + return [] + try: + log_io = import_module("screen_locker._log_io") + log = log_io.load_workout_log(path) + except (ImportError, AttributeError) as exc: + _logger.warning("screen-locker log unreadable (%s); skipped", exc) + return [] + sessions: list[Session] = [] + for day in sorted(log): + if day >= since: + sessions.extend(_day_sessions(day, log[day], tcx_days)) + return sessions + + +def collect_sessions(today: date) -> tuple[Session, ...]: + """Every workout in the last :data:`WINDOW_DAYS`, deduplicated, by day.""" + since = (today - timedelta(days=WINDOW_DAYS)).isoformat() + runs = runnerup_sessions(since) + others = screen_locker_sessions(since, {s.day for s in runs}) + return tuple(sorted(runs + others, key=lambda s: (s.day, s.kind, s.minutes))) + + +def sources_available() -> bool: + """Whether this machine has any workout source at all. + + Guards publishing: a device with neither RunnerUp's folder nor + screen-locker would otherwise publish an empty list over the real one. + """ + return any(folder.is_dir() for folder in RUNNERUP_DIRS) or ( + screen_locker_log_file() is not None + ) diff --git a/diet_guard/_body_activity_store.py b/diet_guard/_body_activity_store.py new file mode 100644 index 0000000..e4f4f4b --- /dev/null +++ b/diet_guard/_body_activity_store.py @@ -0,0 +1,87 @@ +"""The Body document's workout half: published sessions, on disk and back. + +Split out of :mod:`diet_guard._body_store` for the 250-line cap. The PC +writes these (:func:`set_activity`, from ``_sync_body.publish_activity``); +every device reads them. Reads are tolerant like the rest of the document. + +KEEP IN SYNC WITH ``app/lib/services/body_service.dart``. +""" + +from __future__ import annotations + +from dataclasses import asdict +from datetime import datetime + +from diet_guard._activity_kcal import Session +from diet_guard._body_store import as_number, now_stamp, read_body, write_body + +__all__ = ["activity", "session_from_json", "session_to_json", "set_activity"] + + +def session_to_json(session: Session) -> dict[str, object]: + """The wire/disk form of one session (short keys, see the docs).""" + raw = asdict(session) + return { + "day": raw["day"], + "kind": raw["kind"], + "min": raw["minutes"], + "km": raw["km"], + "label": raw["label"], + "src": raw["source"], + "start": raw["start"], + "end": raw["end"], + } + + +def _stamp_or_none(value: object) -> str | None: + """An ISO time string, or None when absent or unparsable.""" + if not isinstance(value, str): + return None + try: + datetime.fromisoformat(value) + except ValueError: + return None + return value + + +def session_from_json(raw: object) -> Session | None: + """Parse one stored session, or None when malformed.""" + if not isinstance(raw, dict): + return None + day, kind, km_raw = raw.get("day"), raw.get("kind"), raw.get("km") + minutes, km = as_number(raw.get("min")), as_number(km_raw) + km_ok = km_raw is None or km is not None + if not (isinstance(day, str) and isinstance(kind, str) and minutes and km_ok): + return None + return Session( + day=day, + kind=kind, + minutes=minutes, + km=km, + label=str(raw.get("label", "")), + source=str(raw.get("src", "")), + start=_stamp_or_none(raw.get("start")), + end=_stamp_or_none(raw.get("end")), + ) + + +def activity() -> tuple[tuple[Session, ...], str | None]: + """The published sessions and their stamp; ``((), None)`` when none.""" + section = read_body()["activity"] + stamp = section.get("t") + raw = section.get("sessions") + if not isinstance(raw, list) or not isinstance(stamp, str): + return (), None + parsed = (session_from_json(item) for item in raw) + return tuple(s for s in parsed if s is not None), stamp + + +def set_activity(sessions: tuple[Session, ...]) -> bool: + """Store ``sessions`` (stamped now) if they differ; True when written.""" + doc = read_body() + payload = [session_to_json(s) for s in sessions] + if doc["activity"].get("sessions") == payload: + return False + doc["activity"] = {"sessions": payload, "t": now_stamp()} + write_body(doc) + return True diff --git a/diet_guard/_body_calc.py b/diet_guard/_body_calc.py new file mode 100644 index 0000000..0fc2858 --- /dev/null +++ b/diet_guard/_body_calc.py @@ -0,0 +1,221 @@ +"""BMI, ideal-weight and resting-metabolism formulas -- pure arithmetic. + +Every formula the Body tab shows lives here (and, byte-for-byte, in +``app/lib/services/body_calc.dart``). Nothing here reads a file or a clock: +the caller supplies the person's :class:`~._budget_biometrics.Biometrics`. + +Both halves are gated by one shared fixture, ``tests/fixtures/body_calc.json`` +(regenerate with ``scripts/build_body_fixture.py``). Rounding goes through +:func:`half_up`, never ``round``: Python's is banker's and Dart's is +half-away-from-zero, so a bare ``round`` would print different numbers on the +two devices for the same person. + +Katch-McArdle and Cunningham need a logged body-fat %; until one exists they +come back as ``None`` so the screens can say what is missing. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math + +from diet_guard._budget_biometrics import Biometrics, mifflin_st_jeor_bmr + +__all__ = [ + "BMI_PRIME_NOTE", + "BROCA_NOTE", + "KATCH_MCARDLE", + "TREFETHEN_NOTE", + "BmiReport", + "Formula", + "bmi_report", + "bmr_formulas", + "half_up", + "healthy_range", + "ideal_summary", + "ideal_weights", + "table_bmr", +] + +_CM_PER_INCH = 2.54 +# The inch-based formulas (Devine, Robinson, Miller, Hamwi) are all +# "base + k per inch over five feet"; below five feet they are undefined. +_FIVE_FEET_IN = 60.0 +# Adult BMI categories are WHO's and do not apply to anyone still growing. +ADULT_AGE = 20 +# BMI Prime divides by the top of the normal range. +_BMI_PRIME_DIVISOR = 25.0 +_HEALTHY_BMI_LOW = 18.5 +_HEALTHY_BMI_HIGH = 24.9 +_TARGET_BMI = 22.0 + +# (upper bound exclusive, label), WHO adult classification. +_BMI_BANDS: tuple[tuple[float, str], ...] = ( + (16.0, "severe thinness"), + (17.0, "moderate thinness"), + (18.5, "mild thinness"), + (25.0, "normal"), + (30.0, "overweight"), + (35.0, "obese class I"), + (40.0, "obese class II"), +) +_TOP_BAND = "obese class III" + +# Plain-language readings, shown under the numbers on both surfaces. +BMI_PRIME_NOTE = ( + "BMI Prime = BMI / 25: 1.00 is the top of the healthy range, " + "so 1.02 means 2% above it." +) +TREFETHEN_NOTE = ( + "Trefethen's new BMI = 1.3 x kg / m^2.5: corrects classic BMI, which " + "over-reads tall people and under-reads short ones." +) +KATCH_MCARDLE = "Katch-McArdle" +BROCA_NOTE = ( + "Broca = height in cm - 100 (1871); it overshoots, so Broca (modified) " + "takes 10% off it for men and 15% for women." +) + + +def half_up(value: float, digits: int = 0) -> float: + """Round ``value`` half-up at ``digits`` decimals, identically to Dart.""" + scale = 10.0**digits + return math.floor(value * scale + 0.5) / scale + + +@dataclass(frozen=True) +class Formula: + """One named formula's result; ``value`` is None when it does not apply.""" + + name: str + value: float | None + + +@dataclass(frozen=True) +class BmiReport: + """Body-mass index and its readings. + + Attributes: + bmi: Classic BMI, kg / m^2. + prime: BMI divided by 25 (1.0 = top of the normal range). + trefethen: Trefethen's "new BMI", 1.3 * kg / m^2.5. + category: WHO adult band, or a note that it does not apply (< 20 y). + """ + + bmi: float + prime: float + trefethen: float + category: str + + +def _category(bmi: float, age_years: float) -> str: + """WHO adult band for ``bmi``; under-20s get a note instead.""" + if age_years < ADULT_AGE: + return "adult categories do not apply under 20" + for upper, label in _BMI_BANDS: + if bmi < upper: + return label + return _TOP_BAND + + +def bmi_report(bio: Biometrics) -> BmiReport: + """Return BMI, BMI Prime, Trefethen's BMI and the WHO category.""" + metres = bio.height_cm / 100.0 + bmi = bio.weight_kg / (metres * metres) + return BmiReport( + bmi=bmi, + prime=bmi / _BMI_PRIME_DIVISOR, + trefethen=1.3 * bio.weight_kg / metres**2.5, + category=_category(bmi, bio.age_years), + ) + + +def _per_inch( + inches: float, base: float, per_inch: float, *, defined: bool +) -> float | None: + """``base + per_inch * inches``, or None below five feet.""" + return base + per_inch * inches if defined else None + + +def ideal_weights(bio: Biometrics) -> tuple[Formula, ...]: + """Every ideal-body-weight formula we can evaluate, in kg. + + Only Creff uses age -- it is the one that answers "ideal weight for my age + and height". The BMI healthy range is :func:`healthy_range`, shown with + BMI rather than here; :func:`ideal_summary` averages these. + """ + h = bio.height_cm + inches = h / _CM_PER_INCH - _FIVE_FEET_IN + ok = inches >= 0 + male = bio.is_male + metres = h / 100.0 + return ( + Formula("Devine", _per_inch(inches, 50.0 if male else 45.5, 2.3, defined=ok)), + Formula( + "Robinson", + _per_inch(inches, 52.0 if male else 49.0, 1.9 if male else 1.7, defined=ok), + ), + Formula( + "Miller", + _per_inch( + inches, 56.2 if male else 53.1, 1.41 if male else 1.36, defined=ok + ), + ), + Formula( + "Hamwi", + _per_inch(inches, 48.0 if male else 45.5, 2.7 if male else 2.2, defined=ok), + ), + Formula("Broca", h - 100.0), + Formula("Broca (modified)", (h - 100.0) * (0.9 if male else 0.85)), + Formula("Lorentz", h - 100.0 - (h - 150.0) / (4.0 if male else 2.0)), + Formula("Creff (age)", (h - 100.0 + bio.age_years / 10.0) * 0.9), + Formula( + "Peterson (BMI 22)", 2.2 * _TARGET_BMI + 3.5 * _TARGET_BMI * (metres - 1.5) + ), + ) + + +def healthy_range(bio: Biometrics) -> tuple[float, float]: + """The weights (kg) at BMI 18.5 and 24.9 for this height.""" + metres = bio.height_cm / 100.0 + return _HEALTHY_BMI_LOW * metres * metres, _HEALTHY_BMI_HIGH * metres * metres + + +def ideal_summary(formulas: tuple[Formula, ...]) -> tuple[float, float, float] | None: + """``(average, lowest, highest)`` of the formulas that apply, or None.""" + values = [f.value for f in formulas if f.value is not None] + if not values: + return None + return sum(values) / len(values), min(values), max(values) + + +def bmr_formulas( + bio: Biometrics, body_fat_pct: float | None = None +) -> tuple[Formula, ...]: + """Resting metabolic rate by every formula, kcal/day. + + Katch-McArdle and Cunningham work from lean mass, so they are None until + a body-fat % is logged. Once one is, Katch-McArdle drives the calorie + table (:func:`table_bmr`) -- it tracks muscle, which the weight-only + formulas cannot. + """ + w, h, a = bio.weight_kg, bio.height_cm, bio.age_years + if bio.is_male: + revised = 88.362 + 13.397 * w + 4.799 * h - 5.677 * a + else: + revised = 447.593 + 9.247 * w + 3.098 * h - 4.330 * a + lean = None if body_fat_pct is None else w * (1.0 - body_fat_pct / 100.0) + return ( + Formula("Mifflin-St Jeor", mifflin_st_jeor_bmr(bio)), + Formula("Harris-Benedict (revised 1984)", revised), + Formula(KATCH_MCARDLE, None if lean is None else 370.0 + 21.6 * lean), + Formula("Cunningham", None if lean is None else 500.0 + 22.0 * lean), + ) + + +def table_bmr(bio: Biometrics, body_fat_pct: float | None) -> Formula: + """The BMR the calorie table uses: Katch-McArdle with body fat, else Mifflin.""" + formulas = {f.name: f for f in bmr_formulas(bio, body_fat_pct)} + if body_fat_pct is not None: + return formulas[KATCH_MCARDLE] + return formulas["Mifflin-St Jeor"] diff --git a/diet_guard/_body_energy.py b/diet_guard/_body_energy.py new file mode 100644 index 0000000..5ad3579 --- /dev/null +++ b/diet_guard/_body_energy.py @@ -0,0 +1,164 @@ +"""The daily calorie target for one chosen goal -- the Body tab's top line. + +"To **lose** **0.5** **kg** / **week** on **Moderate** eat **2019** kcal": +every bold word is a choice (:class:`Goal`), remembered and synced +(``_body_prefs``). Informational only: nothing here writes the budget. + +TDEE is the table BMR (Mifflin-St Jeor, or Katch-McArdle once a body-fat % is +logged -- ``_body_calc.table_bmr``) times an activity factor, or for a +"Measured" choice ``BMR x 1.2`` plus the real mean daily exercise from +:func:`diet_guard._activity_kcal.daily_exercise` by that formula. The target +is ``TDEE -/+ (amount in kg per day) x 7700``. A target below the 1200 kcal +floor is reported and flagged, never clamped. + +Mirrored in ``app/lib/services/body_energy.dart``; gated by the shared +``tests/fixtures/body_calc.json``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from diet_guard._activity_kcal import FORMULAS +from diet_guard._body_calc import half_up +from diet_guard._budget_biometrics import _MIN_SANE_BUDGET + +__all__ = [ + "ACTIVITY_CHOICES", + "ACTIVITY_LEVELS", + "DEFAULT_GOAL", + "DIRECTIONS", + "KCAL_PER_KG", + "MEASURED_PREFIX", + "MIN_TARGET_KCAL", + "PRESET_AMOUNTS", + "TIME_UNITS", + "WEIGHT_UNITS", + "ActivityLevel", + "Goal", + "Target", + "activity_label", + "goal_target", +] + +KCAL_PER_KG = 7700.0 +MIN_TARGET_KCAL = _MIN_SANE_BUDGET +_SEDENTARY = 1.2 +# A "Measured" choice is ``measured:`` (MET, ACSM, Per-km). +MEASURED_PREFIX = "measured:" + +DIRECTIONS: tuple[str, ...] = ("lose", "maintain", "gain") +# Offered in the amount picker; any other typed number is accepted too. +PRESET_AMOUNTS: tuple[float, ...] = (0.25, 0.5, 0.75, 1.0) +# Kilograms per unit. +WEIGHT_UNITS: dict[str, float] = { + "kg": 1.0, + "lb": 0.45359237, + "g": 0.001, + "st": 6.35029318, +} +# Days per unit; a month is the mean Gregorian month. +TIME_UNITS: dict[str, float] = { + "day": 1.0, + "week": 7.0, + "month": 30.436875, + "year": 365.2425, +} + + +@dataclass(frozen=True) +class ActivityLevel: + """One selectable activity level and its TDEE multiplier.""" + + key: str + label: str + factor: float + meaning: str + + +ACTIVITY_LEVELS: tuple[ActivityLevel, ...] = ( + ActivityLevel("sedentary", "Sedentary", 1.2, "little or no exercise"), + ActivityLevel("light", "Light", 1.375, "exercise 1-3x a week"), + ActivityLevel("moderate", "Moderate", 1.55, "exercise 3-5x a week"), + ActivityLevel("very", "Very active", 1.725, "exercise 6-7x a week"), + ActivityLevel("extra", "Extra active", 1.9, "training twice a day"), +) +# Every activity choice, in menu order: the five levels, then measured. +ACTIVITY_CHOICES: tuple[str, ...] = ( + *(lvl.key for lvl in ACTIVITY_LEVELS), + *(f"{MEASURED_PREFIX}{name}" for name in FORMULAS), +) + + +@dataclass(frozen=True) +class Goal: + """What the user wants: direction, amount per time, at an activity.""" + + direction: str + amount: float + weight_unit: str + time_unit: str + activity: str + + def kg_per_day(self) -> float: + """Signed change in kg/day: positive loses, negative gains.""" + if self.direction == "maintain": + return 0.0 + rate = self.amount * WEIGHT_UNITS[self.weight_unit] / TIME_UNITS[self.time_unit] + return rate if self.direction == "lose" else -rate + + +DEFAULT_GOAL = Goal("lose", 0.5, "kg", "week", "moderate") + + +@dataclass(frozen=True) +class Target: + """The answer to a :class:`Goal`, or why there is none. + + Attributes: + kcal: Daily target, or None when the goal needs measured activity + that has not been published yet. + tdee: The maintenance figure it was derived from, when known. + bmr_name: The resting-burn formula the TDEE scales. + """ + + kcal: int | None + tdee: float | None + bmr_name: str + + @property + def below_floor(self) -> bool: + """Whether the target is under the 1200 kcal floor.""" + return self.kcal is not None and self.kcal < MIN_TARGET_KCAL + + +def activity_label(key: str) -> str: + """Menu text for an activity choice, e.g. ``Measured (ACSM)``.""" + if key.startswith(MEASURED_PREFIX): + return f"Measured ({key[len(MEASURED_PREFIX) :]})" + for lvl in ACTIVITY_LEVELS: + if lvl.key == key: + return lvl.label + return key + + +def _tdee(bmr: float, activity: str, exercise: dict[str, float] | None) -> float | None: + """Maintenance kcal for ``activity``; None for measured without data.""" + if activity.startswith(MEASURED_PREFIX): + formula = activity[len(MEASURED_PREFIX) :] + if exercise is None or formula not in exercise: + return None + return bmr * _SEDENTARY + exercise[formula] + factor = next((lvl.factor for lvl in ACTIVITY_LEVELS if lvl.key == activity), None) + return None if factor is None else bmr * factor + + +def goal_target( + goal: Goal, bmr: float, bmr_name: str, exercise: dict[str, float] | None +) -> Target: + """The daily kcal that meets ``goal`` from a ``bmr`` (kcal/day).""" + tdee = _tdee(bmr, goal.activity, exercise) + if tdee is None: + return Target(kcal=None, tdee=None, bmr_name=bmr_name) + kcal = int(half_up(tdee - goal.kg_per_day() * KCAL_PER_KG)) + return Target(kcal=kcal, tdee=tdee, bmr_name=bmr_name) diff --git a/diet_guard/_body_fat_store.py b/diet_guard/_body_fat_store.py new file mode 100644 index 0000000..7974ad2 --- /dev/null +++ b/diet_guard/_body_fat_store.py @@ -0,0 +1,50 @@ +"""The Body document's body-fat log: one percentage per date, like weight. + +Split out of :mod:`diet_guard._body_store` for the 250-line cap. The newest +live entry switches the calorie table's base BMR to Katch-McArdle +(``_body_calc.table_bmr``); older ones draw the body-fat graph. + +KEEP IN SYNC WITH ``app/lib/services/body_service.dart``. +""" + +from __future__ import annotations + +from diet_guard._body_store import as_number, is_day, now_stamp, read_body, write_body + +__all__ = [ + "MAX_BODY_FAT", + "MIN_BODY_FAT", + "body_fats", + "newest_body_fat", + "set_body_fat", +] + +# Outside this range (%) a reading is a typo, not a measurement. +MIN_BODY_FAT = 3.0 +MAX_BODY_FAT = 70.0 + + +def body_fats() -> dict[str, float]: + """``YYYY-MM-DD`` -> body fat % for every live day, oldest first.""" + log = read_body()["bodyfat"] + live = { + day: as_number(cell.get("pct")) if isinstance(cell, dict) else None + for day, cell in log.items() + } + return { + day: pct for day, pct in sorted(live.items()) if pct is not None and is_day(day) + } + + +def newest_body_fat() -> float | None: + """The latest-dated live reading, or None when none is logged.""" + live = body_fats() + return live[max(live)] if live else None + + +def set_body_fat(day: str, pct: float | None) -> None: + """Record (or, with ``pct=None``, delete) ``day``'s body fat, stamped now.""" + doc = read_body() + value = None if pct is None else round(float(pct), 1) + doc["bodyfat"][day] = {"pct": value, "t": now_stamp()} + write_body(doc) diff --git a/diet_guard/_body_ingest.py b/diet_guard/_body_ingest.py new file mode 100644 index 0000000..20452d2 --- /dev/null +++ b/diet_guard/_body_ingest.py @@ -0,0 +1,123 @@ +"""Phone weigh-ins (wake-alarm's morning session) -> the Body weight log. + +The wake-alarm phone app publishes ``latest_weight_kg`` and a short +``morning_sessions`` list on its own ``alarm.json`` in the shared Firebase +project. Reading it through diet_guard's *own* sync client -- paths are +absolute there -- means no import of ``wake_alarm`` and no second sign-in. + +Stamping rules (``docs/DOCS-body.md``): ``latest_weight_kg`` carries its +field's Hlc time, so a re-typed weigh-in wins; ``morning_sessions`` entries +are stamped midnight UTC of their date and only fill gaps, because that list +is republished every morning with a fresh Hlc and would otherwise overwrite +manual corrections daily. + +KEEP IN SYNC WITH ``app/lib/services/wake_alarm_weights.dart``. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +import json +import logging +from typing import TYPE_CHECKING + +from crdt_sync import Hlc, MirrorSyncClient, RemoteSyncError + +from diet_guard._body_store import MAX_KG, MIN_KG, WeightCandidate, stamp_ms + +if TYPE_CHECKING: + from crdt_sync import RemoteStore + +_logger = logging.getLogger(__name__) + +WAKE_ALARM_DEVICES_DIR = "wake-alarm-sync/devices" +_LATEST_FIELD = "latest_weight_kg" +_SESSIONS_FIELD = "morning_sessions" +# A crdt_sync field on the wire is ``[value, hlc]``. +_PAIR_LEN = 2 + + +def _pair(log: object, field: str) -> tuple[object, str]: + """``(value, hlc)`` of one field on the ``alarm`` record, or ``(None, "")``.""" + record = log.get("alarm") if isinstance(log, dict) else None + fields = record.get("fields") if isinstance(record, dict) else None + pair = fields.get(field) if isinstance(fields, dict) else None + if not isinstance(pair, list) or len(pair) < _PAIR_LEN: + return None, "" + return pair[0], str(pair[1]) + + +def _kg(value: object) -> float | None: + """A sane body weight, or None.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + kg = float(value) + return kg if MIN_KG <= kg <= MAX_KG else None + + +def _hlc_stamp(hlc: str) -> str | None: + """The ISO instant an Hlc string was minted at, or None if unparsable.""" + try: + wall = Hlc.from_str(hlc).wall_time_ms + except ValueError: + return None + return datetime.fromtimestamp(wall / 1000, tz=UTC).isoformat(timespec="seconds") + + +def _from_log(log: object) -> list[WeightCandidate]: + """Every weigh-in one device's alarm log offers.""" + found: list[WeightCandidate] = [] + sessions, _ = _pair(log, _SESSIONS_FIELD) + for entry in sessions if isinstance(sessions, list) else []: + day = entry.get("date") if isinstance(entry, dict) else None + kg = _kg(entry.get("weight_kg")) if isinstance(entry, dict) else None + if isinstance(day, str) and kg is not None: + found.append(WeightCandidate(day, kg, "phone", f"{day}T00:00:00+00:00")) + latest, hlc = _pair(log, _LATEST_FIELD) + day = latest.get("date") if isinstance(latest, dict) else None + kg = _kg(latest.get("kg")) if isinstance(latest, dict) else None + stamp = _hlc_stamp(hlc) + if isinstance(day, str) and kg is not None and stamp is not None: + found.append(WeightCandidate(day, kg, "phone", stamp)) + return found + + +def weight_candidates(published: list[tuple[str, str]]) -> list[WeightCandidate]: + """One candidate per date -- the newest-stamped -- across every device. + + Args: + published: ``(device_id, raw alarm.json)`` per wake-alarm device. + """ + best: dict[str, WeightCandidate] = {} + for _, raw in published: + try: + log = json.loads(raw) + except json.JSONDecodeError: + continue + for cand in _from_log(log): + held = best.get(cand.day) + if held is None or (stamp_ms(cand.t), cand.kg) > ( + stamp_ms(held.t), + held.kg, + ): + best[cand.day] = cand + return [best[day] for day in sorted(best)] + + +def fetch_alarm_logs(client: RemoteStore) -> list[tuple[str, str]]: + """Every wake-alarm device's raw ``alarm.json``; ``[]`` on any failure. + + Firebase-only (the primary of a mirror client): wake-alarm never + published weigh-ins to the GitHub mirror. + """ + store = client.primary if isinstance(client, MirrorSyncClient) else client + try: + published = [] + for device in store.list_directory(WAKE_ALARM_DEVICES_DIR): + text = store.get_file_text(f"{WAKE_ALARM_DEVICES_DIR}/{device}/alarm.json") + if text is not None: + published.append((device, text)) + except RemoteSyncError as exc: + _logger.info("wake-alarm weigh-ins unreadable (%s); skipped", exc) + return [] + return published diff --git a/diet_guard/_body_prefs.py b/diet_guard/_body_prefs.py new file mode 100644 index 0000000..82d76e0 --- /dev/null +++ b/diet_guard/_body_prefs.py @@ -0,0 +1,69 @@ +"""The calorie goal the user last picked -- remembered and synced. + +Stored as one extra field, ``goal``, on the synced ``profile`` record, so the +sentence "To lose 0.5 kg / week on Moderate eat ..." reads the same on the PC +gate and the phone. Each part is validated on read: an unknown direction, +unit or activity (a peer on a newer version, a typo) falls back to that part +of :data:`~diet_guard._body_energy.DEFAULT_GOAL` rather than breaking the tab. + +KEEP IN SYNC WITH ``app/lib/services/body_prefs.dart``. +""" + +from __future__ import annotations + +from dataclasses import replace + +from diet_guard._body_energy import ( + ACTIVITY_CHOICES, + DEFAULT_GOAL, + DIRECTIONS, + TIME_UNITS, + WEIGHT_UNITS, + Goal, +) +from diet_guard._body_store import as_number, read_body, set_profile + +__all__ = ["GOAL_FIELD", "goal_from_json", "goal_to_json", "saved_goal", "set_goal"] + +GOAL_FIELD = "goal" + + +def goal_to_json(goal: Goal) -> dict[str, object]: + """The wire/disk form (short keys).""" + return { + "dir": goal.direction, + "amt": goal.amount, + "wu": goal.weight_unit, + "tu": goal.time_unit, + "act": goal.activity, + } + + +def goal_from_json(raw: object) -> Goal: + """Parse a stored goal part by part, defaulting whatever is unusable.""" + if not isinstance(raw, dict): + return DEFAULT_GOAL + amount = as_number(raw.get("amt")) + goal = DEFAULT_GOAL + if raw.get("dir") in DIRECTIONS: + goal = replace(goal, direction=str(raw["dir"])) + if amount is not None and amount >= 0: + goal = replace(goal, amount=amount) + if raw.get("wu") in WEIGHT_UNITS: + goal = replace(goal, weight_unit=str(raw["wu"])) + if raw.get("tu") in TIME_UNITS: + goal = replace(goal, time_unit=str(raw["tu"])) + if raw.get("act") in ACTIVITY_CHOICES: + goal = replace(goal, activity=str(raw["act"])) + return goal + + +def saved_goal() -> Goal: + """The remembered goal (the default until one is saved).""" + cell = read_body()["profile"].get(GOAL_FIELD) + return goal_from_json(cell.get("v") if isinstance(cell, dict) else None) + + +def set_goal(goal: Goal) -> None: + """Remember (and sync) ``goal``.""" + set_profile(**{GOAL_FIELD: goal_to_json(goal)}) diff --git a/diet_guard/_body_report.py b/diet_guard/_body_report.py new file mode 100644 index 0000000..e3663d2 --- /dev/null +++ b/diet_guard/_body_report.py @@ -0,0 +1,206 @@ +"""Render a :class:`~diet_guard._body_view.BodyView` as aligned text blocks. + +One renderer for both surfaces: the CLI prints the blocks, the gate's Body +tab shows each in a monospace panel. Numbers go through ``half_up`` so they +match the phone to the digit. +""" + +from __future__ import annotations + +from datetime import date +import textwrap +from typing import TYPE_CHECKING + +from diet_guard._activity_kcal import ( + FORMULAS, + METHODS_NOTE, + session_kcal, +) +from diet_guard._body_calc import BMI_PRIME_NOTE, BROCA_NOTE, TREFETHEN_NOTE, half_up +from diet_guard._body_energy import KCAL_PER_KG, MIN_TARGET_KCAL, activity_label +from diet_guard._body_steps import STEP_BASELINE +from diet_guard._body_store import age_on + +if TYPE_CHECKING: + from diet_guard._body_energy import Goal + from diet_guard._body_view import BodyView + from diet_guard._week_plan import WeekPlan + +_NAME_W = 24 +_COL_W = 15 +_SESSIONS_SHOWN = 10 +_WRAP = 88 +_NEEDS = "Set birth date, height, sex and a weight to see the numbers." + + +def _wrap(text: str, indent: str = "") -> list[str]: + """Explanatory prose broken at :data:`_WRAP` columns. + + One unwrapped note once widened the gate's left panel enough to push the + whole right column off-screen. + """ + return textwrap.wrap(text, _WRAP, initial_indent=indent, subsequent_indent=indent) + + +def _num(value: float, digits: int = 0) -> str: + """``value`` rounded half-up to ``digits`` decimals.""" + return f"{half_up(value, digits):.{digits}f}" + + +def profile_lines(view: BodyView, today: date) -> list[str]: + """Who the numbers are for, and what is still missing.""" + prof = view.profile + birth = "birth date not set" + if prof.birth is not None: + age = age_on(date.fromisoformat(prof.birth), today) + birth = f"born {prof.birth} ({age} y)" + height = f"{_num(prof.height_cm)} cm" if prof.height_cm else "height not set" + sex = {"m": "male", "f": "female"}.get(prof.sex or "", "sex not set") + newest = max(view.weights) if view.weights else None + weight = ( + f"{_num(view.weights[newest], 1)} kg on {newest} " + f"({len(view.weights)} weigh-ins)" + if newest + else "no weight logged yet" + ) + return [f"Profile: {birth}, {height}, {sex}", f"Weight: {weight}"] + + +def bodyfat_lines(view: BodyView) -> list[str]: + """The newest body-fat reading and what it switches on.""" + if not view.body_fats: + return ["Body fat: not logged (log one to use Katch-McArdle)."] + day = max(view.body_fats) + return [ + f"Body fat: {_num(view.body_fats[day], 1)}% on {day} " + f"({len(view.body_fats)} readings) -- targets use Katch-McArdle." + ] + + +def bmi_lines(view: BodyView) -> list[str]: + """BMI with plain-language readings, and the healthy weight range.""" + if view.numbers is None: + return [_NEEDS] + bmi = view.numbers.bmi + low, high = view.numbers.healthy + return [ + f"BMI {_num(bmi.bmi, 1)} ({bmi.category})", + f"Healthy weight for your height (BMI 18.5-24.9): " + f"{_num(low, 1)}-{_num(high, 1)} kg", + f"BMI Prime {_num(bmi.prime, 2)} Trefethen {_num(bmi.trefethen, 1)}", + *_wrap(BMI_PRIME_NOTE, " "), + *_wrap(TREFETHEN_NOTE, " "), + ] + + +def ideal_lines(view: BodyView) -> list[str]: + """Every ideal-weight formula, then their average and range.""" + if view.numbers is None: + return [_NEEDS] + lines = ["Ideal weight:"] + for formula in view.numbers.ideal: + value = ( + "n/a (< 152 cm)" + if formula.value is None + else f"{_num(formula.value, 1)} kg" + ) + lines.append(f" {formula.name:<{_NAME_W}}{value}") + summary = view.numbers.ideal_summary + if summary is not None: + avg, low, high = summary + lines.append(f" {'Average of all':<{_NAME_W}}{_num(avg, 1)} kg") + lines.append(f" {'Ideal range':<{_NAME_W}}{_num(low, 1)}-{_num(high, 1)} kg") + lines.extend(_wrap(BROCA_NOTE, " ")) + return lines + + +def bmr_lines(view: BodyView) -> list[str]: + """Resting metabolism by formula; body-fat ones wait for a reading.""" + if view.numbers is None: + return [_NEEDS] + lines = ["Resting metabolism (kcal/day):"] + for f in view.numbers.bmr: + value = "n/a -- log a body-fat %" if f.value is None else _num(f.value) + lines.append(f" {f.name:<{_NAME_W + 8}}{value}") + return lines + + +def goal_sentence(goal: Goal) -> str: + """``To lose 0.5 kg / week on Moderate`` -- the goal as words.""" + activity = activity_label(goal.activity) + if goal.direction == "maintain": + return f"To maintain on {activity}" + amount = f"{goal.amount:g} {goal.weight_unit} / {goal.time_unit}" + return f"To {goal.direction} {amount} on {activity}" + + +def target_lines(view: BodyView) -> list[str]: + """The top line: what to eat for the chosen goal, and how it was got.""" + if view.numbers is None: + return [_NEEDS] + target = view.numbers.target + if target.kcal is None or target.tdee is None: + return [ + f"{goal_sentence(view.goal)}: no workouts published yet " + "(the PC publishes them on sync)." + ] + lines = [f"{goal_sentence(view.goal)} eat {target.kcal} kcal a day."] + if target.below_floor: + lines.append(f" That is below the {MIN_TARGET_KCAL} kcal floor.") + lines.append( + f" Maintenance {_num(target.tdee)} kcal ({target.bmr_name} resting burn " + f"x activity); 1 kg of fat = {KCAL_PER_KG:g} kcal." + ) + lines.extend(_wrap("Informational only -- the budget is never changed here.", " ")) + return lines + + +def activity_lines(view: BodyView) -> list[str]: + """Measured exercise averages and the newest published workouts.""" + if view.activity_stamp is None and not view.steps: + return ["No workouts published yet (the PC publishes them on sync)."] + stamp = (view.activity_stamp or "")[:16].replace("T", " ") or "not yet" + lines = [f"Workouts published {stamp}"] + exercise = view.exercise + if exercise is not None: + means = " ".join( + f"{name} {_num(kcal)}" for name, kcal in exercise.means.items() + ) + lines.append(f"Last {exercise.days} full days, net exercise kcal/day: {means}") + if exercise is not None and view.steps: + newest = max(view.steps) + lines.append( + f"Steps outside workouts: {view.steps[newest]} on {newest}; above " + f"{STEP_BASELINE}/day they add {_num(exercise.steps_kcal)} kcal/day " + "(included above)." + ) + weight = view.weight + for session in view.sessions[-_SESSIONS_SHOWN:][::-1]: + km = f" {_num(session.km, 1)} km" if session.km else "" + kcal = "" + if weight is not None: + result = session_kcal(session, weight) + kcal = " " + " ".join( + f"{name[0]}{_num(result.kcal[name])}" for name in FORMULAS + ) + lines.append( + f" {session.day} {session.kind:<8}{_num(session.minutes):>4} min{km}{kcal}" + ) + lines.extend(_wrap(f"(M = MET, A = ACSM, P = Per-km) {METHODS_NOTE}")) + return lines + + +def plan_lines(plan: WeekPlan) -> list[str]: + """The week plan, one line per day.""" + lines = [f"Week plan for an average of {plan.target_avg} kcal/day:"] + for day in plan.days: + note = {"unlogged": " (no log - type it?)"}.get(day.source, "") + lines.append(f" {day.weekday} {day.day} {day.kcal:>5} {day.source}{note}") + if plan.per_remaining is None: + lines.append( + f"Every day is known: the week averages {_num(plan.week_avg)} kcal." + ) + else: + warn = f" -- below the {MIN_TARGET_KCAL} floor!" if plan.below_floor else "" + lines.append(f"Eat {plan.per_remaining} kcal on each remaining day{warn}") + return lines diff --git a/diet_guard/_body_steps.py b/diet_guard/_body_steps.py new file mode 100644 index 0000000..1116d48 --- /dev/null +++ b/diet_guard/_body_steps.py @@ -0,0 +1,59 @@ +"""Daily steps from the phone's Health Connect, turned into exercise kcal. + +The **phone** writes these (it holds the Health Connect permission): one +count per day of steps taken *outside* every published workout's +``start..end`` window, so a recorded walk is never counted as both a workout +and steps. Every device reads them. + +Only steps above :data:`STEP_BASELINE` count: the ``BMR x 1.2`` sedentary +baseline the measured TDEE starts from already assumes a few thousand steps +of ordinary daily movement. The rest are walking at the per-km rate +(``_activity_kcal``: 0.5 kcal per kg per km, net of resting), over a stride +estimated from height. + +KEEP IN SYNC WITH ``app/lib/services/body_steps.dart``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from diet_guard._body_store import as_number, is_day, read_body + +if TYPE_CHECKING: + from diet_guard._budget_biometrics import Biometrics + +__all__ = ["STEP_BASELINE", "daily_steps", "step_kcal", "stride_m"] + +STEP_BASELINE = 4000 +# Stride as a fraction of height (the common pedometer estimate). +_STRIDE_MALE = 0.415 +_STRIDE_FEMALE = 0.413 +_WALK_KCAL_PER_KG_KM = 0.5 +_M_PER_KM = 1000.0 + + +def daily_steps() -> dict[str, int]: + """``YYYY-MM-DD`` -> steps outside workouts, oldest first.""" + log = read_body()["steps"] + counts = { + day: as_number(cell.get("n")) if isinstance(cell, dict) else None + for day, cell in log.items() + } + return { + day: int(n) + for day, n in sorted(counts.items()) + if n is not None and is_day(day) + } + + +def stride_m(bio: Biometrics) -> float: + """Estimated walking stride in metres.""" + return bio.height_cm / 100.0 * (_STRIDE_MALE if bio.is_male else _STRIDE_FEMALE) + + +def step_kcal(steps: int, bio: Biometrics) -> float: + """Net kcal of the steps above the baseline, as walking.""" + extra = max(0, steps - STEP_BASELINE) + km = extra * stride_m(bio) / _M_PER_KM + return km * _WALK_KCAL_PER_KG_KM * bio.weight_kg diff --git a/diet_guard/_body_store.py b/diet_guard/_body_store.py new file mode 100644 index 0000000..4175df1 --- /dev/null +++ b/diet_guard/_body_store.py @@ -0,0 +1,210 @@ +"""The local Body document: profile, per-day weight log, published workouts. + +Shape and merge rules are in ``docs/DOCS-body.md``; the sync adapters are +:mod:`diet_guard.sync_merge._body`. Every write stamps an edit time ``t`` so +the per-field last-writer-wins merge has something to compare, and every read +is tolerant: a malformed field reads as absent rather than raising, because a +peer on a future version must not be able to break this device's Body tab. + +KEEP IN SYNC WITH ``app/lib/services/body_service.dart``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, date, datetime +import json +import logging + +from diet_guard._budget_biometrics import Biometrics +from diet_guard._constants import BODY_FILE + +_logger = logging.getLogger(__name__) + +_FILE_VERSION = 1 +# Every top-level section of the document (``bodyfat`` is a dated log like +# ``weights``; see ``_body_fat_store``). +SECTIONS: tuple[str, ...] = ("profile", "weights", "bodyfat", "activity", "steps") +PROFILE_FIELDS: tuple[str, ...] = ("birth", "height_cm", "sex") +_EPOCH = datetime(1970, 1, 1, tzinfo=UTC) +# A body weight outside this range is a typo or a unit mix-up, never real. +MIN_KG = 20.0 +MAX_KG = 400.0 + + +@dataclass(frozen=True) +class Profile: + """The person's stored body profile; any field may still be unset.""" + + birth: str | None + height_cm: float | None + sex: str | None + + @property + def complete(self) -> bool: + """Whether every field is set -- then the profile moves to the bottom.""" + return None not in (self.birth, self.height_cm, self.sex) + + def biometrics(self, weight_kg: float, today: date) -> Biometrics | None: + """Biometrics for today, or None while the profile is incomplete.""" + if self.birth is None or self.height_cm is None or self.sex is None: + return None + return Biometrics( + weight_kg=weight_kg, + height_cm=self.height_cm, + age_years=age_on(date.fromisoformat(self.birth), today), + is_male=self.sex == "m", + ) + + +@dataclass(frozen=True) +class WeightCandidate: + """A weigh-in offered for the log, applied only if it is newer.""" + + day: str + kg: float + src: str + t: str + + +def age_on(birth: date, today: date) -> int: + """Completed years between ``birth`` and ``today``.""" + before_birthday = (today.month, today.day) < (birth.month, birth.day) + return today.year - birth.year - int(before_birthday) + + +def now_stamp() -> str: + """The current local time as the ISO edit stamp every write carries.""" + return datetime.now(tz=UTC).astimezone().isoformat(timespec="seconds") + + +def stamp_ms(stamp: object) -> int: + """Epoch milliseconds of an edit stamp; unparsable -> 0 (the epoch).""" + try: + moment = datetime.fromisoformat(str(stamp)) + except ValueError: + return 0 + if moment.tzinfo is None: + moment = moment.replace(tzinfo=UTC) + return int((moment - _EPOCH).total_seconds() * 1000) + + +def read_body() -> dict[str, dict[str, object]]: + """The raw document, normalised to its three sections; never raises.""" + try: + raw = json.loads(BODY_FILE.read_text(encoding="utf-8")) + except FileNotFoundError: + raw = {} + except (OSError, ValueError) as exc: + _logger.warning( + "Unreadable body file %s (%s); treating as empty", BODY_FILE, exc + ) + raw = {} + if not isinstance(raw, dict): + raw = {} + return { + section: value if isinstance(value := raw.get(section), dict) else {} + for section in SECTIONS + } + + +def write_body(doc: dict[str, dict[str, object]]) -> None: + """Persist ``doc`` atomically enough for a single-writer local file.""" + BODY_FILE.parent.mkdir(parents=True, exist_ok=True) + payload = {"v": _FILE_VERSION, **doc} + tmp = BODY_FILE.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, indent=1, sort_keys=True), encoding="utf-8") + tmp.replace(BODY_FILE) + + +def _field(doc: dict[str, dict[str, object]], name: str) -> object: + """A profile field's value, or None when missing/malformed.""" + cell = doc["profile"].get(name) + return cell.get("v") if isinstance(cell, dict) else None + + +def as_number(value: object) -> float | None: + """``value`` as a float when it is a real (non-bool) number, else None.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return float(value) + + +def profile() -> Profile: + """The stored profile, with malformed fields read as unset.""" + doc = read_body() + birth, height, sex = (_field(doc, name) for name in PROFILE_FIELDS) + try: + date.fromisoformat(str(birth)) + except ValueError: + birth = None + height_cm = as_number(height) + return Profile( + birth=birth if isinstance(birth, str) else None, + height_cm=height_cm if height_cm is not None and height_cm > 0 else None, + sex=sex if sex in {"m", "f"} else None, + ) + + +def set_profile(**fields: object) -> None: + """Set any of ``birth``/``height_cm``/``sex``, each stamped now.""" + doc = read_body() + stamp = now_stamp() + for name, value in fields.items(): + doc["profile"][name] = {"v": value, "t": stamp} + write_body(doc) + + +def _kg(cell: object) -> float | None: + """A weight cell's kg, or None for a tombstone/malformed cell.""" + return as_number(cell.get("kg")) if isinstance(cell, dict) else None + + +def is_day(key: str) -> bool: + """Whether ``key`` is a ``YYYY-MM-DD`` date (a peer could send anything).""" + try: + date.fromisoformat(key) + except ValueError: + return False + return True + + +def weights() -> dict[str, float]: + """``YYYY-MM-DD`` -> kg for every live (non-deleted) day, oldest first.""" + live = {day: _kg(cell) for day, cell in read_body()["weights"].items()} + return { + day: kg for day, kg in sorted(live.items()) if kg is not None and is_day(day) + } + + +def newest_weight() -> tuple[str, float] | None: + """The latest-dated live weigh-in, or None when the log is empty.""" + live = weights() + if not live: + return None + day = max(live) + return day, live[day] + + +def set_weight(day: str, kg: float | None, src: str = "manual") -> None: + """Record (or, with ``kg=None``, delete) ``day``'s weight, stamped now.""" + doc = read_body() + value = None if kg is None else round(float(kg), 2) + doc["weights"][day] = {"kg": value, "src": src, "t": now_stamp()} + write_body(doc) + + +def apply_weight_candidates(candidates: list[WeightCandidate]) -> int: + """Apply each candidate whose day is missing or older; return the count.""" + doc = read_body() + applied = 0 + for cand in candidates: + current = doc["weights"].get(cand.day) + current_t = current.get("t") if isinstance(current, dict) else None + if current is not None and stamp_ms(current_t) >= stamp_ms(cand.t): + continue + doc["weights"][cand.day] = {"kg": cand.kg, "src": cand.src, "t": cand.t} + applied += 1 + if applied: + write_body(doc) + return applied diff --git a/diet_guard/_body_view.py b/diet_guard/_body_view.py new file mode 100644 index 0000000..00a2cc0 --- /dev/null +++ b/diet_guard/_body_view.py @@ -0,0 +1,186 @@ +"""Everything the Body surfaces show, gathered once from local state. + +The CLI's ``body`` command and the gate's Body tab both render a +:class:`BodyView`; neither reads the store or runs a formula itself, so the +two cannot drift. Local reads only -- no network -- because the gate builds +this on the Tk thread. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import TYPE_CHECKING + +from diet_guard._activity_kcal import daily_exercise, window_days +from diet_guard._body_activity_store import activity +from diet_guard._body_calc import ( + bmi_report, + bmr_formulas, + healthy_range, + ideal_summary, + ideal_weights, + table_bmr, +) +from diet_guard._body_energy import goal_target +from diet_guard._body_fat_store import body_fats +from diet_guard._body_prefs import saved_goal +from diet_guard._body_steps import daily_steps, step_kcal +from diet_guard._body_store import profile, weights +from diet_guard._budget import ( + BudgetFileCorruptError, + BudgetNotInitializedError, + daily_budget, +) + +if TYPE_CHECKING: + from datetime import date + + from diet_guard._activity_kcal import Session + from diet_guard._body_calc import BmiReport, Formula + from diet_guard._body_energy import Goal, Target + from diet_guard._body_store import Profile + from diet_guard._budget_biometrics import Biometrics + + +# The app-wide unset-budget default (the gate's History tab and the phone use +# the same 2200), so the week planner has a target from a fresh install. +DEFAULT_TARGET_KCAL = 2200 + + +def budget_or_default() -> int: + """Today's budget, or :data:`DEFAULT_TARGET_KCAL` when unset or corrupt.""" + try: + return daily_budget() + except BudgetNotInitializedError, BudgetFileCorruptError: + return DEFAULT_TARGET_KCAL + + +@dataclass(frozen=True) +class BodyNumbers: + """The formula outputs, present only when profile and weight both exist.""" + + bio: Biometrics + bmi: BmiReport + ideal: tuple[Formula, ...] + ideal_summary: tuple[float, float, float] | None + healthy: tuple[float, float] + bmr: tuple[Formula, ...] + target: Target + + +@dataclass(frozen=True) +class ExerciseSummary: + """Measured daily exercise over the averaging window. + + Attributes: + means: Mean daily net kcal per formula, steps included. + steps_kcal: The counted steps' share of each mean (same window). + days: Full past days averaged over (1-14; see ``window_days``). + """ + + means: dict[str, float] + steps_kcal: float + days: int + + +@dataclass(frozen=True) +class BodyView: + """The Body tab's whole state. + + Attributes: + profile: Stored profile (fields may be unset). + goal: The calorie goal the target line answers. + weights: Live weight log, oldest first. + body_fats: Live body-fat log (%), oldest first. + steps: Phone-published steps outside workouts, per day. + sessions: Published workouts (empty when none). + activity_stamp: When the workouts were published, or None. + exercise: Measured exercise, or None without published activity or + steps, or without a profile to scale them by. + numbers: The formulas, or None while profile/weight are missing. + """ + + profile: Profile + goal: Goal + weights: dict[str, float] + body_fats: dict[str, float] + steps: dict[str, int] + sessions: tuple[Session, ...] + activity_stamp: str | None + exercise: ExerciseSummary | None + numbers: BodyNumbers | None + + @property + def weight(self) -> float | None: + """The newest logged weight, or None.""" + return self.weights[max(self.weights)] if self.weights else None + + +def _numbers( + bio: Biometrics, fat: float | None, goal: Goal, exercise: ExerciseSummary | None +) -> BodyNumbers: + """Every formula for one person.""" + bmr = table_bmr(bio, fat) + ideal = ideal_weights(bio) + means = None if exercise is None else exercise.means + return BodyNumbers( + bio=bio, + bmi=bmi_report(bio), + ideal=ideal, + ideal_summary=ideal_summary(ideal), + healthy=healthy_range(bio), + bmr=bmr_formulas(bio, fat), + target=goal_target(goal, bmr.value or 0.0, bmr.name, means), + ) + + +def _exercise( + bio: Biometrics, sessions: tuple[Session, ...], steps: dict[str, int], today: date +) -> ExerciseSummary: + """Averages over one shared window, so the steps share adds up.""" + by_day = {day: step_kcal(n, bio) for day, n in steps.items()} + days = window_days(sessions, today, by_day) + first = (today - timedelta(days=days)).isoformat() + in_window = sum(k for d, k in by_day.items() if first <= d < today.isoformat()) + return ExerciseSummary( + means=daily_exercise(sessions, bio.weight_kg, today, by_day), + steps_kcal=in_window / days if days else 0.0, + days=days, + ) + + +def build_view(today: date, goal: Goal | None = None) -> BodyView: + """Read the store and evaluate every formula for ``today``. + + Args: + today: The local date (ages and the exercise window hang off it). + goal: The calorie goal; None reads the saved one. + """ + chosen = saved_goal() if goal is None else goal + log = weights() + fats = body_fats() + steps = daily_steps() + sessions, stamp = activity() + weight = log[max(log)] if log else None + bio = profile().biometrics(weight, today) if weight is not None else None + exercise = ( + _exercise(bio, sessions, steps, today) + if bio is not None and (stamp is not None or steps) + else None + ) + return BodyView( + profile=profile(), + goal=chosen, + weights=log, + body_fats=fats, + steps=steps, + sessions=sessions, + activity_stamp=stamp, + exercise=exercise, + numbers=( + None + if bio is None + else _numbers(bio, fats[max(fats)] if fats else None, chosen, exercise) + ), + ) diff --git a/diet_guard/_cli.py b/diet_guard/_cli.py index 112b3db..ff01e73 100644 --- a/diet_guard/_cli.py +++ b/diet_guard/_cli.py @@ -27,6 +27,13 @@ from diet_guard._budget import ( from diet_guard._budget_derived import protein_target_g from diet_guard._cli_args import parse_args from diet_guard._cli_averages import cmd_averages +from diet_guard._cli_body import ( + cmd_body, + cmd_bodyfat, + cmd_plan_week, + cmd_profile, + cmd_weight, +) from diet_guard._cli_gate import cmd_gate from diet_guard._cli_init import cmd_init from diet_guard._cli_kuchnia import cmd_kuchnia @@ -219,6 +226,11 @@ _COMMANDS: dict[str, Callable[[argparse.Namespace], int]] = { "prune-peers": _dispatch_prune, "gate": _dispatch_gate, "undo": lambda _args: _cmd_undo(), + "body": lambda args: cmd_body(_emit, args), + "weight": lambda args: cmd_weight(_emit, args), + "bodyfat": lambda args: cmd_bodyfat(_emit, args), + "profile": lambda args: cmd_profile(_emit, args), + "plan-week": lambda args: cmd_plan_week(_emit, args), } diff --git a/diet_guard/_cli_args.py b/diet_guard/_cli_args.py index d1b5ecb..4d06b5a 100644 --- a/diet_guard/_cli_args.py +++ b/diet_guard/_cli_args.py @@ -15,6 +15,7 @@ from __future__ import annotations import argparse from diet_guard._cli_averages import register_averages_subparser +from diet_guard._cli_body import register_body_subparsers from diet_guard._cli_kuchnia import register_kuchnia_subparser from diet_guard._cli_prune import register_prune_subparser from diet_guard._cli_sync import register_sync_subparser @@ -97,6 +98,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace: register_kuchnia_subparser(sub) register_prune_subparser(sub) register_sync_subparser(sub) + register_body_subparsers(sub) gate = sub.add_parser( "gate", diff --git a/diet_guard/_cli_body.py b/diet_guard/_cli_body.py new file mode 100644 index 0000000..a7773e1 --- /dev/null +++ b/diet_guard/_cli_body.py @@ -0,0 +1,231 @@ +"""CLI for the Body feature: ``body``, ``weight``, ``profile``, ``plan-week``. + +Split out of :mod:`diet_guard._cli` for the 250-line cap. Everything shown is +informational; nothing here writes the daily budget. The one budget-record +field touched is ``w`` (body weight, feeding the protein target), and only +when the typed weigh-in is the newest one. + +Edits publish straight away (``publish_after_log``), for the same reason a +logged meal does: until the full tick runs, the phone shows stale data. +""" + +from __future__ import annotations + +import argparse +from dataclasses import replace +from datetime import date +from typing import TYPE_CHECKING + +from diet_guard._body_energy import ( + ACTIVITY_CHOICES, + DIRECTIONS, + TIME_UNITS, + WEIGHT_UNITS, + Goal, +) +from diet_guard._body_fat_store import ( + MAX_BODY_FAT, + MIN_BODY_FAT, + body_fats, + set_body_fat, +) +from diet_guard._body_prefs import saved_goal, set_goal +from diet_guard._body_report import ( + activity_lines, + bmi_lines, + bmr_lines, + bodyfat_lines, + ideal_lines, + plan_lines, + profile_lines, + target_lines, +) +from diet_guard._body_store import ( + MAX_KG, + MIN_KG, + newest_weight, + set_profile, + set_weight, + weights, +) +from diet_guard._body_view import budget_or_default, build_view +from diet_guard._daystatus import day_total_kcal +from diet_guard._phone_weight import update_weight +from diet_guard._state import load_log, now_local +from diet_guard._sync_events import publish_after_log +from diet_guard._week_plan import WEEKDAYS, plan_week + +if TYPE_CHECKING: + from collections.abc import Callable + +_WEIGHT_LIST = 14 + + +def _iso_date(text: str) -> str: + """Argparse type: a ``YYYY-MM-DD`` date, normalised.""" + try: + return date.fromisoformat(text).isoformat() + except ValueError as exc: + msg = f"not a YYYY-MM-DD date: {text!r}" + raise argparse.ArgumentTypeError(msg) from exc + + +def _day_kcal(text: str) -> tuple[int, int]: + """Argparse type: ``mon=2500`` -> ``(0, 2500)``.""" + name, _, kcal = text.partition("=") + names = [w.lower() for w in WEEKDAYS] + if name.lower()[:3] not in names or not kcal.isdigit(): + msg = f"expected e.g. mon=2500, got {text!r}" + raise argparse.ArgumentTypeError(msg) + return names.index(name.lower()[:3]), int(kcal) + + +def register_body_subparsers(sub: argparse._SubParsersAction) -> None: + """Register ``body``, ``weight``, ``profile`` and ``plan-week``.""" + body = sub.add_parser("body", help="Calorie target, BMI, ideal weight, workouts.") + body.add_argument("--direction", choices=DIRECTIONS, help="lose, maintain or gain.") + body.add_argument("--amount", type=float, help="how much, e.g. 0.5.") + body.add_argument( + "--weight-unit", choices=list(WEIGHT_UNITS), help="kg, lb, g, st." + ) + body.add_argument("--time-unit", choices=list(TIME_UNITS), help="per day/week/...") + body.add_argument("--activity", choices=ACTIVITY_CHOICES, help="activity level.") + body.add_argument("--save", action="store_true", help="remember this goal.") + fat = sub.add_parser("bodyfat", help="Log (or list) body fat %%.") + fat.add_argument("pct", nargs="?", type=float, help="body fat in %%.") + fat.add_argument("--date", type=_iso_date, help="day (default today).") + fat.add_argument("--delete", action="store_true", help="delete that day.") + weight = sub.add_parser("weight", help="Log (or list) body weight.") + weight.add_argument("kg", nargs="?", type=float, help="weight in kg.") + weight.add_argument("--date", type=_iso_date, help="day (default today).") + weight.add_argument("--delete", action="store_true", help="delete that day.") + prof = sub.add_parser("profile", help="Set birth date, height and sex.") + prof.add_argument("--birth", type=_iso_date, help="YYYY-MM-DD.") + prof.add_argument("--height", type=float, help="height in cm.") + prof.add_argument("--sex", choices=("m", "f"), help="for the BMR constant.") + plan = sub.add_parser( + "plan-week", help="kcal per remaining day for a weekly average." + ) + plan.add_argument("--target", type=int, help="average kcal/day (default: budget).") + plan.add_argument( + "--day", type=_day_kcal, action="append", default=[], help="mon=2500" + ) + + +def _publish(emit: Callable[[str], None]) -> None: + """Push the edit now, reporting (not raising) an outage.""" + failure = publish_after_log() + if failure is not None: + emit(f"saved locally, not yet published ({failure}).") + + +def _goal(args: argparse.Namespace) -> Goal: + """The saved goal with any typed part replaced.""" + goal = saved_goal() + parts = { + "direction": args.direction, + "amount": args.amount, + "weight_unit": args.weight_unit, + "time_unit": args.time_unit, + "activity": args.activity, + } + return replace(goal, **{k: v for k, v in parts.items() if v is not None}) + + +def cmd_body(emit: Callable[[str], None], args: argparse.Namespace) -> int: + """Print every Body block: the target first, profile and body fat last. + + The profile is set up once, so it sits at the bottom -- unless something + in it is missing, when it comes first so the gap is the first thing seen. + """ + today = now_local().date() + goal = _goal(args) + if args.save: + set_goal(goal) + emit("goal saved.") + view = build_view(today, goal) + blocks = [ + target_lines(view), + bmi_lines(view), + ideal_lines(view), + bmr_lines(view), + activity_lines(view), + ] + profile = profile_lines(view, today) + blocks = [profile, *blocks] if not view.profile.complete else [*blocks, profile] + blocks.append(bodyfat_lines(view)) + for block in blocks: + for line in block: + emit(line) + emit("") + return 0 + + +def cmd_bodyfat(emit: Callable[[str], None], args: argparse.Namespace) -> int: + """Log, delete or list body-fat readings.""" + day = args.date or now_local().date().isoformat() + if args.pct is None and not args.delete: + for when, pct in list(body_fats().items())[-_WEIGHT_LIST:]: + emit(f"{when} {pct:.1f}%") + return 0 + if args.pct is not None and not MIN_BODY_FAT <= args.pct <= MAX_BODY_FAT: + emit(f"body fat must be between {MIN_BODY_FAT:g} and {MAX_BODY_FAT:g} %.") + return 1 + set_body_fat(day, None if args.delete else args.pct) + emit(f"{'deleted' if args.delete else f'{args.pct:g}%'} for {day}.") + _publish(emit) + return 0 + + +def cmd_weight(emit: Callable[[str], None], args: argparse.Namespace) -> int: + """Log, delete or list weigh-ins.""" + day = args.date or now_local().date().isoformat() + if args.kg is None and not args.delete: + for when, kg in list(weights().items())[-_WEIGHT_LIST:]: + emit(f"{when} {kg:.1f} kg") + return 0 + if args.kg is not None and not MIN_KG <= args.kg <= MAX_KG: + emit(f"weight must be between {MIN_KG:g} and {MAX_KG:g} kg.") + return 1 + set_weight(day, None if args.delete else args.kg) + newest = newest_weight() + # Editing (or deleting) the newest day moves ``w``; an older day does not. + if newest is not None and day >= newest[0]: + update_weight(newest[1]) + emit(f"{'deleted' if args.delete else f'{args.kg:g} kg'} for {day}.") + _publish(emit) + return 0 + + +def cmd_profile(emit: Callable[[str], None], args: argparse.Namespace) -> int: + """Set any of birth/height/sex.""" + fields = { + name: value + for name, value in ( + ("birth", args.birth), + ("height_cm", args.height), + ("sex", args.sex), + ) + if value is not None + } + if not fields: + emit(profile_lines(build_view(now_local().date()), now_local().date())[0]) + return 0 + set_profile(**fields) + emit("profile saved.") + _publish(emit) + return 0 + + +def cmd_plan_week(emit: Callable[[str], None], args: argparse.Namespace) -> int: + """Print the week plan.""" + log = load_log() + plan = plan_week( + now_local().date(), + args.target or budget_or_default(), + {day: day_total_kcal(log, day) for day in log}, + dict(args.day), + ) + for line in plan_lines(plan): + emit(line) + return 0 diff --git a/diet_guard/_cli_init.py b/diet_guard/_cli_init.py index 0ed0524..522b773 100644 --- a/diet_guard/_cli_init.py +++ b/diet_guard/_cli_init.py @@ -4,17 +4,20 @@ Split out of :mod:`diet_guard._cli` to hold the repo's 250-line cap, following the same thin-per-subcommand-handler shape as ``_cli_gate.py`` (``gate``), ``_cli_log.py`` (``ate``) and ``_cli_sync.py`` (``sync``). -The biometrics prompted for here are used **once** and discarded: only the -computed budget (and the body weight, which the protein target needs) is ever -persisted. See ``CLAUDE.md``'s "Biometrics are used once and discarded". +The biometrics prompted for here seed the Body profile too (height, sex, a +birth date estimated from the typed age, and today's weigh-in), so BMI and +ideal weight work straight after ``init``. Refine the birth date with +``diet_guard profile --birth``. See ``docs/DOCS-body.md``. """ from __future__ import annotations from typing import TYPE_CHECKING +from diet_guard._body_store import set_profile, set_weight from diet_guard._budget import write_budget from diet_guard._budget_biometrics import Biometrics, compute_target_budget +from diet_guard._state import now_local if TYPE_CHECKING: from collections.abc import Callable @@ -81,6 +84,15 @@ def cmd_init( deficit_kcal=deficit, ) write_budget(budget, weight_kg=bio.weight_kg) + today = now_local().date() + # Same month/day as today, so the estimated age is exact right now. + birth = today.replace(year=today.year - int(bio.age_years), day=min(today.day, 28)) + set_profile( + birth=birth.isoformat(), + height_cm=bio.height_cm, + sex="m" if bio.is_male else "f", + ) + set_weight(today.isoformat(), bio.weight_kg, "init") emit(f"budget computed from your biometrics: {budget:g} kcal/day.") emit("edit it any time from the gate's calendar tab or the phone app.") return 0 diff --git a/diet_guard/_constants.py b/diet_guard/_constants.py index cf02f6d..25b6c7f 100644 --- a/diet_guard/_constants.py +++ b/diet_guard/_constants.py @@ -37,6 +37,10 @@ FOOD_BANK_FILE: Path = DATA_DIR / "food_bank.json" # sync in their own right. Kept separate from FOOD_BANK_FILE because that # one is rewritten wholesale on every log write. MANUAL_BANK_FILE: Path = DATA_DIR / "food_bank_manual.json" +# Body profile (birth date, height, sex), the per-day weight log and the +# PC-published workout sessions (see _body_store.py, docs/DOCS-body.md). +# Synced as its own document; git-ignored, never committed. +BODY_FILE: Path = DATA_DIR / "body.json" # The budget: a plain JSON dotfile alongside the log, freely editable on this # device or the phone app and synced between them (see _sync.py). # Git-ignored, never committed. "Hidden" here means never-online (it lives diff --git a/diet_guard/_gatelock_body.py b/diet_guard/_gatelock_body.py new file mode 100644 index 0000000..08a1de6 --- /dev/null +++ b/diet_guard/_gatelock_body.py @@ -0,0 +1,241 @@ +"""The gate's Body tab: calorie goal, weight/body-fat logs + graph, BMI. + +A self-contained component, not another link in ``MealGate``'s class chain: +that chain sits at pylint's max-parents cap (see the ``_gatelock_calendar`` +notes), and the tab shares no state with the meal form. ``_build_tabs`` +constructs one :class:`BodyTab` per monitor; it keeps itself alive through +the Tk callbacks it registers. + +Everything shown is informational -- no button here writes the budget. Saving +a weight, body fat, the profile or the goal writes the Body document +(and ``w``, when the weight is the newest), then publishes on a background +thread so a slow network can never freeze a lock screen. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from gatelock import ScrollableSurface + +from diet_guard._body_fat_store import MAX_BODY_FAT, MIN_BODY_FAT, set_body_fat +from diet_guard._body_prefs import saved_goal, set_goal +from diet_guard._body_report import ( + activity_lines, + bmi_lines, + bmr_lines, + bodyfat_lines, + ideal_lines, + plan_lines, + profile_lines, + target_lines, +) +from diet_guard._body_store import ( + MAX_KG, + MIN_KG, + newest_weight, + set_profile, + set_weight, +) +from diet_guard._body_view import BodyView, budget_or_default, build_view +from diet_guard._daystatus import day_total_kcal +from diet_guard._gatelock_body_graph import GraphBox, draw_weight_graph, in_range +from diet_guard._gatelock_body_input import ( + HEIGHT_CM, + goal_choice, + load_goal, + parse_day, + parse_float, +) +from diet_guard._gatelock_body_vars import BodyCallbacks, BodyVars +from diet_guard._gatelock_body_widgets import GRAPH_H, GRAPH_W, build_body_frame +from diet_guard._gatelock_calendar_types import _COLORS +from diet_guard._phone_weight import update_weight +from diet_guard._state import load_log, now_local +from diet_guard._sync_events import publish_after_log_detached +from diet_guard._week_plan import plan_week + +if TYPE_CHECKING: + from datetime import date + import tkinter as tk + + from diet_guard._week_plan import WeekPlan + +__all__ = ["BodyTab"] + +_DAY_HINT = "Type the YYYY-MM-DD day to delete." + + +class BodyTab: + """One monitor's Body tab.""" + + def __init__(self, notebook: tk.Misc) -> None: + """Build the tab into ``notebook`` and render it once.""" + self._budget = budget_or_default() + self._days: int | None = 90 + self._goal = saved_goal() + self.vars = BodyVars() + callbacks = BodyCallbacks( + save_profile=self.save_profile, + save_weight=self.save_weight, + delete_weight=self.delete_weight, + save_body_fat=self.save_body_fat, + delete_body_fat=self.delete_body_fat, + goal_changed=self.goal_changed, + set_range=self.set_range, + recalc=self.refresh, + ) + view = build_view(now_local().date()) + surface = ScrollableSurface(notebook, _COLORS) + widgets = build_body_frame( + surface.content, + self.vars, + callbacks, + profile_first=not view.profile.complete, + ) + widgets.frame.pack(fill="both", expand=True) + surface.finalize() + self.container = surface.container + self._canvas = widgets.canvas + self._load_inputs(view) + self.refresh() + + def _load_inputs(self, view: BodyView) -> None: + """Seed the input fields and table choice from what is stored.""" + prof, logs = self.vars.profile, self.vars.logs + prof.birth.set(view.profile.birth or "") + height = view.profile.height_cm + prof.height.set(f"{height:g}" if height else "") + prof.sex.set(view.profile.sex or "") + today = now_local().date().isoformat() + logs.weight_day.set(today) + logs.fat_day.set(today) + self.vars.plan.target.set(str(self._budget)) + load_goal(self.vars.goal, self._goal) + + def refresh(self) -> None: + """Recompute every block and redraw the graph from local state.""" + today = now_local().date() + view = build_view(today, self._goal) + target = None if view.numbers is None else view.numbers.target + kcal = None if target is None else target.kcal + self.vars.goal.answer.set("" if kcal is None else f"eat {kcal} kcal") + lines = target_lines(view) + texts = { + "summary": profile_lines(view, today), + "table": lines[1:] if kcal is not None else lines, + "bodyfat": bodyfat_lines(view), + "bmi": bmi_lines(view), + "ideal": ideal_lines(view), + "bmr": bmr_lines(view), + "workouts": activity_lines(view), + "plan": plan_lines(self._plan(today)), + } + for name, lines in texts.items(): + self.vars.blocks[name].set("\n".join(lines)) + self._draw(view, today) + + def _plan(self, today: date) -> WeekPlan: + log = load_log() + typed = { + index: int(value) + for index, var in enumerate(self.vars.plan.days) + if (value := parse_float(var.get())) is not None + } + target = parse_float(self.vars.plan.target.get()) + return plan_week( + today, + int(target) if target else self._budget, + {day: day_total_kcal(log, day) for day in log}, + typed, + ) + + def _draw(self, view: BodyView, today: date) -> None: + fat = self.vars.logs.graph.get() == "fat" + values = view.body_fats if fat else view.weights + healthy = None if fat or view.numbers is None else view.numbers.healthy + box = GraphBox(GRAPH_W, GRAPH_H) + draw_weight_graph( + self._canvas, in_range(values, today, self._days), box, healthy + ) + + def set_range(self, days: int | None) -> None: + """Show the last ``days`` days of the graph (None = all).""" + self._days = days + self.refresh() + + def _saved(self, message: str) -> None: + self.vars.status.set(message) + publish_after_log_detached( + lambda why: self.vars.status.set(f"Saved; not yet synced ({why}).") + ) + self.refresh() + + def goal_changed(self) -> None: + """Re-answer and remember (synced) the goal the pickers now show.""" + self._goal = goal_choice(self.vars.goal, self._goal) + load_goal(self.vars.goal, self._goal) + set_goal(self._goal) + self._saved("Goal saved.") + + def save_profile(self) -> None: + """Validate and store birth date, height and sex.""" + prof = self.vars.profile + birth, height = parse_day(prof.birth.get()), parse_float(prof.height.get()) + sex = prof.sex.get() + if ( + birth is None + or height is None + or not HEIGHT_CM[0] <= height <= HEIGHT_CM[1] + or sex not in {"m", "f"} + ): + self.vars.status.set("Profile needs a YYYY-MM-DD birth date, cm and sex.") + return + set_profile(birth=birth, height_cm=height, sex=sex) + self._saved("Profile saved -- it moves to the bottom next time.") + + def save_weight(self) -> None: + """Validate and store the typed weigh-in.""" + kg = parse_float(self.vars.logs.weight.get()) + day = parse_day(self.vars.logs.weight_day.get()) + if kg is None or day is None or not MIN_KG <= kg <= MAX_KG: + self.vars.status.set(f"Weight needs {MIN_KG:g}-{MAX_KG:g} kg and a day.") + return + self._store_weight(day, kg) + self._saved(f"Saved {kg:g} kg for {day}.") + + def delete_weight(self) -> None: + """Delete the weigh-in for the typed day.""" + day = parse_day(self.vars.logs.weight_day.get()) + if day is None: + self.vars.status.set(_DAY_HINT) + return + self._store_weight(day, None) + self._saved(f"Deleted the weigh-in for {day}.") + + @staticmethod + def _store_weight(day: str, kg: float | None) -> None: + set_weight(day, kg) + newest = newest_weight() + if newest is not None and day >= newest[0]: + update_weight(newest[1]) + + def save_body_fat(self) -> None: + """Validate and store the typed body-fat reading.""" + pct = parse_float(self.vars.logs.body_fat.get()) + day = parse_day(self.vars.logs.fat_day.get()) + if pct is None or day is None or not MIN_BODY_FAT <= pct <= MAX_BODY_FAT: + limits = f"{MIN_BODY_FAT:g}-{MAX_BODY_FAT:g}%" + self.vars.status.set(f"Body fat needs {limits} and a day.") + return + set_body_fat(day, pct) + self._saved(f"Saved {pct:g}% body fat for {day}.") + + def delete_body_fat(self) -> None: + """Delete the body-fat reading for the typed day.""" + day = parse_day(self.vars.logs.fat_day.get()) + if day is None: + self.vars.status.set(_DAY_HINT) + return + set_body_fat(day, None) + self._saved(f"Deleted the body-fat reading for {day}.") diff --git a/diet_guard/_gatelock_body_graph.py b/diet_guard/_gatelock_body_graph.py new file mode 100644 index 0000000..5cca1c4 --- /dev/null +++ b/diet_guard/_gatelock_body_graph.py @@ -0,0 +1,186 @@ +"""The Body tab's weight graph, drawn on a plain ``tk.Canvas``. + +No plotting dependency: the gate runs on system ``/usr/bin/python`` and a +line, some dots, four gridlines and a shaded healthy band are all a weight +trend needs. The geometry (:func:`graph_points`, :func:`value_ticks`) is pure +so it is tested without Tk; :func:`draw_weight_graph` only issues canvas +calls. + +.. note:: + Imports ``tkinter`` for types only; it never builds widgets, so it is not + in ``_GATE_TK_MODULES``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import TYPE_CHECKING + +from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _MUTED +from diet_guard._gatelock_typography import CAPTION +from diet_guard._gatelock_ui import BG, FG + +if TYPE_CHECKING: + import tkinter as tk + +__all__ = [ + "GRAPH_RANGES", + "GraphBox", + "draw_weight_graph", + "graph_points", + "value_ticks", +] + +# (label, days back; None = everything). +GRAPH_RANGES: tuple[tuple[str, int | None], ...] = ( + ("30 days", 30), + ("90 days", 90), + ("1 year", 365), + ("All", None), +) +_HEALTHY_FILL = "#1f3a2a" +_GRID = "#33363d" +_DOT_R = 3 +_TICKS = 4 +# Keep a flat line off the frame: pad the value range by this much (kg). +_MIN_SPAN_KG = 2.0 +# Include a healthy-band edge on the axis only when it is this close (kg). +_BAND_REACH_KG = 5.0 +# Headroom above and below the data, as a fraction of its range. +_PAD_FRACTION = 0.1 + + +@dataclass(frozen=True) +class GraphBox: + """Canvas size and the inner plot margins (px).""" + + width: int + height: int + left: int = 56 + right: int = 16 + top: int = 16 + bottom: int = 28 + + def x(self, frac: float) -> float: + """Canvas x for a 0..1 position along the time axis.""" + return self.left + frac * (self.width - self.left - self.right) + + def y(self, frac: float) -> float: + """Canvas y for a 0..1 position up the value axis.""" + return self.height - self.bottom - frac * (self.height - self.top - self.bottom) + + +def in_range( + weights: dict[str, float], today: date, days: int | None +) -> dict[str, float]: + """The weigh-ins within the last ``days`` days (all when None).""" + if days is None: + return dict(weights) + first = (today - timedelta(days=days)).isoformat() + return {day: kg for day, kg in weights.items() if day >= first} + + +def value_span( + values: list[float], band: tuple[float, float] | None +) -> tuple[float, float]: + """Low/high of the value axis: the data, padded, plus a *nearby* band edge. + + A band edge more than :data:`_BAND_REACH_KG` from the data is left off the + axis: pulling a 60 kg floor under an 84-to-82 kg trend flattens the trend + into one line, and the trend is what the graph is for. + """ + low, high = min(values), max(values) + if band is not None: + for edge in band: + if low - _BAND_REACH_KG <= edge <= high + _BAND_REACH_KG: + low, high = min(low, edge), max(high, edge) + pad = max((high - low) * _PAD_FRACTION, _MIN_SPAN_KG / 2) + return low - pad, high + pad + + +def graph_points( + weights: dict[str, float], box: GraphBox, span: tuple[float, float] +) -> list[tuple[float, float]]: + """Canvas points for each weigh-in, time on x (by date), kg on y.""" + days = sorted(weights) + first = date.fromisoformat(days[0]) + total = max((date.fromisoformat(days[-1]) - first).days, 1) + low, high = span + return [ + ( + box.x( + (date.fromisoformat(day) - first).days / total if len(days) > 1 else 0.5 + ), + box.y((weights[day] - low) / (high - low)), + ) + for day in days + ] + + +def value_ticks(span: tuple[float, float]) -> list[float]: + """Evenly spaced gridline values across the axis, ends included.""" + low, high = span + return [low + (high - low) * i / _TICKS for i in range(_TICKS + 1)] + + +def _draw_band( + canvas: tk.Canvas, + box: GraphBox, + span: tuple[float, float], + healthy: tuple[float, float], +) -> None: + """Shade the healthy-BMI weights, clipped to the axis (skipped if off it).""" + low, high = span + if healthy[0] >= high or healthy[1] <= low: + return + top = box.y((min(healthy[1], high) - low) / (high - low)) + bottom = box.y((max(healthy[0], low) - low) / (high - low)) + canvas.create_rectangle( + box.x(0), top, box.x(1), bottom, fill=_HEALTHY_FILL, outline="" + ) + font = (_COLORS.typography.font_family, CAPTION) + canvas.create_text( + box.x(1) - 4, top + 2, text="healthy BMI", anchor="ne", fill=_MUTED, font=font + ) + + +def draw_weight_graph( + canvas: tk.Canvas, + weights: dict[str, float], + box: GraphBox, + healthy: tuple[float, float] | None, +) -> None: + """Redraw the whole graph; an empty log shows a hint instead.""" + canvas.delete("all") + font = (_COLORS.typography.font_family, CAPTION) + if not weights: + canvas.create_text( + box.width / 2, + box.height / 2, + text="Nothing logged in this range yet.", + fill=_MUTED, + font=font, + ) + return + span = value_span(list(weights.values()), healthy) + low, high = span + if healthy is not None: + _draw_band(canvas, box, span, healthy) + for value in value_ticks(span): + y = box.y((value - low) / (high - low)) + canvas.create_line(box.x(0), y, box.x(1), y, fill=_GRID) + canvas.create_text( + box.left - 6, y, text=f"{value:.1f}", anchor="e", fill=_MUTED, font=font + ) + points = graph_points(weights, box, span) + if len(points) > 1: + canvas.create_line(*[c for p in points for c in p], fill=_ACCENT, width=2) + for x, y in points: + canvas.create_oval( + x - _DOT_R, y - _DOT_R, x + _DOT_R, y + _DOT_R, fill=_ACCENT, outline=BG + ) + days = sorted(weights) + base = box.height - box.bottom + 14 + canvas.create_text(box.x(0), base, text=days[0], anchor="w", fill=FG, font=font) + canvas.create_text(box.x(1), base, text=days[-1], anchor="e", fill=FG, font=font) diff --git a/diet_guard/_gatelock_body_input.py b/diet_guard/_gatelock_body_input.py new file mode 100644 index 0000000..0ade485 --- /dev/null +++ b/diet_guard/_gatelock_body_input.py @@ -0,0 +1,62 @@ +"""Parse and seed the Body tab's inputs -- pure over the tab's variables. + +Split from :mod:`diet_guard._gatelock_body` for the 250-line cap. No widget +is built here; the functions only read and write ``StringVar``-likes. +""" + +from __future__ import annotations + +from datetime import date +from typing import TYPE_CHECKING + +from diet_guard._body_energy import ACTIVITY_CHOICES, activity_label +from diet_guard._body_prefs import goal_from_json + +if TYPE_CHECKING: + from diet_guard._body_energy import Goal + from diet_guard._gatelock_body_vars import GoalVars + +__all__ = ["HEIGHT_CM", "goal_choice", "load_goal", "parse_day", "parse_float"] + +# Outside this range (cm) a height is a typo rather than a person. +HEIGHT_CM = (50.0, 260.0) + + +def parse_float(text: str) -> float | None: + """``text`` as a float (comma decimals accepted), or None.""" + try: + return float(text.strip().replace(",", ".")) + except ValueError: + return None + + +def parse_day(text: str) -> str | None: + """``text`` as a normalised ``YYYY-MM-DD``, or None.""" + try: + return date.fromisoformat(text.strip()).isoformat() + except ValueError: + return None + + +def load_goal(goal_vars: GoalVars, goal: Goal) -> None: + """Put ``goal`` into the sentence's pickers.""" + goal_vars.direction.set(goal.direction) + goal_vars.amount.set(f"{goal.amount:g}") + goal_vars.weight_unit.set(goal.weight_unit) + goal_vars.time_unit.set(goal.time_unit) + goal_vars.activity.set(activity_label(goal.activity)) + + +def goal_choice(goal_vars: GoalVars, fallback: Goal) -> Goal: + """The goal the pickers show; an unreadable amount keeps ``fallback``'s.""" + labels = {activity_label(key): key for key in ACTIVITY_CHOICES} + amount = parse_float(goal_vars.amount.get()) + return goal_from_json( + { + "dir": goal_vars.direction.get(), + "amt": fallback.amount if amount is None or amount < 0 else amount, + "wu": goal_vars.weight_unit.get(), + "tu": goal_vars.time_unit.get(), + "act": labels.get(goal_vars.activity.get(), fallback.activity), + } + ) diff --git a/diet_guard/_gatelock_body_vars.py b/diet_guard/_gatelock_body_vars.py new file mode 100644 index 0000000..3172a81 --- /dev/null +++ b/diet_guard/_gatelock_body_vars.py @@ -0,0 +1,122 @@ +"""The Body tab's variables and callback bundle. + +Split from :mod:`diet_guard._gatelock_body_widgets` for the 250-line cap, and +grouped into small bundles (profile, logs, goal, plan) rather than one flat +record, so each stays under pylint's instance-attribute limit. + +.. note:: + Creates ``tkinter`` variables, so it **must** appear in + ``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import tkinter as tk +from typing import TYPE_CHECKING + +from diet_guard._week_plan import WEEKDAYS + +if TYPE_CHECKING: + from collections.abc import Callable + +__all__ = [ + "BLOCKS", + "BodyCallbacks", + "BodyVars", + "BodyWidgets", +] + +# The read-only text panels, each a monospace block the controller fills. +BLOCKS: tuple[str, ...] = ( + "summary", + "table", + "bodyfat", + "bmi", + "ideal", + "bmr", + "workouts", + "plan", +) + + +def _var(value: str = "") -> tk.StringVar: + return tk.StringVar(value=value) + + +@dataclass +class ProfileVars: + """Birth date, height and sex inputs.""" + + birth: tk.StringVar = field(default_factory=_var) + height: tk.StringVar = field(default_factory=_var) + sex: tk.StringVar = field(default_factory=_var) + + +@dataclass +class LogVars: + """The weight and body-fat inputs, and which one the graph shows.""" + + weight: tk.StringVar = field(default_factory=_var) + weight_day: tk.StringVar = field(default_factory=_var) + body_fat: tk.StringVar = field(default_factory=_var) + fat_day: tk.StringVar = field(default_factory=_var) + graph: tk.StringVar = field(default_factory=lambda: _var("weight")) + + +@dataclass +class GoalVars: + """The goal sentence's five choices (activity holds its menu label).""" + + direction: tk.StringVar = field(default_factory=_var) + amount: tk.StringVar = field(default_factory=_var) + weight_unit: tk.StringVar = field(default_factory=_var) + time_unit: tk.StringVar = field(default_factory=_var) + activity: tk.StringVar = field(default_factory=_var) + answer: tk.StringVar = field(default_factory=_var) + + +@dataclass +class PlanVars: + """The week planner's target and per-day overrides.""" + + target: tk.StringVar = field(default_factory=_var) + days: tuple[tk.StringVar, ...] = field( + default_factory=lambda: tuple(_var() for _ in WEEKDAYS) + ) + + +@dataclass +class BodyVars: + """Every input bundle, the status line and the text panels.""" + + profile: ProfileVars = field(default_factory=ProfileVars) + logs: LogVars = field(default_factory=LogVars) + goal: GoalVars = field(default_factory=GoalVars) + plan: PlanVars = field(default_factory=PlanVars) + status: tk.StringVar = field(default_factory=_var) + blocks: dict[str, tk.StringVar] = field( + default_factory=lambda: {name: _var() for name in BLOCKS} + ) + + +@dataclass(frozen=True) +class BodyCallbacks: + """What the tab's buttons do.""" + + save_profile: Callable[[], None] + save_weight: Callable[[], None] + delete_weight: Callable[[], None] + save_body_fat: Callable[[], None] + delete_body_fat: Callable[[], None] + goal_changed: Callable[[], None] + set_range: Callable[[int | None], None] + recalc: Callable[[], None] + + +@dataclass(frozen=True) +class BodyWidgets: + """The widgets the controller touches after building.""" + + frame: tk.Frame + canvas: tk.Canvas diff --git a/diet_guard/_gatelock_body_widgets.py b/diet_guard/_gatelock_body_widgets.py new file mode 100644 index 0000000..0c2fff5 --- /dev/null +++ b/diet_guard/_gatelock_body_widgets.py @@ -0,0 +1,220 @@ +"""Widget builders for the gate's Body tab. + +Split from :mod:`diet_guard._gatelock_body` (the controller) for the 250-line +cap. Everything here only builds and packs; every value lives in +:class:`~diet_guard._gatelock_body_vars.BodyVars` and every action is a +callback. + +Layout, top to bottom: the goal sentence ("To lose 0.5 kg / week on +Moderate eat 2019 kcal", every choice a drop-down), then weight with the +graph beside BMI, ideal weight and resting burn, then workouts and the week +plan, then the profile (set up once -- first instead while incomplete) and +last the body-fat log. + +.. note:: + Builds ``tkinter`` widgets, so it **must** appear in + ``diet_guard.tests._gate_fixtures._GATE_TK_MODULES``. +""" + +from __future__ import annotations + +import tkinter as tk +from typing import TYPE_CHECKING + +from gatelock import ButtonStyle, make_button + +from diet_guard._body_energy import ( + ACTIVITY_CHOICES, + DIRECTIONS, + PRESET_AMOUNTS, + TIME_UNITS, + WEIGHT_UNITS, + activity_label, +) +from diet_guard._gatelock_body_graph import GRAPH_RANGES +from diet_guard._gatelock_body_vars import BodyWidgets +from diet_guard._gatelock_calendar_types import _ACCENT, _COLORS, _FIELD_BG, _MUTED +from diet_guard._gatelock_spacing import MD, SM, XS +from diet_guard._gatelock_typography import BODY, LABEL, SUBTITLE, TITLE +from diet_guard._gatelock_ui import BG, FG +from diet_guard._week_plan import WEEKDAYS + +if TYPE_CHECKING: + from collections.abc import Callable + + from diet_guard._gatelock_body_vars import BodyCallbacks, BodyVars + +__all__ = ["GRAPH_H", "GRAPH_W", "build_body_frame"] + +GRAPH_W = 720 +GRAPH_H = 300 +_MONO = ("Courier", LABEL) +_SECONDARY = ButtonStyle(variant="secondary", bold=False) +_TOGGLE = {"bg": BG, "fg": FG, "selectcolor": _FIELD_BG, "activebackground": BG} + + +def _font(size: int = LABEL) -> tuple[str, int]: + return (_COLORS.typography.font_family, size) + + +def _label(parent: tk.Misc, text: str, size: int = LABEL) -> None: + tk.Label(parent, text=text, font=_font(size), bg=BG, fg=FG).pack( + side="left", padx=(0, XS) + ) + + +def _entry(parent: tk.Misc, var: tk.StringVar, width: int, size: int = BODY) -> None: + tk.Entry( + parent, + textvariable=var, + width=width, + font=_font(size), + bg=_FIELD_BG, + fg=FG, + insertbackground=FG, + ).pack(side="left", padx=(0, SM)) + + +def _row(parent: tk.Misc) -> tk.Frame: + row = tk.Frame(parent, bg=BG) + row.pack(anchor="w", pady=(XS, XS)) + return row + + +def _block(parent: tk.Misc, var: tk.StringVar) -> None: + tk.Label( + parent, textvariable=var, font=_MONO, bg=BG, fg=FG, justify="left", anchor="w" + ).pack(anchor="w", pady=(SM, 0)) + + +def _button(parent: tk.Misc, text: str, command: Callable[[], None]) -> None: + make_button(parent, _COLORS, text, command, _SECONDARY).pack( + side="left", padx=(0, SM) + ) + + +def _menu( + parent: tk.Misc, var: tk.StringVar, values: list[str], cb: BodyCallbacks +) -> None: + """A drop-down that re-answers the goal whenever a value is picked.""" + menu = tk.OptionMenu(parent, var, *values, command=lambda _v: cb.goal_changed()) + menu.config(font=_font(SUBTITLE), bg=_FIELD_BG, fg=_ACCENT, highlightthickness=0) + menu.pack(side="left", padx=(0, XS)) + + +def _build_goal(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None: + """``To [lose] [0.5][v] [kg] / [week] on [Moderate] eat 2019 kcal``.""" + goal = vars_.goal + row = _row(parent) + _label(row, "To", SUBTITLE) + _menu(row, goal.direction, list(DIRECTIONS), cb) + _entry(row, goal.amount, 5, SUBTITLE) + _menu(row, goal.amount, [f"{a:g}" for a in PRESET_AMOUNTS], cb) + _menu(row, goal.weight_unit, list(WEIGHT_UNITS), cb) + _label(row, "/", SUBTITLE) + _menu(row, goal.time_unit, list(TIME_UNITS), cb) + _label(row, "on", SUBTITLE) + _menu(row, goal.activity, [activity_label(k) for k in ACTIVITY_CHOICES], cb) + tk.Label( + row, textvariable=goal.answer, font=(*_font(TITLE), "bold"), bg=BG, fg=_ACCENT + ).pack(side="left", padx=(MD, 0)) + _button(row, "Apply typed amount", cb.goal_changed) + _block(parent, vars_.blocks["table"]) + + +def _build_profile(parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None: + row = _row(parent) + _label(row, "Profile -- born (YYYY-MM-DD)") + _entry(row, vars_.profile.birth, 11) + _label(row, "Height cm") + _entry(row, vars_.profile.height, 6) + sex = vars_.profile.sex + for value, text in (("m", "male"), ("f", "female")): + tk.Radiobutton(row, text=text, value=value, variable=sex, **_TOGGLE).pack( + side="left" + ) + _button(row, "Save profile", cb.save_profile) + _block(parent, vars_.blocks["summary"]) + + +def _dated_row( + parent: tk.Misc, + label: str, + vars_pair: tuple[tk.StringVar, tk.StringVar], + actions: tuple[Callable[[], None], Callable[[], None]], +) -> None: + row = _row(parent) + _label(row, label) + _entry(row, vars_pair[0], 6) + _label(row, "on") + _entry(row, vars_pair[1], 11) + _button(row, "Save", actions[0]) + _button(row, "Delete that day", actions[1]) + + +def _build_left(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> tk.Canvas: + logs = vars_.logs + pair = (logs.weight, logs.weight_day) + _dated_row(col, "Weight kg", pair, (cb.save_weight, cb.delete_weight)) + row = _row(col) + for value, text in (("weight", "Weight"), ("fat", "Body fat")): + tk.Radiobutton( + row, + text=text, + value=value, + variable=logs.graph, + command=cb.recalc, + **_TOGGLE, + ).pack(side="left") + for text, days in GRAPH_RANGES: + _button(row, text, lambda d=days: cb.set_range(d)) + canvas = tk.Canvas(col, width=GRAPH_W, height=GRAPH_H, bg=BG, highlightthickness=0) + canvas.pack(anchor="w", pady=(SM, 0)) + tk.Label(col, textvariable=vars_.status, font=_font(), bg=BG, fg=_MUTED).pack( + anchor="w" + ) + _block(col, vars_.blocks["workouts"]) + return canvas + + +def _build_right(col: tk.Misc, vars_: BodyVars, cb: BodyCallbacks) -> None: + for name in ("bmi", "ideal", "bmr"): + _block(col, vars_.blocks[name]) + row = _row(col) + _label(row, "Week average target kcal") + _entry(row, vars_.plan.target, 6) + for name, var in zip(WEEKDAYS, vars_.plan.days, strict=True): + _label(row, name) + _entry(row, var, 5) + _button(row, "Plan week", cb.recalc) + _block(col, vars_.blocks["plan"]) + + +def build_body_frame( + parent: tk.Misc, vars_: BodyVars, cb: BodyCallbacks, *, profile_first: bool +) -> BodyWidgets: + """Build the whole tab into ``parent``; return what the controller needs.""" + frame = tk.Frame(parent, bg=BG) + tk.Label(frame, text="Body", font=(*_font(TITLE), "bold"), bg=BG, fg=FG).pack( + pady=(MD, SM) + ) + inner = tk.Frame(frame, bg=BG) + inner.pack(fill="both", expand=True, padx=MD) + if profile_first: + _build_profile(inner, vars_, cb) + _build_goal(inner, vars_, cb) + columns = tk.Frame(inner, bg=BG) + columns.pack(anchor="w", pady=(MD, 0)) + left = tk.Frame(columns, bg=BG) + left.pack(side="left", anchor="n", padx=(0, MD)) + right = tk.Frame(columns, bg=BG) + right.pack(side="left", anchor="n") + canvas = _build_left(left, vars_, cb) + _build_right(right, vars_, cb) + if not profile_first: + _build_profile(inner, vars_, cb) + logs = vars_.logs + pair = (logs.body_fat, logs.fat_day) + _dated_row(inner, "Body fat %", pair, (cb.save_body_fat, cb.delete_body_fat)) + _block(inner, vars_.blocks["bodyfat"]) + return BodyWidgets(frame=frame, canvas=canvas) diff --git a/diet_guard/_gatelock_calendar.py b/diet_guard/_gatelock_calendar.py index 4f41ca8..5983d55 100644 --- a/diet_guard/_gatelock_calendar.py +++ b/diet_guard/_gatelock_calendar.py @@ -37,6 +37,7 @@ from diet_guard._calendar_view import ( ytd_text, ) from diet_guard._daystatus import DayStatus, status_map +from diet_guard._gatelock_body import BodyTab from diet_guard._gatelock_calendar_ui import ( _DECEMBER, _DEFAULT_BUDGET_KCAL, @@ -118,6 +119,7 @@ class _GateCalendar(_GateScheduleEdit): ) cal_widgets = build_calendar_frame(notebook, self._cal_vars, cal_callbacks) notebook.add(cal_widgets.frame, text="History") + notebook.add(BodyTab(notebook).container, text="Body") # Ctrl+Tab / Ctrl+PageUp / Ctrl+PageDown and Alt+mnemonic tab switching # all live in toplevel bindings that ttk only installs on request. The # notebook is in the focus ring and Left/Right work once it holds focus, diff --git a/diet_guard/_manual_workout_parse.py b/diet_guard/_manual_workout_parse.py new file mode 100644 index 0000000..97b4c94 --- /dev/null +++ b/diet_guard/_manual_workout_parse.py @@ -0,0 +1,110 @@ +"""Read a screen-locker manual workout's free text for sport and distance. + +A manual entry's sport field is often a generic "other" while the typed text +says "Walking at Ai clearing..." -- and a distance, when there is one, lives +in the prose ("5 km", "5,2km", "3000 m"). Without this, a walk logged by +hand gets only the flat MET estimate even though the user typed its length. + +Duration never comes from here: ``duration_minutes`` is a structured field. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import re + +from diet_guard._activity_kcal import Session + +__all__ = ["logged_window", "manual_session"] + +_WALK_RE = re.compile(r"\b(walk|walking|walked|hike|hiking|hiked)\b", re.IGNORECASE) +_RUN_RE = re.compile(r"\b(run|running|ran|jog|jogging|jogged)\b", re.IGNORECASE) +_DISTANCE_RE = re.compile( + r"(\d+(?:[.,]\d+)?)\s*(km|kilomet(?:er|re)s?|m|met(?:er|re)s?)\b", + re.IGNORECASE, +) +_METRES_PER_KM = 1000.0 +_SPORT_KINDS = { + "running": "run", + "walking": "walk", + "hiking": "walk", + "cycling": "cycle", + "biking": "cycle", + "strength": "strength", + "gym": "strength", +} + + +def _text(item: dict[str, object]) -> str: + """Every free-text field of the entry, joined.""" + return " ".join(str(v) for v in item.values() if isinstance(v, str)) + + +def _kind(item: dict[str, object], text: str) -> str: + """The sport field if it is specific, else what the prose says.""" + sport = str(item.get("activity_type") or item.get("sport") or "").lower() + if sport in _SPORT_KINDS: + return _SPORT_KINDS[sport] + if _RUN_RE.search(text): + return "run" + if _WALK_RE.search(text): + return "walk" + return "other" + + +def _typed_km(item: dict[str, object]) -> float | None: + """A structured ``distance_km``, when present and numeric.""" + raw = item.get("distance_km") + try: + return float(str(raw)) if raw is not None else None + except ValueError: + return None + + +def _text_km(text: str) -> float | None: + """The first distance written in ``text``, in km, or None.""" + match = _DISTANCE_RE.search(text) + if match is None: + return None + value = float(match.group(1).replace(",", ".")) + in_km = match.group(2).lower().startswith("k") + return value if in_km else value / _METRES_PER_KM + + +def logged_window(stamp: object, minutes: float) -> tuple[str | None, str | None]: + """``(start, end)`` for a workout logged at ``stamp`` as it finished. + + screen-locker stamps a verified workout when it is logged, i.e. at its + end, so the start is ``minutes`` earlier. Unparsable stamp -> no window. + """ + try: + end = datetime.fromisoformat(str(stamp)).astimezone() + except ValueError: + return None, None + start = end - timedelta(minutes=minutes) + return start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds") + + +def _clock_window(day: str, item: dict[str, object]) -> tuple[str | None, str | None]: + """``(start, end)`` from a manual entry's ``HH:MM`` start/end fields.""" + try: + start = datetime.fromisoformat(f"{day}T{item['start_time']}").astimezone() + end = datetime.fromisoformat(f"{day}T{item['end_time']}").astimezone() + except KeyError, ValueError: + return None, None + if end < start: # past midnight + end += timedelta(days=1) + return start.isoformat(timespec="seconds"), end.isoformat(timespec="seconds") + + +def manual_session( + day: str, item: dict[str, object], minutes: float, label: str +) -> Session: + """The :class:`Session` one manual workout stands for.""" + text = _text(item) + kind = _kind(item, text) + km = _typed_km(item) + if km is None and kind in {"run", "walk"}: + km = _text_km(text) + start, end = _clock_window(day, item) + return Session(day, kind, minutes, km, label, "screen-locker", start, end) diff --git a/diet_guard/_phone_weight.py b/diet_guard/_phone_weight.py index 7e875f4..5032f92 100644 --- a/diet_guard/_phone_weight.py +++ b/diet_guard/_phone_weight.py @@ -1,34 +1,30 @@ -"""Carry the phone's morning weigh-in into the budget's stored weight. +"""Carry the newest logged weight into the budget's stored weight. -The wake-alarm phone app ends every morning session with a typed weight, -published as ``latest_weight_kg`` on its synced record; ``wake_alarm._weight`` -reads it on the PC. That weight is what the protein target here is derived -from (``_budget_derived.protein_target_g``), so after each successful -``sync`` the stored ``w`` is refreshed from it. +The Body weight log (:mod:`diet_guard._body_store`) holds every weigh-in -- +the phone's morning ones, ingested each tick by :mod:`diet_guard._sync_body`, +and any typed on either device. Its newest entry is what the protein target +here is derived from (``_budget_derived.protein_target_g``), so after each +successful ``sync`` the stored ``w`` is refreshed from it. -Only ``w`` moves. The kcal budget is not recomputed -- biometrics are -discarded by design on this side (see ``_budget_biometrics``), so there is -nothing to recompute it *from*. ``t`` is refreshed with ``w`` because both -ride the same edit timestamp into the per-field last-write-wins merge -(``sync_merge._budget``); a new weight under an old ``t`` would lose to -every other device on the next merge. - -``wake_alarm`` is an optional neighbour, imported lazily: a machine without -it (or with a broken install) syncs exactly as before and simply never -refreshes the weight. +Only ``w`` moves. The kcal budget is never recomputed -- the Body tab's +calorie table is informational, and the budget stays the number the user +edits. ``t`` is refreshed with ``w`` because both ride the same edit +timestamp into the per-field last-write-wins merge (``sync_merge._budget``); +a new weight under an old ``t`` would lose to every other device on the next +merge. """ from __future__ import annotations -from importlib import import_module from typing import TYPE_CHECKING +from diet_guard._body_store import newest_weight from diet_guard._budget import _now_local, read_raw_record, write_raw_record if TYPE_CHECKING: from collections.abc import Callable -__all__ = ["refresh_weight_from_phone", "update_weight"] +__all__ = ["refresh_weight_from_log", "update_weight"] # Below this the scale and the record agree; a rewrite would only churn ``t``. _SAME_WEIGHT_KG = 0.05 @@ -55,14 +51,12 @@ def _budget_edit_day(record: dict[str, object]) -> str: return str(record.get("t", ""))[:10] -def refresh_weight_from_phone(emit: Callable[[str], None]) -> None: - """Apply the phone's newest weigh-in to the stored weight, if it is newer. +def refresh_weight_from_log(emit: Callable[[str], None]) -> None: + """Apply the weight log's newest weigh-in to the stored weight, if newer. - Skipped, silently, when: ``wake_alarm`` is not installed; nothing has - been published (or the backend is unreadable -- ``latest_weight`` never - raises); no budget exists; the weigh-in is not from a *later* day than - the budget's last edit; or it matches the stored weight within - :data:`_SAME_WEIGHT_KG`. + Skipped, silently, when: the log is empty; no budget exists; the + weigh-in is not from a *later* day than the budget's last edit; or it + matches the stored weight within :data:`_SAME_WEIGHT_KG`. The day comparison is strict. A weigh-in happens in the morning and a manual budget edit at any time of day, and ``t`` carries only the day's @@ -74,24 +68,21 @@ def refresh_weight_from_phone(emit: Callable[[str], None]) -> None: Args: emit: One-line output sink, given a line only when a write happened. """ - try: - weight_module = import_module("wake_alarm._weight") - except ImportError: - return - weight = weight_module.latest_weight() - if weight is None: + newest = newest_weight() + if newest is None: return + day, kg = newest record = read_raw_record() if record is None: return - if weight.date <= _budget_edit_day(record): + if day <= _budget_edit_day(record): return stored = record.get("w") if ( isinstance(stored, (int, float)) and not isinstance(stored, bool) - and abs(float(stored) - weight.kg) < _SAME_WEIGHT_KG + and abs(float(stored) - kg) < _SAME_WEIGHT_KG ): return - update_weight(weight.kg) - emit(f"weight: {weight.kg} kg from the phone ({weight.date}).") + update_weight(kg) + emit(f"weight: {kg} kg from the weight log ({day}).") diff --git a/diet_guard/_sync.py b/diet_guard/_sync.py index 727419d..c3589f5 100644 --- a/diet_guard/_sync.py +++ b/diet_guard/_sync.py @@ -37,13 +37,14 @@ from diet_guard._constants import ( ) from diet_guard._device import device_identity from diet_guard._foodbank_rebuild import rebuild_food_bank -from diet_guard._phone_weight import refresh_weight_from_phone +from diet_guard._phone_weight import refresh_weight_from_log from diet_guard._state_sync import ( read_raw_log, resign_entry, write_raw_log, ) from diet_guard._sync_banks import _sync_budget, _sync_food_bank, _sync_manual_bank +from diet_guard._sync_body import sync_body from diet_guard._sync_client import _client_for_run from diet_guard._sync_errors import SyncError from diet_guard._sync_kuchnia import _sync_kuchnia_credential @@ -182,6 +183,7 @@ def run_sync() -> DayLog: _sync_food_bank(client, device_ids) _sync_manual_bank(client, device_ids) _sync_kuchnia_credential(client, device_ids) + sync_body(client, device_ids) push_log = daylog_to_log(resigned) push_json = json.dumps( @@ -207,7 +209,7 @@ def run_sync() -> DayLog: # the merged winner, so the refreshed ``t`` re-asserts nothing stale. # Here rather than in the ``sync`` subcommand because on the PC a full # tick runs from ``publish_after_log``, never from that command. - refresh_weight_from_phone(lambda line: _logger.info("%s", line)) + refresh_weight_from_log(lambda line: _logger.info("%s", line)) return resigned diff --git a/diet_guard/_sync_body.py b/diet_guard/_sync_body.py new file mode 100644 index 0000000..47a474d --- /dev/null +++ b/diet_guard/_sync_body.py @@ -0,0 +1,84 @@ +"""Pull/merge/push for the Body document, plus the two things that feed it. + +Runs inside the full sync tick (:func:`diet_guard._sync.run_sync`), after +the budget. Order matters: + +1. **Ingest the phone's weigh-ins** from wake-alarm's record + (:mod:`diet_guard._body_ingest`), so a morning weigh-in lands in the log + even when the phone's diet-guard app never opened. +2. **Publish workouts** (:mod:`diet_guard._activity_sources`) -- PC only, and + only when a source exists here, so a machine without RunnerUp or + screen-locker can never publish an empty list over the real one. +3. **Merge** with every peer's ``body.json`` (per-field LWW), persist, push. + +Body is a Firebase-era document, so peers are read from the primary only +(:func:`diet_guard._sync_banks._primary_only_text`). +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING + +from crdt_sync import merge_logs + +from diet_guard._activity_sources import collect_sessions, sources_available +from diet_guard._body_activity_store import set_activity +from diet_guard._body_ingest import fetch_alarm_logs, weight_candidates +from diet_guard._body_store import apply_weight_candidates, read_body, write_body +from diet_guard._device import device_identity +from diet_guard._state import now_local +from diet_guard._sync_banks import _primary_only_text +from diet_guard._sync_paths import _device_body_path +from diet_guard.sync_merge._body import ( + body_to_log, + encode_body_for_push, + log_to_body, + parse_remote_body, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + + from crdt_sync import RemoteStore + +_logger = logging.getLogger(__name__) + + +def publish_activity() -> None: + """Refresh the published workouts from this machine's sources, if any.""" + if not sources_available(): + return + if set_activity(collect_sessions(now_local().date())): + _logger.info("activity: published updated workout sessions") + + +def sync_body(client: RemoteStore, device_ids: Sequence[str]) -> None: + """Ingest weigh-ins, publish workouts, then merge and push ``body.json``.""" + applied = apply_weight_candidates(weight_candidates(fetch_alarm_logs(client))) + if applied: + _logger.info("body: %d weigh-in(s) from the phone", applied) + publish_activity() + identity = device_identity() + merged = body_to_log(read_body()) + for device_id in device_ids: + if identity.is_own(device_id): + continue + text = _primary_only_text(client, _device_body_path(device_id)) + if text is None: + continue + try: + merged = merge_logs(merged, parse_remote_body(text)) + except TypeError, KeyError, ValueError, json.JSONDecodeError: + _logger.warning( + "Unparsable body document from device %r, skipping", device_id + ) + if not merged: + return + write_body(log_to_body(merged)) + client.put_file_text( + _device_body_path(identity.device_id), + encode_body_for_push(merged), + message="diet_guard sync: body", + ) diff --git a/diet_guard/_sync_paths.py b/diet_guard/_sync_paths.py index 0d364e3..41332b9 100644 --- a/diet_guard/_sync_paths.py +++ b/diet_guard/_sync_paths.py @@ -47,3 +47,8 @@ def _device_kuchnia_path(device_id: str) -> str: be readable by anything that reads the budget. """ return f"{_DEVICES_DIR}/{device_id}/kuchnia.json" + + +def _device_body_path(device_id: str) -> str: + """Remote path of ``device_id``'s Body document (profile, weights, workouts).""" + return f"{_DEVICES_DIR}/{device_id}/body.json" diff --git a/diet_guard/_week_plan.py b/diet_guard/_week_plan.py new file mode 100644 index 0000000..b86b082 --- /dev/null +++ b/diet_guard/_week_plan.py @@ -0,0 +1,125 @@ +"""Weekly-average planner: what to eat on the rest of the week's days. + +"I want to average N kcal this week, and on Monday I ate 2500 -- what do I +eat on the other days?" The week is Monday-Sunday around ``today``. A day is +*known* when the user typed a number for it, or when it is already over +(before ``today``) and has meals in the log. Every other day -- today +included, since it is still in progress -- shares what is left evenly:: + + per_day = (target x 7 - sum(known)) / remaining_days + +Informational only, like the calorie table: it never touches the budget. +Pure over its arguments; mirrored in ``app/lib/services/week_plan.dart`` and +gated by the shared ``tests/fixtures/body_calc.json``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta + +from diet_guard._body_calc import half_up +from diet_guard._body_energy import MIN_TARGET_KCAL + +__all__ = ["WEEKDAYS", "DayPlan", "WeekPlan", "plan_week", "week_start"] + +WEEKDAYS: tuple[str, ...] = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + + +@dataclass(frozen=True) +class DayPlan: + """One day of the plan. + + Attributes: + day: ``YYYY-MM-DD``. + weekday: ``Mon`` .. ``Sun``. + kcal: What was eaten (known) or should be eaten (planned). + source: ``typed``, ``logged``, ``plan``, or ``unlogged`` -- a day + already over with nothing in the log, planned like a remaining + day (an unlogged day is not a zero-kcal day) so the screens can + ask for the real number. + """ + + day: str + weekday: str + kcal: int + source: str + + +@dataclass(frozen=True) +class WeekPlan: + """The whole week. + + Attributes: + days: Monday .. Sunday. + target_avg: The weekly average aimed for. + per_remaining: kcal for each ``plan`` day, or None when none remain. + week_avg: The average the plan produces (equals ``target_avg`` up to + rounding when anything remains; the achieved average otherwise). + """ + + days: tuple[DayPlan, ...] + target_avg: int + per_remaining: int | None + week_avg: float + + @property + def below_floor(self) -> bool: + """Whether the remaining days would have to go under 1200 kcal.""" + return self.per_remaining is not None and self.per_remaining < MIN_TARGET_KCAL + + +def _open_source(monday: date, index: int, today: date) -> str: + """``unlogged`` for a past day without data, ``plan`` for today onward.""" + return "unlogged" if monday + timedelta(days=index) < today else "plan" + + +def week_start(today: date) -> date: + """The Monday of ``today``'s week.""" + return today - timedelta(days=today.weekday()) + + +def plan_week( + today: date, + target_avg: int, + logged: dict[str, float], + typed: dict[int, int], +) -> WeekPlan: + """Plan the rest of ``today``'s week. + + Args: + today: The current local date. + target_avg: Desired mean kcal/day over the seven days. + logged: ``YYYY-MM-DD`` -> kcal eaten, from the food log. Only days + before ``today`` are used. + typed: Weekday index (0 = Monday) -> kcal the user typed; wins over + the log, and may name any day (a planned feast on Saturday too). + """ + monday = week_start(today) + known: dict[int, tuple[int, str]] = {} + for index in range(7): + day = monday + timedelta(days=index) + if index in typed: + known[index] = (typed[index], "typed") + elif day < today and day.isoformat() in logged: + known[index] = (int(half_up(logged[day.isoformat()])), "logged") + remaining = 7 - len(known) + eaten = sum(kcal for kcal, _ in known.values()) + per = int(half_up((target_avg * 7 - eaten) / remaining)) if remaining else None + days = tuple( + DayPlan( + day=(monday + timedelta(days=index)).isoformat(), + weekday=WEEKDAYS[index], + kcal=known[index][0] if index in known else int(per or 0), + source=known[index][1] + if index in known + else _open_source(monday, index, today), + ) + for index in range(7) + ) + return WeekPlan( + days=days, + target_avg=target_avg, + per_remaining=per, + week_avg=sum(d.kcal for d in days) / 7, + ) diff --git a/diet_guard/sync_merge/_body.py b/diet_guard/sync_merge/_body.py new file mode 100644 index 0000000..9361d4e --- /dev/null +++ b/diet_guard/sync_merge/_body.py @@ -0,0 +1,150 @@ +"""Body document <-> ``crdt_sync.Log`` adapters (profile, weights, workouts). + +Wire format in ``docs/DOCS-body.md``: record ``profile`` with one field per +profile value; one record per day for each of weights (``w:``), +body fat (``bf:...``) and phone-written steps (``st:...``); and one +``activity`` record carrying the PC's published sessions. Every field's Hlc +is derived from its own ``t`` edit stamp, so an unchanged document +re-derives identical clocks and re-syncing it is a no-op -- the same +determinism the budget adapter relies on. + +KEEP IN SYNC WITH ``app/lib/services/sync_merge_body.dart``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +import json +from typing import TYPE_CHECKING + +from crdt_sync import Hlc, Record + +from diet_guard._device import device_id +from diet_guard.sync_merge._clock import _wall_time_ms + +if TYPE_CHECKING: + from crdt_sync import Log + +PROFILE_RECORD_ID = "profile" +ACTIVITY_RECORD_ID = "activity" + + +@dataclass(frozen=True) +class DatedLog: + """One per-day section: its record prefix, field name and value keys.""" + + section: str + prefix: str + field: str + keys: tuple[str, ...] + + +# Weights, body fat and (phone-written) steps all sync the same way: one +# record per date, one field holding a small map, LWW by its edit stamp. +DATED_LOGS: tuple[DatedLog, ...] = ( + DatedLog("weights", "w:", "kg", ("kg", "src")), + DatedLog("bodyfat", "bf:", "pct", ("pct",)), + DatedLog("steps", "st:", "n", ("n",)), +) +_DEFAULTS: dict[str, object] = {"src": ""} + + +def _hlc(stamp: object) -> Hlc: + """The deterministic clock for an edit stamp (epoch when unparsable).""" + return Hlc.new_tick(device_id(), wall_time_ms=_wall_time_ms(str(stamp))) + + +def _stamp(hlc: Hlc) -> str: + """The local ISO edit stamp a merged field's clock stands for.""" + moment = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=UTC) + return moment.astimezone().isoformat(timespec="seconds") + + +def body_to_log(doc: dict[str, dict[str, object]]) -> Log: + """Convert the local Body document (see ``_body_store``) into a Log.""" + log: Log = {} + profile_fields = { + name: (cell.get("v"), _hlc(cell.get("t"))) + for name, cell in doc["profile"].items() + if isinstance(cell, dict) + } + if profile_fields: + log[PROFILE_RECORD_ID] = Record(id=PROFILE_RECORD_ID, fields=profile_fields) + for dated in DATED_LOGS: + for day, cell in doc[dated.section].items(): + if not isinstance(cell, dict): + continue + record_id = f"{dated.prefix}{day}" + value = {key: cell.get(key, _DEFAULTS.get(key)) for key in dated.keys} + hlc = _hlc(cell.get("t")) + log[record_id] = Record(id=record_id, fields={dated.field: (value, hlc)}) + sessions = doc["activity"].get("sessions") + if isinstance(sessions, list): + hlc = _hlc(doc["activity"].get("t")) + log[ACTIVITY_RECORD_ID] = Record( + id=ACTIVITY_RECORD_ID, fields={"sessions": (sessions, hlc)} + ) + return log + + +def _dated_cell(dated: DatedLog, record: Record) -> dict[str, object] | None: + """One dated record back as a local cell, or None when malformed.""" + if dated.field not in record.fields: + return None + value, hlc = record.fields[dated.field] + if not isinstance(value, dict): + return None + cell = {key: value.get(key, _DEFAULTS.get(key)) for key in dated.keys} + return {**cell, "t": _stamp(hlc)} + + +def log_to_body(log: Log) -> dict[str, dict[str, object]]: + """Convert a merged Log back into the local document shape.""" + doc: dict[str, dict[str, object]] = { + "profile": {}, + "activity": {}, + **{dated.section: {} for dated in DATED_LOGS}, + } + profile = log.get(PROFILE_RECORD_ID) + if profile is not None: + doc["profile"] = { + name: {"v": value, "t": _stamp(hlc)} + for name, (value, hlc) in profile.fields.items() + } + for record_id, record in log.items(): + for dated in DATED_LOGS: + if record_id.startswith(dated.prefix): + cell = _dated_cell(dated, record) + if cell is not None: + doc[dated.section][record_id[len(dated.prefix) :]] = cell + activity = log.get(ACTIVITY_RECORD_ID) + if activity is not None and "sessions" in activity.fields: + sessions, hlc = activity.fields["sessions"] + if isinstance(sessions, list): + doc["activity"] = {"sessions": sessions, "t": _stamp(hlc)} + return doc + + +def parse_remote_body(text: str) -> Log: + """Parse one device's pushed ``body.json`` into a Log. + + Raises: + TypeError: The top level is not a JSON object. + KeyError: Via ``Record.from_dict`` on a record missing a key. + ValueError: Invalid JSON, or a malformed clock string. + """ + raw = json.loads(text) + if not isinstance(raw, dict): + msg = f"top-level body payload is not a JSON object: {raw!r}" + raise TypeError(msg) + return {record_id: Record.from_dict(data) for record_id, data in raw.items()} + + +def encode_body_for_push(log: Log) -> str: + """Serialise a merged Log for push.""" + return json.dumps( + {record_id: record.to_dict() for record_id, record in log.items()}, + indent=2, + sort_keys=True, + ) diff --git a/diet_guard/tests/_gate_fixtures.py b/diet_guard/tests/_gate_fixtures.py index 0fbb944..517de0d 100644 --- a/diet_guard/tests/_gate_fixtures.py +++ b/diet_guard/tests/_gate_fixtures.py @@ -23,6 +23,8 @@ import pytest from diet_guard import ( _gatelock, + _gatelock_body_vars, + _gatelock_body_widgets, _gatelock_budgetedit, _gatelock_calendar, _gatelock_calendar_schedule, @@ -157,6 +159,10 @@ _GATE_TK_MODULES = ( # separately in fake_tk(). Listing it here fails with "does not have the # attribute 'tk'". _gatelock_calendar_widgets, + # The Body tab builds its own widgets and vars (the controller only + # wires them). + _gatelock_body_vars, + _gatelock_body_widgets, # The History tab's meal-schedule row builds its own tk widgets. _gatelock_calendar_schedule, # Builds the History tab's StringVars (make_calendar_vars); without the diff --git a/diet_guard/tests/_state_redirects.py b/diet_guard/tests/_state_redirects.py new file mode 100644 index 0000000..f690e21 --- /dev/null +++ b/diet_guard/tests/_state_redirects.py @@ -0,0 +1,152 @@ +"""The list of state/network redirects ``conftest._isolate_state`` applies. + +Split out of ``conftest.py`` to keep it under the 250-line cap. Every on-disk +state path and every network entry point a test could reach is patched here; +a new one missing from this list writes to (or reads from) the live machine. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import freedays + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import _patch + + +def state_redirects(tmp_path: Path) -> list[_patch[object]]: + """Every patch that points diet_guard state and network at ``tmp_path``.""" + return [ + # The shared free-day pool lives under ~/.local/share, outside every + # diet_guard path below. Without this a real free day on the + # developer's machine would make every "the gate is due" test here + # fail, for a reason that looks nothing like the cause. + patch( + "freedays._api.resolve_paths", + lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"), + ), + patch( + "diet_guard._budget.BUDGET_FILE", + tmp_path / ".budget", + ), + patch( + "diet_guard._budget_history.BUDGET_HISTORY_FILE", + tmp_path / ".budget_history", + ), + patch( + "diet_guard._meal_schedule_store.MEAL_SCHEDULE_FILE", + tmp_path / ".meal_schedule", + ), + patch( + "diet_guard._state.FOOD_LOG_FILE", + tmp_path / "food_log.json", + ), + patch( + "diet_guard._foodbank.FOOD_BANK_FILE", + tmp_path / "food_bank.json", + ), + patch( + "diet_guard._foodbank_manual.MANUAL_BANK_FILE", + tmp_path / "food_bank_manual.json", + ), + patch( + "diet_guard._gatelock_lockfile.GATE_LOCK_FILE", + tmp_path / ".gate.lock", + ), + patch( + "diet_guard._sync_client.SYNC_TOKEN_FILE", + tmp_path / "sync_token", + ), + patch( + "diet_guard._sync.SYNC_STATE_FILE", + tmp_path / "sync_state.json", + ), + # Without this a test that syncs mints a uuid into the REAL + # ~/.local/share/diet_guard/.device_id, and this machine's live sync + # identity is decided by whichever test happened to run first. + patch( + "diet_guard._device.SYNC_DEVICE_ID_FILE", + tmp_path / ".device_id", + ), + # `run_sync` reads this to decide whether to build a Firebase-primary + # mirror. On a developer machine the real file exists, so without this + # every sync test would sign in and push to the live database. + patch( + "diet_guard._sync_client.CONFIG_FILE", + tmp_path / "nonexistent-firebase.json", + ), + # Every full tick ends by copying the weight log's newest weigh-in + # into the budget's `w`. `test_sync_budget` asserts the call; every + # other sync test keeps the budget record untouched by it. + patch("diet_guard._sync.refresh_weight_from_log", return_value=None), + # Logging a meal now publishes immediately (`_sync_events`), so every + # test that logs one would otherwise open a real connection through a + # path that has nothing to do with what it is asserting. Patched at the + # *call sites* rather than on `_sync_events`, so a test that imports + # `publish_after_log` directly still exercises the real helper. + patch("diet_guard._cli_log.publish_after_log_detached", return_value=None), + patch("diet_guard._cli_gate.publish_after_log", return_value=None), + patch("diet_guard._mcp.publish_after_log", return_value=None), + patch("diet_guard._cli_body.publish_after_log", return_value=None), + # The gate's pre-lock refresh is a *second*, independent network + # entry point (narrow peer-log pull, not the full tick). Without + # this every test that reaches `_should_lock` hits the real remote. + patch("diet_guard._cli_gate.pull_peer_logs", return_value=None), + # The catering credentials and its cached session cookie. Redirected + # for the same reason as `sync_token`: `_test_guard` raises on any + # write under the real ~/.config/diet_guard, and a test that logs in + # would otherwise clobber the live session. Deliberately NOT added to + # `test_state_redirect._REDIRECTED_CONSTANTS`, matching + # `SYNC_TOKEN_FILE` -- that check enforces a single naming module, + # which would forbid the CLI naming the path in its own setup text. + patch( + "diet_guard._kuchnia_config.KUCHNIA_CREDENTIALS_FILE", + tmp_path / "kuchnia_credentials", + ), + patch( + "diet_guard._kuchnia_config.KUCHNIA_SESSION_FILE", + tmp_path / "kuchnia_session.json", + ), + patch( + "diet_guard._kuchnia_credential_store.KUCHNIA_SYNCED_CREDENTIAL_FILE", + tmp_path / "kuchnia_synced_credential.json", + ), + patch( + "diet_guard._kuchnia_config.KUCHNIA_LAST_IMPORT_FILE", + tmp_path / "kuchnia_last_import", + ), + # The catering fetch is a third network entry point. Patched at each + # call site, so a test importing `refresh_delivery` directly still + # exercises the real helper. + patch("diet_guard._cli_kuchnia.refresh_delivery", return_value=([], None)), + # Logging a meal now warms the catering bank on a background + # thread. Without this every meal-logging test in the suite reaches + # the live panel. + patch( + "diet_guard._cli_log.refresh_delivery_once", + return_value=([], None), + ), + # Logging a meal now warms the catering bank on a background + # thread. Without this every meal-logging test in the suite reaches + # the live panel. + patch( + "diet_guard._gatelock_delivery.refresh_delivery", + return_value=([], None), + ), + # The Body tab's profile, weight log and published workouts. + patch("diet_guard._body_store.BODY_FILE", tmp_path / "body.json"), + # Workout sources live outside diet_guard entirely: RunnerUp's WebDAV + # drop and screen-locker's signed log. Reads, not writes -- but a test + # reading the real ones is non-deterministic, so both point nowhere. + patch( + "diet_guard._activity_sources.RUNNERUP_DIRS", + (tmp_path / "runnerup",), + ), + patch( + "diet_guard._activity_sources.screen_locker_log_file", + return_value=None, + ), + ] diff --git a/diet_guard/tests/_tk_fakes.py b/diet_guard/tests/_tk_fakes.py index d1f96b0..ccab9eb 100644 --- a/diet_guard/tests/_tk_fakes.py +++ b/diet_guard/tests/_tk_fakes.py @@ -164,6 +164,7 @@ _FAKE_TK = SimpleNamespace( Button=FakeWidget, OptionMenu=FakeWidget, Radiobutton=FakeRadiobutton, + Checkbutton=FakeWidget, Entry=FakeEntry, Text=FakeText, Listbox=FakeListbox, diff --git a/diet_guard/tests/_tk_fakes_canvas.py b/diet_guard/tests/_tk_fakes_canvas.py index eb51ba1..2b0ea43 100644 --- a/diet_guard/tests/_tk_fakes_canvas.py +++ b/diet_guard/tests/_tk_fakes_canvas.py @@ -50,6 +50,29 @@ class FakeCanvas(FakeWidget): def itemconfigure(self, *args: object, **kwargs: object) -> None: pass + # Drawing, for the Body tab's weight graph: recorded as + # ``(kind, args, kwargs)`` so a test can assert what was drawn. + def delete(self, *_args: object) -> None: + self.drawn: list[tuple[str, tuple[object, ...], dict[str, object]]] = [] + + def _draw( + self, kind: str, args: tuple[object, ...], kwargs: dict[str, object] + ) -> int: + self.drawn.append((kind, args, kwargs)) + return len(self.drawn) + + def create_line(self, *args: object, **kwargs: object) -> int: + return self._draw("line", args, kwargs) + + def create_oval(self, *args: object, **kwargs: object) -> int: + return self._draw("oval", args, kwargs) + + def create_rectangle(self, *args: object, **kwargs: object) -> int: + return self._draw("rectangle", args, kwargs) + + def create_text(self, *args: object, **kwargs: object) -> int: + return self._draw("text", args, kwargs) + def coords(self, *args: object, **kwargs: object) -> None: pass diff --git a/diet_guard/tests/conftest.py b/diet_guard/tests/conftest.py index 65b773b..f19bdd3 100644 --- a/diet_guard/tests/conftest.py +++ b/diet_guard/tests/conftest.py @@ -22,9 +22,7 @@ from __future__ import annotations from contextlib import ExitStack from typing import TYPE_CHECKING -from unittest.mock import patch -import freedays import pytest from diet_guard._estimator import Nutrition @@ -44,6 +42,7 @@ from diet_guard.tests._gate_fixtures import ( fake_tk, gate, ) +from diet_guard.tests._state_redirects import state_redirects from diet_guard.tests._tk_fakes import ( _FAKE_TK, _FAKE_TTK, @@ -103,124 +102,7 @@ def _isolate_state(tmp_path: Path) -> Iterator[None]: tuple: the tuple form is a statically nested block per entry, and at ~20 redirects CPython refuses to compile it. """ - redirects = [ - # The shared free-day pool lives under ~/.local/share, outside every - # diet_guard path below. Without this a real free day on the - # developer's machine would make every "the gate is due" test here - # fail, for a reason that looks nothing like the cause. - patch( - "freedays._api.resolve_paths", - lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"), - ), - patch( - "diet_guard._budget.BUDGET_FILE", - tmp_path / ".budget", - ), - patch( - "diet_guard._budget_history.BUDGET_HISTORY_FILE", - tmp_path / ".budget_history", - ), - patch( - "diet_guard._meal_schedule_store.MEAL_SCHEDULE_FILE", - tmp_path / ".meal_schedule", - ), - patch( - "diet_guard._state.FOOD_LOG_FILE", - tmp_path / "food_log.json", - ), - patch( - "diet_guard._foodbank.FOOD_BANK_FILE", - tmp_path / "food_bank.json", - ), - patch( - "diet_guard._foodbank_manual.MANUAL_BANK_FILE", - tmp_path / "food_bank_manual.json", - ), - patch( - "diet_guard._gatelock_lockfile.GATE_LOCK_FILE", - tmp_path / ".gate.lock", - ), - patch( - "diet_guard._sync_client.SYNC_TOKEN_FILE", - tmp_path / "sync_token", - ), - patch( - "diet_guard._sync.SYNC_STATE_FILE", - tmp_path / "sync_state.json", - ), - # Without this a test that syncs mints a uuid into the REAL - # ~/.local/share/diet_guard/.device_id, and this machine's live sync - # identity is decided by whichever test happened to run first. - patch( - "diet_guard._device.SYNC_DEVICE_ID_FILE", - tmp_path / ".device_id", - ), - # `run_sync` reads this to decide whether to build a Firebase-primary - # mirror. On a developer machine the real file exists, so without this - # every sync test would sign in and push to the live database. - patch( - "diet_guard._sync_client.CONFIG_FILE", - tmp_path / "nonexistent-firebase.json", - ), - # Every full tick ends by reading the phone's weigh-in through - # `wake_alarm._weight`, which is installed here and reads the live - # Firebase database. `test_sync_budget` asserts the call; nothing - # else may pay for it. - patch("diet_guard._sync.refresh_weight_from_phone", return_value=None), - # Logging a meal now publishes immediately (`_sync_events`), so every - # test that logs one would otherwise open a real connection through a - # path that has nothing to do with what it is asserting. Patched at the - # *call sites* rather than on `_sync_events`, so a test that imports - # `publish_after_log` directly still exercises the real helper. - patch("diet_guard._cli_log.publish_after_log_detached", return_value=None), - patch("diet_guard._cli_gate.publish_after_log", return_value=None), - patch("diet_guard._mcp.publish_after_log", return_value=None), - # The gate's pre-lock refresh is a *second*, independent network - # entry point (narrow peer-log pull, not the full tick). Without - # this every test that reaches `_should_lock` hits the real remote. - patch("diet_guard._cli_gate.pull_peer_logs", return_value=None), - # The catering credentials and its cached session cookie. Redirected - # for the same reason as `sync_token`: `_test_guard` raises on any - # write under the real ~/.config/diet_guard, and a test that logs in - # would otherwise clobber the live session. Deliberately NOT added to - # `test_state_redirect._REDIRECTED_CONSTANTS`, matching - # `SYNC_TOKEN_FILE` -- that check enforces a single naming module, - # which would forbid the CLI naming the path in its own setup text. - patch( - "diet_guard._kuchnia_config.KUCHNIA_CREDENTIALS_FILE", - tmp_path / "kuchnia_credentials", - ), - patch( - "diet_guard._kuchnia_config.KUCHNIA_SESSION_FILE", - tmp_path / "kuchnia_session.json", - ), - patch( - "diet_guard._kuchnia_credential_store.KUCHNIA_SYNCED_CREDENTIAL_FILE", - tmp_path / "kuchnia_synced_credential.json", - ), - patch( - "diet_guard._kuchnia_config.KUCHNIA_LAST_IMPORT_FILE", - tmp_path / "kuchnia_last_import", - ), - # The catering fetch is a third network entry point. Patched at each - # call site, so a test importing `refresh_delivery` directly still - # exercises the real helper. - patch("diet_guard._cli_kuchnia.refresh_delivery", return_value=([], None)), - # Logging a meal now warms the catering bank on a background - # thread. Without this every meal-logging test in the suite reaches - # the live panel. - patch( - "diet_guard._cli_log.refresh_delivery_once", - return_value=([], None), - ), - # Logging a meal now warms the catering bank on a background - # thread. Without this every meal-logging test in the suite reaches - # the live panel. - patch( - "diet_guard._gatelock_delivery.refresh_delivery", - return_value=([], None), - ), - ] + redirects = state_redirects(tmp_path) with ExitStack() as stack: for redirect in redirects: stack.enter_context(redirect) diff --git a/diet_guard/tests/test_activity_sources.py b/diet_guard/tests/test_activity_sources.py new file mode 100644 index 0000000..5717c19 --- /dev/null +++ b/diet_guard/tests/test_activity_sources.py @@ -0,0 +1,211 @@ +"""Workouts from RunnerUp TCX files and screen-locker's log, deduplicated. + +conftest points ``RUNNERUP_DIRS`` at an empty tmp dir and stubs +``screen_locker_log_file``; each test here supplies its own sources. +""" + +from __future__ import annotations + +from datetime import date +import json +from types import SimpleNamespace +from typing import TYPE_CHECKING +from unittest.mock import patch + +from diet_guard import _activity_sources + +if TYPE_CHECKING: + from pathlib import Path + +# Captured at import, before conftest's autouse patch replaces the attribute. +_REAL_LOG_FILE = _activity_sources.screen_locker_log_file +# screen-locker is not installed on CI (nor in the dev venv): stand in for its +# ``_log_io`` with the one behaviour used -- read the day-keyed JSON. +_FAKE_LOG_IO = SimpleNamespace( + load_workout_log=lambda path: json.loads(path.read_text(encoding="utf-8")) +) + +_TCX = """ + + {ident} + 1800{m} + 6000 + +""" + + +def _tcx( + folder: Path, name: str, ident: str, sport: str = "Running", m: int = 5000 +) -> None: + folder.mkdir(parents=True, exist_ok=True) + (folder / name).write_text( + _TCX.format(sport=sport, ident=ident, m=m), encoding="utf-8" + ) + + +def _entry( + data: dict[str, object], stamp: str = "2026-09-20T17:00:00+00:00" +) -> dict[str, object]: + return {"timestamp": stamp, "workout_data": data} + + +class TestRunnerUp: + def test_parses_dedupes_and_windows(self, tmp_path: Path) -> None: + inbox, done = tmp_path / "in", tmp_path / "in/processed" + _tcx(inbox, "a.tcx", "2026-09-20T17:00:00Z") + _tcx(done, "a.tcx", "2026-09-20T17:00:00Z") # same activity, moved + _tcx(done, "old.tcx", "2026-08-01T17:00:00Z") + _tcx(done, "walk.tcx", "2026-09-21T08:00:00Z", sport="Walking", m=0) + (done / "broken.tcx").write_text("", encoding="utf-8") + with patch.object( + _activity_sources, "RUNNERUP_DIRS", (inbox, done, tmp_path / "x") + ): + runs = _activity_sources.runnerup_sessions("2026-09-01") + assert [(s.kind, s.minutes, s.km) for s in runs] == [ + ("run", 40.0, 5.0), + ("walk", 40.0, None), + ] + assert runs[0].start is not None + assert runs[0].end is not None + + +class TestScreenLockerLogFile: + def test_absent_package(self) -> None: + with patch.object(_activity_sources, "import_module", side_effect=ImportError): + assert _REAL_LOG_FILE() is None + + def test_package_with_and_without_a_log(self, tmp_path: Path) -> None: + pkg = SimpleNamespace(__file__=str(tmp_path / "__init__.py")) + with patch.object(_activity_sources, "import_module", return_value=pkg): + assert _REAL_LOG_FILE() is None + (tmp_path / "log.json").write_text("{}", encoding="utf-8") + assert _REAL_LOG_FILE() == tmp_path / "log.json" + + +class TestScreenLocker: + def _run( + self, tmp_path: Path, log: dict[str, object], tcx_days: set[str] + ) -> list[object]: + path = tmp_path / "log.json" + path.write_text(json.dumps(log), encoding="utf-8") + with ( + patch.object( + _activity_sources, "screen_locker_log_file", return_value=path + ), + patch.object(_activity_sources, "import_module", return_value=_FAKE_LOG_IO), + ): + return _activity_sources.screen_locker_sessions("2026-09-01", tcx_days) + + def test_every_entry_type(self, tmp_path: Path) -> None: + log = { + "2026-08-01": [ + _entry({"type": "pc_workout_verified", "duration_minutes": "50"}) + ], + "2026-09-20": [ + _entry( + { + "type": "pc_workout_verified", + "source": "SL B (86 min, partial)", + "duration_minutes": "86.2", + } + ), + _entry( + {"type": "phone_verified", "source": "Workout verified! (86 min)"} + ), + _entry({"type": "relaxed_day_skip"}), + _entry( + { + "type": "manual_workout", + "activity_type": "Walking", + "duration_minutes": "60", + "start_time": "19:15", + "end_time": "20:15", + } + ), + {"workout_data": "junk"}, + ], + "2026-09-21": [ + _entry( + {"type": "phone_verified", "source": "Workout verified! (70 min)"} + ) + ], + "2026-09-22": [ + _entry( + { + "type": "runnerup_verified", + "source": "Auto-scanned: Running: 5.6 km in 60 min", + "distance_km": 5.61, + "duration_minutes": 60.5, + } + ) + ], + "2026-09-23": [ + _entry( + { + "type": "runnerup_verified", + "source": "Auto-scanned", + "duration_minutes": "x", + } + ) + ], + "2026-09-24": [ + _entry( + { + "type": "runnerup_verified", + "source": "odd", + "duration_minutes": 30, + "distance_km": "far", + } + ) + ], + "2026-09-25": [ + _entry({"type": "runnerup_verified", "duration_minutes": 30}) + ], + } + sessions = self._run(tmp_path, log, {"2026-09-25"}) + summary = [(s.day, s.kind, s.minutes, s.km) for s in sessions] + assert summary == [ + ("2026-09-20", "strength", 86.2, None), + ("2026-09-20", "walk", 60.0, None), + ("2026-09-21", "strength", 70.0, None), + ("2026-09-22", "run", 60.5, 5.61), + ("2026-09-24", "run", 30.0, None), + ] + + def test_absent_or_unreadable_log(self, tmp_path: Path) -> None: + assert _activity_sources.screen_locker_sessions("2026-09-01", set()) == [] + with ( + patch.object( + _activity_sources, "screen_locker_log_file", return_value=tmp_path + ), + patch.object(_activity_sources, "import_module", side_effect=ImportError), + ): + assert _activity_sources.screen_locker_sessions("2026-09-01", set()) == [] + + +class TestCollect: + def test_tcx_day_suppresses_the_duplicate_scan(self, tmp_path: Path) -> None: + _tcx(tmp_path / "r", "a.tcx", "2026-09-22T17:00:00Z") + log = tmp_path / "log.json" + log.write_text( + json.dumps( + { + "2026-09-22": [ + _entry({"type": "runnerup_verified", "duration_minutes": 60}) + ] + } + ), + encoding="utf-8", + ) + with ( + patch.object(_activity_sources, "RUNNERUP_DIRS", (tmp_path / "r",)), + patch.object(_activity_sources, "screen_locker_log_file", return_value=log), + patch.object(_activity_sources, "import_module", return_value=_FAKE_LOG_IO), + ): + sessions = _activity_sources.collect_sessions(date(2026, 9, 28)) + assert _activity_sources.sources_available() + assert [s.source for s in sessions] == ["runnerup"] + + def test_no_sources(self) -> None: + assert not _activity_sources.sources_available() diff --git a/diet_guard/tests/test_body_calc.py b/diet_guard/tests/test_body_calc.py new file mode 100644 index 0000000..15a8525 --- /dev/null +++ b/diet_guard/tests/test_body_calc.py @@ -0,0 +1,168 @@ +"""Edge cases of the Body formulas the shared parity fixture does not reach.""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from diet_guard._activity_kcal import ( + Session, + daily_exercise, + met_for, + session_kcal, + window_days, +) +from diet_guard._body_calc import ( + bmi_report, + bmr_formulas, + half_up, + ideal_summary, + table_bmr, +) +from diet_guard._body_energy import ( + MEASURED_PREFIX, + Goal, + Target, + activity_label, + goal_target, +) +from diet_guard._budget_biometrics import Biometrics +from diet_guard._week_plan import plan_week, week_start + +_MAN = Biometrics(weight_kg=80.0, height_cm=180.0, age_years=30, is_male=True) +_TODAY = date(2026, 9, 28) + + +def _session( + day: str, kind: str = "run", minutes: float = 60.0, km: float | None = 10.0 +) -> Session: + return Session(day, kind, minutes, km, "x", "runnerup") + + +class TestHalfUp: + def test_rounds_halves_up_unlike_python_round(self) -> None: + assert half_up(2.5) == 3 + assert round(2.5) == 2 + assert half_up(1.25, 1) == 1.3 + + +class TestBmiCategory: + @pytest.mark.parametrize( + ("kg", "label"), + [ + (45.0, "severe thinness"), + (52.0, "moderate thinness"), + (58.0, "mild thinness"), + (70.0, "normal"), + (90.0, "overweight"), + (100.0, "obese class I"), + (120.0, "obese class II"), + (140.0, "obese class III"), + ], + ) + def test_every_who_band(self, kg: float, label: str) -> None: + bio = Biometrics(weight_kg=kg, height_cm=180.0, age_years=30, is_male=True) + assert bmi_report(bio).category == label + + +class TestBmr: + def test_body_fat_formulas_are_none_without_a_reading(self) -> None: + names = {f.name: f.value for f in bmr_formulas(_MAN)} + assert names["Katch-McArdle"] is None + assert names["Cunningham"] is None + + def test_table_bmr_switches_to_katch_mcardle_with_body_fat(self) -> None: + assert table_bmr(_MAN, None).name == "Mifflin-St Jeor" + lean = table_bmr(_MAN, 20.0) + assert lean.name == "Katch-McArdle" + assert lean.value == pytest.approx(370 + 21.6 * 64.0) + + def test_ideal_summary_is_none_when_nothing_applies(self) -> None: + assert ideal_summary(()) is None + + +class TestGoal: + def test_kg_per_day_signs(self) -> None: + assert Goal("lose", 0.7, "kg", "week", "light").kg_per_day() == pytest.approx( + 0.1 + ) + assert Goal("gain", 0.7, "kg", "week", "light").kg_per_day() == pytest.approx( + -0.1 + ) + assert Goal("maintain", 5.0, "kg", "day", "light").kg_per_day() == 0.0 + + def test_unknown_activity_has_no_target(self) -> None: + target = goal_target(Goal("lose", 1, "kg", "week", "bogus"), 1800, "M", None) + assert target == Target(kcal=None, tdee=None, bmr_name="M") + assert not target.below_floor + + def test_measured_needs_that_formula(self) -> None: + goal = Goal("lose", 1, "kg", "week", f"{MEASURED_PREFIX}MET") + assert goal_target(goal, 1800, "M", None).kcal is None + assert goal_target(goal, 1800, "M", {"ACSM": 100.0}).kcal is None + assert goal_target(goal, 1800, "M", {"MET": 100.0}).tdee == pytest.approx(2260) + + def test_below_floor(self) -> None: + target = goal_target( + Goal("lose", 2, "kg", "week", "sedentary"), 1500, "M", None + ) + assert target.below_floor + + def test_activity_labels(self) -> None: + assert activity_label("moderate") == "Moderate" + assert activity_label(f"{MEASURED_PREFIX}ACSM") == "Measured (ACSM)" + assert activity_label("mystery") == "mystery" + + +class TestSessions: + def test_met_is_clamped_at_both_ends_of_the_speed_table(self) -> None: + assert met_for(_session("d", km=1.0)) == 6.0 # 1 km/h + assert met_for(_session("d", km=40.0)) == 23.0 # 40 km/h + assert met_for(_session("d", kind="walk", km=2.0)) == 2.8 + assert met_for(_session("d", kind="walk", km=100.0)) == 8.3 + + def test_fixed_mets_without_a_distance(self) -> None: + assert met_for(_session("d", kind="cycle", km=None)) == 7.5 + assert met_for(_session("d", kind="yoga", km=None)) == 4.0 + assert met_for(_session("d", minutes=0.0)) == 8.0 + + def test_no_distance_falls_back_to_met(self) -> None: + result = session_kcal(_session("d", kind="walk", km=None), 70.0) + assert result.fallback == {"ACSM", "Per-km"} + assert len(set(result.kcal.values())) == 1 + + +class TestWindow: + def test_full_window_once_history_is_old_enough(self) -> None: + assert window_days((_session("2026-09-01"),), _TODAY) == 14 + + def test_short_history_counts_from_its_first_day(self) -> None: + assert window_days((_session("2026-09-25"),), _TODAY) == 3 + extra = {"2026-09-26": 50.0} + assert window_days((), _TODAY, extra) == 2 + + def test_today_never_counts(self) -> None: + assert window_days((_session("2026-09-28"),), _TODAY) == 0 + zero = daily_exercise((_session("2026-09-28"),), 80.0, _TODAY) + assert zero == {"MET": 0.0, "ACSM": 0.0, "Per-km": 0.0} + + def test_extra_days_outside_the_window_are_ignored(self) -> None: + extra = {"2026-09-26": 20.0, "2026-09-01": 999.0, "2026-09-28": 999.0} + result = daily_exercise((), 80.0, _TODAY, extra) + assert result["MET"] == pytest.approx(20.0 / 14) + + def test_short_history_average_uses_the_short_span(self) -> None: + one_day = daily_exercise((), 80.0, _TODAY, {"2026-09-27": 90.0}) + assert one_day["MET"] == pytest.approx(90.0) + + +class TestWeekPlan: + def test_week_starts_on_monday(self) -> None: + assert week_start(date(2026, 10, 4)) == date(2026, 9, 28) + + def test_below_floor_only_with_remaining_days(self) -> None: + low = plan_week(date(2026, 10, 1), 1500, {"2026-09-28": 5000}, {}) + assert low.below_floor + full = plan_week(date(2026, 10, 4), 1500, {}, dict.fromkeys(range(7), 1500)) + assert not full.below_floor diff --git a/diet_guard/tests/test_body_parity.py b/diet_guard/tests/test_body_parity.py new file mode 100644 index 0000000..c83b669 --- /dev/null +++ b/diet_guard/tests/test_body_parity.py @@ -0,0 +1,102 @@ +"""Python half of the Body parity gate: every formula against the fixture. + +``tests/fixtures/body_calc.json`` is generated from this implementation by +``scripts/build_body_fixture.py`` and asserted value-for-value by the Dart +suite too (``app/test/services/body_parity_test.dart``). This half guards the +fixture against drifting from the code it claims to describe. +""" + +from __future__ import annotations + +from dataclasses import asdict +from datetime import date +import json +from pathlib import Path + +import pytest + +from diet_guard._activity_kcal import Session, daily_exercise, session_kcal +from diet_guard._body_calc import ( + bmi_report, + bmr_formulas, + healthy_range, + ideal_summary, + ideal_weights, + table_bmr, +) +from diet_guard._body_energy import goal_target +from diet_guard._body_prefs import goal_from_json +from diet_guard._body_steps import step_kcal, stride_m +from diet_guard._budget_biometrics import Biometrics +from diet_guard._week_plan import plan_week + +_FIXTURE = Path(__file__).resolve().parents[2] / "tests/fixtures/body_calc.json" +_DATA = json.loads(_FIXTURE.read_text(encoding="utf-8")) +_TODAY = date.fromisoformat(_DATA["today"]) +_SESSIONS = tuple(Session(**raw) for raw in _DATA["sessions"]) + + +def _same(actual: object, expected: object) -> None: + """Deep equality with float tolerance (JSON turns tuples into lists).""" + if isinstance(expected, float) or isinstance(actual, float): + assert actual == pytest.approx(expected, rel=1e-12, abs=1e-9) + elif isinstance(expected, dict): + assert isinstance(actual, dict) + assert set(actual) == set(expected) + for key, value in expected.items(): + _same(actual[key], value) + elif isinstance(expected, list): + assert isinstance(actual, (list, tuple)) + assert len(actual) == len(expected) + for got, want in zip(actual, expected, strict=True): + _same(got, want) + else: + assert actual == expected + + +@pytest.mark.parametrize("person", _DATA["people"], ids=lambda p: str(p["bio"])) +def test_person_matches_the_fixture(person: dict[str, object]) -> None: + bio = Biometrics(**person["bio"]) # type-checked by the dataclass itself + fat = person["body_fat_pct"] + steps = {day: step_kcal(n, bio) for day, n in _DATA["steps"].items()} + _same(asdict(bmi_report(bio)), person["bmi"]) + _same([[f.name, f.value] for f in ideal_weights(bio)], person["ideal"]) + _same([[f.name, f.value] for f in bmr_formulas(bio)], person["bmr"]) + _same([[f.name, f.value] for f in bmr_formulas(bio, fat)], person["bmr_with_fat"]) + _same(ideal_summary(ideal_weights(bio)), person["ideal_summary"]) + _same(healthy_range(bio), person["healthy_range"]) + _same(stride_m(bio), person["stride_m"]) + _same(steps, person["step_kcal"]) + exercise = daily_exercise(_SESSIONS, bio.weight_kg, _TODAY, steps) + _same(exercise, person["exercise"]) + _same(daily_exercise(_SESSIONS, bio.weight_kg, _TODAY), person["exercise_no_steps"]) + short = tuple(s for s in _SESSIONS if s.day >= "2026-09-24") + _same(daily_exercise(short, bio.weight_kg, _TODAY), person["exercise_short"]) + only_today = tuple(s for s in _SESSIONS if s.day == "2026-09-28") + _same( + daily_exercise(only_today, bio.weight_kg, _TODAY), + person["exercise_today_only"], + ) + for row in person["targets"]: + bmr = table_bmr(bio, row["body_fat_pct"]) + target = goal_target( + goal_from_json(row["goal"]), bmr.value or 0.0, bmr.name, exercise + ) + expected = {k: v for k, v in row.items() if k not in {"goal", "body_fat_pct"}} + _same(asdict(target), expected) + + +def test_session_kcal_matches_the_fixture() -> None: + for session, expected in zip(_SESSIONS, _DATA["session_kcal_80kg"], strict=True): + result = session_kcal(session, 80.0) + _same(result.kcal, expected["kcal"]) + assert sorted(result.fallback) == expected["fallback"] + + +@pytest.mark.parametrize("plan", _DATA["plans"], ids=lambda p: p["today"]) +def test_week_plans_match_the_fixture(plan: dict[str, object]) -> None: + typed = {int(k): v for k, v in plan["typed"].items()} + result = plan_week( + date.fromisoformat(plan["today"]), plan["target"], plan["logged"], typed + ) + _same(asdict(result), plan["expected"]) diff --git a/diet_guard/tests/test_body_report.py b/diet_guard/tests/test_body_report.py new file mode 100644 index 0000000..a0da73b --- /dev/null +++ b/diet_guard/tests/test_body_report.py @@ -0,0 +1,138 @@ +"""The text renderer and the view it renders, over seeded Body state.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date +import json +from unittest.mock import patch + +from diet_guard import _sync_body +from diet_guard._activity_kcal import Session, window_days +from diet_guard._body_activity_store import set_activity +from diet_guard._body_ingest import weight_candidates +from diet_guard._body_prefs import goal_from_json +from diet_guard._body_report import ( + activity_lines, + bodyfat_lines, + ideal_lines, + plan_lines, + target_lines, +) +from diet_guard._body_store import read_body, set_profile, set_weight, write_body +from diet_guard._body_view import build_view +from diet_guard._week_plan import plan_week + +_TODAY = date(2026, 9, 28) + + +def _person() -> None: + set_profile(birth="2000-05-22", height_cm=169.0, sex="m") + set_weight("2026-09-27", 72.6) + + +class TestView: + def test_empty_view(self) -> None: + view = build_view(_TODAY) + assert view.weight is None + assert view.numbers is None + assert view.exercise is None + assert activity_lines(view) == [ + "No workouts published yet (the PC publishes them on sync)." + ] + assert target_lines(view)[0].startswith("Set birth date") + assert ideal_lines(view)[0].startswith("Set birth date") + + def test_published_workouts_and_steps(self) -> None: + _person() + set_activity( + ( + Session("2026-09-22", "run", 60.0, 6.0, "Run", "runnerup"), + Session("2026-09-25", "strength", 80.0, None, "SL", "screen-locker"), + ) + ) + doc = read_body() + doc["steps"] = {"2026-09-26": {"n": 9000, "t": "2026-09-26T22:00:00+02:00"}} + write_body(doc) + view = build_view(_TODAY) + assert view.weight == 72.6 + assert view.exercise is not None + assert view.exercise.days == 6 + lines = activity_lines(view) + assert lines[1].startswith("Last 6 full days, net exercise kcal/day: MET") + assert lines[2].startswith("Steps outside workouts: 9000 on 2026-09-26") + assert any(" run " in line and "6.0 km M" in line for line in lines) + + def test_steps_alone_show_without_a_publish_stamp(self) -> None: + _person() + doc = read_body() + doc["steps"] = {"2026-09-27": {"n": 3000, "t": "2026-09-27T22:00:00+02:00"}} + write_body(doc) + view = build_view(_TODAY) + assert activity_lines(view)[0] == "Workouts published not yet" + + def test_sessions_without_a_weight_have_no_kcal(self) -> None: + set_activity((Session("2026-09-22", "run", 60.0, None, "Run", "runnerup"),)) + line = activity_lines(build_view(_TODAY))[1] + assert line.endswith("min") + + +class TestTargetAndIdeal: + def test_missing_summary_skips_average_rows(self) -> None: + _person() + view = build_view(_TODAY) + assert view.numbers is not None + bare = replace(view, numbers=replace(view.numbers, ideal_summary=None)) + assert not any("Average of all" in line for line in ideal_lines(bare)) + + def test_body_fat_lines(self) -> None: + assert bodyfat_lines(build_view(_TODAY))[0].startswith("Body fat: not logged") + + +class TestPlanLines: + def test_all_known_and_unlogged_and_floor(self) -> None: + full = plan_week(date(2026, 10, 4), 1800, {}, dict.fromkeys(range(7), 1900)) + assert plan_lines(full)[-1].startswith("Every day is known") + low = plan_week(date(2026, 10, 1), 1500, {"2026-09-28": 5000}, {}) + lines = plan_lines(low) + assert "(no log - type it?)" in lines[2] + assert lines[-1].endswith("floor!") + + +class TestLeftoverBranches: + def test_future_extra_days_are_ignored(self) -> None: + assert window_days((), _TODAY, {"2026-09-28": 5.0}) == 0 + + def test_an_older_candidate_does_not_replace_a_newer_one(self) -> None: + newer = json.dumps( + { + "alarm": { + "fields": { + "morning_sessions": [ + [{"date": "2026-09-27", "weight_kg": 71}], + "x", + ] + } + } + } + ) + older_hlc = "1970-01-01T00:00:00.000Z-0000-a" + older = ( + '{"alarm": {"fields": {"latest_weight_kg": ' + f'[{{"date": "2026-09-27", "kg": 70}}, "{older_hlc}"]}}}}}}' + ) + cands = weight_candidates([("n", newer), ("o", older)]) + assert [c.kg for c in cands] == [71.0] + + def test_unknown_time_unit_keeps_the_default(self) -> None: + assert goal_from_json({"tu": "fortnight"}).time_unit == "week" + + def test_unchanged_activity_is_not_republished(self) -> None: + with ( + patch.object(_sync_body, "sources_available", return_value=True), + patch.object(_sync_body, "collect_sessions", return_value=()), + patch.object(_sync_body, "set_activity", return_value=False), + patch.object(_sync_body._logger, "info") as info, + ): + _sync_body.publish_activity() + info.assert_not_called() diff --git a/diet_guard/tests/test_body_store.py b/diet_guard/tests/test_body_store.py new file mode 100644 index 0000000..4fe5135 --- /dev/null +++ b/diet_guard/tests/test_body_store.py @@ -0,0 +1,232 @@ +"""The Body document on disk: profile, weights, body fat, sessions, goal, steps. + +``BODY_FILE`` is redirected into ``tmp_path`` by conftest's ``_isolate_state``. +""" + +from __future__ import annotations + +from datetime import date +import json + +import pytest + +from diet_guard import _body_store +from diet_guard._activity_kcal import Session +from diet_guard._body_activity_store import ( + activity, + session_from_json, + session_to_json, + set_activity, +) +from diet_guard._body_energy import DEFAULT_GOAL, Goal +from diet_guard._body_fat_store import body_fats, newest_body_fat, set_body_fat +from diet_guard._body_prefs import goal_from_json, saved_goal, set_goal +from diet_guard._body_steps import daily_steps +from diet_guard._body_store import ( + Profile, + WeightCandidate, + age_on, + apply_weight_candidates, + newest_weight, + profile, + read_body, + set_profile, + set_weight, + stamp_ms, + weights, + write_body, +) + + +def _raw(doc: object) -> None: + _body_store.BODY_FILE.write_text(json.dumps(doc), encoding="utf-8") + + +def _cell(value: object) -> dict[str, object]: + """Narrow a document cell (typed ``object``) for indexing.""" + assert isinstance(value, dict) + return value + + +class TestReadBody: + def test_missing_file_reads_as_empty_sections(self) -> None: + assert read_body() == {s: {} for s in _body_store.SECTIONS} + + def test_corrupt_or_wrong_shape_reads_as_empty(self) -> None: + _body_store.BODY_FILE.write_text("{not json", encoding="utf-8") + assert read_body()["weights"] == {} + _raw([1, 2]) + assert read_body()["profile"] == {} + _raw({"weights": "nope"}) + assert read_body()["weights"] == {} + + def test_write_then_read_round_trips(self) -> None: + doc = read_body() + doc["weights"]["2026-09-01"] = {"kg": 80.0, "src": "manual", "t": "x"} + write_body(doc) + assert json.loads(_body_store.BODY_FILE.read_text())["v"] == 1 + assert _cell(read_body()["weights"]["2026-09-01"])["kg"] == 80.0 + + +class TestHelpers: + def test_age_counts_completed_years(self) -> None: + assert age_on(date(2000, 5, 22), date(2026, 5, 21)) == 25 + assert age_on(date(2000, 5, 22), date(2026, 5, 22)) == 26 + + def test_stamp_ms(self) -> None: + assert stamp_ms("1970-01-01T00:00:01+00:00") == 1000 + assert stamp_ms("1970-01-01T00:00:02") == 2000 # naive = UTC + assert stamp_ms("garbage") == 0 + assert stamp_ms(None) == 0 + + +class TestProfile: + def test_empty_profile_is_incomplete(self) -> None: + prof = profile() + assert not prof.complete + assert prof.biometrics(80.0, date(2026, 9, 28)) is None + + def test_set_profile_and_biometrics(self) -> None: + set_profile(birth="2000-05-22", height_cm=169.0, sex="m") + prof = profile() + assert prof.complete + bio = prof.biometrics(72.6, date(2026, 9, 28)) + assert bio is not None + assert bio.age_years == 26 + assert bio.is_male + + def test_malformed_fields_read_as_unset(self) -> None: + _raw( + { + "profile": { + "birth": {"v": "not-a-date"}, + "height_cm": {"v": True}, + "sex": {"v": "x"}, + } + } + ) + assert profile() == Profile(birth=None, height_cm=None, sex=None) + _raw({"profile": {"birth": {"v": 12}, "height_cm": {"v": -5}, "sex": "m"}}) + assert profile() == Profile(birth=None, height_cm=None, sex=None) + + +class TestWeights: + def test_set_list_newest_and_delete(self) -> None: + assert newest_weight() is None + set_weight("2026-09-01", 80.123) + set_weight("2026-09-10", 79.0) + assert weights() == {"2026-09-01": 80.12, "2026-09-10": 79.0} + set_weight("2026-09-10", None) + assert newest_weight() == ("2026-09-01", 80.12) + + def test_bad_keys_and_values_are_skipped(self) -> None: + _raw( + { + "weights": { + "garbage": {"kg": 70}, + "2026-09-01": {"kg": "x"}, + "2026-09-02": 5, + } + } + ) + assert weights() == {} + + def test_candidates_apply_only_when_newer(self) -> None: + set_weight("2026-09-27", 73.0) # stamped now + applied = apply_weight_candidates( + [ + WeightCandidate( + "2026-09-27", 72.0, "phone", "2026-09-27T00:00:00+00:00" + ), + WeightCandidate( + "2026-09-26", 72.5, "phone", "2026-09-26T00:00:00+00:00" + ), + ] + ) + assert applied == 1 + assert weights() == {"2026-09-26": 72.5, "2026-09-27": 73.0} + assert apply_weight_candidates([]) == 0 + + +class TestBodyFat: + def test_log_newest_and_delete(self) -> None: + assert newest_body_fat() is None + set_body_fat("2026-09-20", 17.55) + set_body_fat("2026-09-27", 16.8) + assert body_fats() == {"2026-09-20": 17.6, "2026-09-27": 16.8} + set_body_fat("2026-09-27", None) + assert newest_body_fat() == 17.6 + + def test_malformed_cells_are_skipped(self) -> None: + _raw({"bodyfat": {"2026-09-01": 3, "bad": {"pct": 10}}}) + assert body_fats() == {} + + +class TestSessions: + def test_round_trip_with_window(self) -> None: + s = Session( + "2026-09-23", + "run", + 60.0, + 5.6, + "Run", + "runnerup", + "2026-09-23T19:05:43+02:00", + "2026-09-23T20:05:43+02:00", + ) + assert session_from_json(session_to_json(s)) == s + + @pytest.mark.parametrize( + "raw", + [ + "x", + {"day": 1, "kind": "run", "min": 5}, + {"day": "d", "kind": "run", "min": 0}, + {"day": "d", "kind": "run", "min": 5, "km": "far"}, + ], + ) + def test_malformed_sessions_are_none(self, raw: object) -> None: + assert session_from_json(raw) is None + + def test_bad_window_stamps_read_as_none(self) -> None: + s = session_from_json( + {"day": "d", "kind": "run", "min": 5, "start": 3, "end": "nope"} + ) + assert s is not None + assert s.start is None + assert s.end is None + + def test_activity_publish_is_idempotent(self) -> None: + assert activity() == ((), None) + s = Session("2026-09-23", "strength", 80.0, None, "SL", "screen-locker") + assert set_activity((s,)) is True + assert set_activity((s,)) is False + sessions, stamp = activity() + assert sessions == (s,) + assert stamp is not None + + def test_malformed_activity_reads_as_none(self) -> None: + _raw({"activity": {"sessions": "x", "t": "2026"}}) + assert activity() == ((), None) + + +class TestGoalPrefs: + def test_default_until_saved(self) -> None: + assert saved_goal() == DEFAULT_GOAL + goal = Goal("gain", 2.0, "lb", "month", "measured:ACSM") + set_goal(goal) + assert saved_goal() == goal + + def test_each_part_falls_back_on_its_own(self) -> None: + assert goal_from_json("x") == DEFAULT_GOAL + mixed = goal_from_json( + {"dir": "sideways", "amt": -1, "wu": "stone?", "tu": "week", "act": "?"} + ) + assert mixed == DEFAULT_GOAL + assert goal_from_json({"amt": 3, "tu": "year"}).time_unit == "year" + + +class TestSteps: + def test_daily_steps_reads_live_days(self) -> None: + _raw({"steps": {"2026-09-27": {"n": 8421.0}, "bad": {"n": 5}, "2026-09-26": 4}}) + assert daily_steps() == {"2026-09-27": 8421} diff --git a/diet_guard/tests/test_body_sync.py b/diet_guard/tests/test_body_sync.py new file mode 100644 index 0000000..afccb86 --- /dev/null +++ b/diet_guard/tests/test_body_sync.py @@ -0,0 +1,221 @@ +"""Body sync: wake-alarm weigh-in ingestion, the Log adapter, and the tick.""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +from crdt_sync import Hlc, MirrorSyncClient, RemoteSyncError, merge_logs +import pytest + +from diet_guard import _sync_body +from diet_guard._body_ingest import fetch_alarm_logs, weight_candidates +from diet_guard._body_store import read_body, set_profile, set_weight, weights +from diet_guard._device import device_id +from diet_guard.sync_merge._body import ( + body_to_log, + encode_body_for_push, + log_to_body, + parse_remote_body, +) + +# 2026-09-27 ~21:00 UTC: after that morning's weigh-in, as a real publish is. +_HLC = Hlc(wall_time_ms=1_790_535_000_000, counter=0, node_id="phone").to_str() + + +def _alarm(latest: object = None, sessions: object = None, hlc: str = _HLC) -> str: + fields: dict[str, object] = {} + if latest is not None: + fields["latest_weight_kg"] = [latest, hlc] + if sessions is not None: + fields["morning_sessions"] = [sessions, hlc] + return json.dumps({"alarm": {"fields": fields}}) + + +def _cell(value: object) -> dict[str, object]: + """Narrow a document cell (typed ``object``) for indexing.""" + assert isinstance(value, dict) + return value + + +class TestWeightCandidates: + def test_latest_uses_its_hlc_and_sessions_fill_gaps(self) -> None: + cands = weight_candidates( + [ + ( + "phone", + _alarm( + {"date": "2026-09-27", "kg": 72.6}, + [ + {"date": "2026-09-26", "weight_kg": 72.8}, + {"date": "2026-09-27", "weight_kg": 72.9}, + {"date": "2026-09-25", "weight_kg": 500}, + "junk", + ], + ), + ) + ] + ) + by_day = {c.day: c for c in cands} + assert set(by_day) == {"2026-09-26", "2026-09-27"} + assert by_day["2026-09-27"].kg == 72.6 # the HLC-stamped value wins + assert by_day["2026-09-26"].t == "2026-09-26T00:00:00+00:00" + + @pytest.mark.parametrize( + "raw", + [ + "not json", + json.dumps([1]), + json.dumps({"alarm": {"fields": {"latest_weight_kg": "short"}}}), + _alarm({"date": "2026-09-27", "kg": True}), + _alarm({"date": 5, "kg": 70}), + _alarm({"date": "2026-09-27", "kg": 70}, hlc="bad-hlc"), + ], + ) + def test_unusable_logs_offer_nothing(self, raw: str) -> None: + assert weight_candidates([("phone", raw)]) == [] + + def test_newest_stamp_wins_across_devices(self) -> None: + older = Hlc(wall_time_ms=1_000, counter=0, node_id="a").to_str() + cands = weight_candidates( + [ + ("a", _alarm({"date": "2026-09-27", "kg": 70.0}, hlc=older)), + ("b", _alarm({"date": "2026-09-27", "kg": 71.0})), + ] + ) + assert [c.kg for c in cands] == [71.0] + + +class TestFetchAlarmLogs: + def test_reads_every_device_and_skips_missing_files(self) -> None: + store = MagicMock() + store.list_directory.return_value = ["a", "b"] + store.get_file_text.side_effect = ["{}", None] + assert fetch_alarm_logs(store) == [("a", "{}")] + + def test_mirror_reads_the_primary_only(self) -> None: + mirror = MagicMock(spec=MirrorSyncClient) + mirror.primary = MagicMock() + mirror.primary.list_directory.return_value = [] + assert fetch_alarm_logs(mirror) == [] + mirror.primary.list_directory.assert_called_once() + + def test_a_remote_failure_reads_as_nothing(self) -> None: + store = MagicMock() + store.list_directory.side_effect = RemoteSyncError("down") + assert fetch_alarm_logs(store) == [] + + +class TestLogAdapter: + def test_round_trip_every_section(self) -> None: + set_profile(birth="2000-05-22", sex="m") + set_weight("2026-09-27", 72.6, "phone") + doc = read_body() + doc["bodyfat"]["2026-09-27"] = {"pct": 16.8, "t": "2026-09-27T10:00:00+02:00"} + doc["steps"]["2026-09-27"] = {"n": 8000, "t": "2026-09-27T21:00:00+02:00"} + doc["activity"] = { + "sessions": [{"day": "2026-09-27"}], + "t": "2026-09-27T21:00:00+02:00", + } + doc["weights"]["junk"] = "not a cell" + back = log_to_body(parse_remote_body(encode_body_for_push(body_to_log(doc)))) + assert _cell(back["profile"]["sex"])["v"] == "m" + assert _cell(back["weights"]["2026-09-27"])["kg"] == 72.6 + assert _cell(back["bodyfat"]["2026-09-27"])["pct"] == 16.8 + assert _cell(back["steps"]["2026-09-27"])["n"] == 8000 + assert back["activity"]["sessions"] == [{"day": "2026-09-27"}] + assert "junk" not in back["weights"] + + def test_empty_doc_is_an_empty_log(self) -> None: + assert body_to_log(read_body()) == {} + assert log_to_body({})["weights"] == {} + + def test_malformed_records_are_dropped(self) -> None: + log = body_to_log( + { + "profile": {}, + "weights": {"2026-09-01": {"kg": 1, "t": "x"}}, + "bodyfat": {}, + "steps": {}, + "activity": {"sessions": "not a list"}, + } + ) + record = log["w:2026-09-01"] + record.fields["kg"] = ("not a dict", record.fields["kg"][1]) + log["w:2026-09-02"] = type(record)(id="w:2026-09-02", fields={}) + log["activity"] = type(record)( + id="activity", fields={"sessions": ("x", record.fields["kg"][1])} + ) + back = log_to_body(log) + assert back["weights"] == {} + assert back["activity"] == {} + + def test_non_object_payload_raises(self) -> None: + with pytest.raises(TypeError): + parse_remote_body("[1]") + + def test_later_edit_wins_the_merge(self) -> None: + set_weight("2026-09-27", 72.0) + mine = body_to_log(read_body()) + doc = read_body() + doc["weights"]["2026-09-27"] = { + "kg": 99.0, + "src": "manual", + "t": "2000-01-01T00:00:00+00:00", + } + merged = merge_logs(mine, body_to_log(doc)) + assert _cell(log_to_body(merged)["weights"]["2026-09-27"])["kg"] == 72.0 + + +class TestSyncBody: + def _client(self, files: dict[str, str]) -> MagicMock: + client = MagicMock() + client.list_directory.return_value = [] + client.get_file_text.side_effect = files.get + return client + + def test_merges_a_peer_and_pushes(self) -> None: + peer_doc = read_body() + peer_doc["weights"]["2026-09-20"] = { + "kg": 74.0, + "src": "manual", + "t": "2026-09-20T08:00:00+02:00", + } + path = "diet-guard-sync/devices/peer/body.json" + client = self._client({path: encode_body_for_push(body_to_log(peer_doc))}) + _sync_body.sync_body(client, ["peer", device_id()]) + assert weights() == {"2026-09-20": 74.0} + pushed = client.put_file_text.call_args.args + assert pushed[0] == f"diet-guard-sync/devices/{device_id()}/body.json" + + def test_skips_missing_and_unparsable_peers(self) -> None: + client = self._client({"diet-guard-sync/devices/bad/body.json": "{oops"}) + _sync_body.sync_body(client, ["bad", "absent"]) + client.put_file_text.assert_not_called() + + def test_ingests_phone_weigh_ins_first(self) -> None: + client = self._client({}) + with patch.object( + _sync_body, + "fetch_alarm_logs", + return_value=[("p", _alarm({"date": "2026-09-27", "kg": 72.6}))], + ): + _sync_body.sync_body(client, []) + assert weights() == {"2026-09-27": 72.6} + client.put_file_text.assert_called_once() + + +class TestPublishActivity: + def test_no_source_publishes_nothing(self) -> None: + with patch.object(_sync_body, "set_activity") as publish: + _sync_body.publish_activity() + publish.assert_not_called() + + def test_publishes_when_a_source_exists(self) -> None: + with ( + patch.object(_sync_body, "sources_available", return_value=True), + patch.object(_sync_body, "collect_sessions", return_value=()), + patch.object(_sync_body, "set_activity", return_value=True) as publish, + ): + _sync_body.publish_activity() + publish.assert_called_once_with(()) diff --git a/diet_guard/tests/test_cli_body.py b/diet_guard/tests/test_cli_body.py new file mode 100644 index 0000000..0f747ba --- /dev/null +++ b/diet_guard/tests/test_cli_body.py @@ -0,0 +1,157 @@ +"""CLI: ``body``, ``weight``, ``bodyfat``, ``profile`` and ``plan-week``. + +State is redirected by conftest; ``_cli_body.publish_after_log`` is patched +there too, so no command here reaches the network. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from diet_guard import _cli_body +from diet_guard._body_fat_store import body_fats +from diet_guard._body_prefs import saved_goal +from diet_guard._body_store import set_profile, weights +from diet_guard._budget import read_raw_record, write_budget +from diet_guard._cli import main + + +def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> tuple[int, str]: + code = main(list(argv)) + return code, capsys.readouterr().out + + +def _profile() -> None: + set_profile(birth="2000-05-22", height_cm=169.0, sex="m") + + +class TestBody: + def test_incomplete_profile_comes_first( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + code, out = _run(capsys, "body") + assert code == 0 + assert out.index("Profile:") < out.index("Set birth date") + assert out.rstrip().endswith("Katch-McArdle).") + + def test_complete_profile_goes_last_before_body_fat( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + _profile() + _run(capsys, "weight", "72.6") + _run(capsys, "bodyfat", "16.8") + _, out = _run(capsys, "body") + assert out.startswith("To lose 0.5 kg / week on Moderate eat") + assert out.index("Ideal range") < out.index("Profile:") < out.index("Body fat:") + assert "Katch-McArdle resting burn" in out + + def test_overrides_and_save(self, capsys: pytest.CaptureFixture[str]) -> None: + _profile() + _run(capsys, "weight", "72.6") + code, out = _run( + capsys, + "body", + "--direction", + "gain", + "--amount", + "1", + "--weight-unit", + "lb", + "--time-unit", + "month", + "--activity", + "sedentary", + "--save", + ) + assert code == 0 + assert out.startswith("goal saved.\nTo gain 1 lb / month on Sedentary eat") + assert saved_goal().weight_unit == "lb" + + def test_maintain_and_measured_without_data( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + _profile() + _run(capsys, "weight", "72.6") + _, out = _run(capsys, "body", "--direction", "maintain") + assert out.startswith("To maintain on Moderate eat") + _, out = _run(capsys, "body", "--activity", "measured:MET") + assert "no workouts published yet" in out.splitlines()[0] + + def test_far_below_the_floor_is_flagged( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + _profile() + _run(capsys, "weight", "72.6") + _, out = _run(capsys, "body", "--amount", "3", "--activity", "sedentary") + assert "below the 1200 kcal floor" in out + + +class TestWeight: + def test_log_list_and_reject(self, capsys: pytest.CaptureFixture[str]) -> None: + assert _run(capsys, "weight", "500")[0] == 1 + _run(capsys, "weight", "80", "--date", "2026-09-01") + _, out = _run(capsys, "weight") + assert out == "2026-09-01 80.0 kg\n" + + def test_newest_day_moves_w_older_does_not( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + write_budget(2000, weight_kg=90.0) + _run(capsys, "weight", "80", "--date", "2026-09-10") + assert (read_raw_record() or {})["w"] == 80.0 + _run(capsys, "weight", "70", "--date", "2026-09-01") + assert (read_raw_record() or {})["w"] == 80.0 + _run(capsys, "weight", "--delete", "--date", "2026-09-10") + assert (read_raw_record() or {})["w"] == 70.0 + assert weights() == {"2026-09-01": 70.0} + + def test_bad_date_is_an_argparse_error(self) -> None: + with pytest.raises(SystemExit): + main(["weight", "80", "--date", "yesterday"]) + + def test_publish_failure_is_reported( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with patch.object(_cli_body, "publish_after_log", return_value="offline"): + _, out = _run(capsys, "weight", "80") + assert "not yet published (offline)" in out + + +class TestBodyFat: + def test_log_list_reject_delete(self, capsys: pytest.CaptureFixture[str]) -> None: + assert _run(capsys, "bodyfat", "90")[0] == 1 + _run(capsys, "bodyfat", "17.5", "--date", "2026-09-20") + assert _run(capsys, "bodyfat")[1] == "2026-09-20 17.5%\n" + _, out = _run(capsys, "bodyfat", "--delete", "--date", "2026-09-20") + assert out.startswith("deleted for 2026-09-20.") + assert body_fats() == {} + + +class TestProfile: + def test_show_then_set(self, capsys: pytest.CaptureFixture[str]) -> None: + assert _run(capsys, "profile")[1].startswith("Profile: birth date not set") + _, out = _run( + capsys, "profile", "--birth", "2000-05-22", "--height", "169", "--sex", "m" + ) + assert out.startswith("profile saved.") + assert "born 2000-05-22" in _run(capsys, "profile")[1] + + +class TestPlanWeek: + def test_typed_days_and_default_target( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + code, out = _run( + capsys, "plan-week", "--day", "mon=2500", "--day", "tuesday=1800" + ) + assert code == 0 + assert "average of 2200 kcal/day" in out + assert "2500 typed" in out + write_budget(1900) + assert "average of 1900" in _run(capsys, "plan-week")[1] + + def test_bad_day_spec(self) -> None: + with pytest.raises(SystemExit): + main(["plan-week", "--day", "funday=5"]) diff --git a/diet_guard/tests/test_gatelock_body.py b/diet_guard/tests/test_gatelock_body.py new file mode 100644 index 0000000..5a388ce --- /dev/null +++ b/diet_guard/tests/test_gatelock_body.py @@ -0,0 +1,159 @@ +"""The gate's Body tab: its handlers, input parsing and weight graph. + +Built on the in-memory Tk fakes (``fake_tk``), so no window ever opens. +""" + +from __future__ import annotations + +from datetime import date +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from diet_guard import _gatelock_body +from diet_guard._body_fat_store import body_fats +from diet_guard._body_prefs import saved_goal +from diet_guard._body_store import profile, set_profile, set_weight, weights +from diet_guard._budget import read_raw_record, write_budget +from diet_guard._gatelock_body import BodyTab +from diet_guard._gatelock_body_graph import ( + GraphBox, + draw_weight_graph, + graph_points, + in_range, + value_span, +) +from diet_guard._gatelock_body_input import parse_day, parse_float +from diet_guard.tests._gate_fixtures import fake_tk +from diet_guard.tests._tk_fakes import FakeCanvas, FakeNotebook + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@pytest.fixture +def tab() -> Iterator[BodyTab]: + with ( + fake_tk(), + patch.object(_gatelock_body, "publish_after_log_detached") as publish, + ): + body = BodyTab(FakeNotebook()) + body.publish = publish # the test can assert it + yield body + + +def _person() -> None: + set_profile(birth="2000-05-22", height_cm=169.0, sex="m") + set_weight("2026-09-27", 72.6) + + +class TestInput: + def test_parsing(self) -> None: + assert parse_float(" 72,6 ") == 72.6 + assert parse_float("x") is None + assert parse_day(" 2026-09-28") == "2026-09-28" + assert parse_day("today") is None + + +class TestProfile: + def test_rejects_then_saves(self, tab: BodyTab) -> None: + tab.vars.profile.height.set("9") + tab.save_profile() + assert tab.vars.status.get().startswith("Profile needs") + tab.vars.profile.birth.set("2000-05-22") + tab.vars.profile.height.set("169") + tab.vars.profile.sex.set("m") + tab.save_profile() + assert profile().complete + assert tab.vars.status.get().startswith("Profile saved") + + def test_complete_profile_loads_into_the_inputs(self) -> None: + _person() + with fake_tk(): + body = BodyTab(FakeNotebook()) + assert body.vars.profile.height.get() == "169" + assert body.vars.goal.answer.get().startswith("eat ") + + +class TestWeights: + def test_save_reject_delete(self, tab: BodyTab) -> None: + write_budget(2000, weight_kg=90.0) + tab.vars.logs.weight.set("5") + tab.save_weight() + assert tab.vars.status.get().startswith("Weight needs") + tab.vars.logs.weight.set("80") + tab.vars.logs.weight_day.set("2026-09-10") + tab.save_weight() + assert weights() == {"2026-09-10": 80.0} + assert (read_raw_record() or {})["w"] == 80.0 + tab.vars.logs.weight_day.set("nope") + tab.delete_weight() + assert tab.vars.status.get().startswith("Type the") + tab.vars.logs.weight_day.set("2026-09-10") + tab.delete_weight() + assert weights() == {} + + def test_body_fat_save_reject_delete(self, tab: BodyTab) -> None: + tab.vars.logs.body_fat.set("90") + tab.save_body_fat() + assert tab.vars.status.get().startswith("Body fat needs") + tab.vars.logs.body_fat.set("17") + tab.save_body_fat() + assert body_fats() == {tab.vars.logs.fat_day.get(): 17.0} + tab.vars.logs.fat_day.set("x") + tab.delete_body_fat() + assert tab.vars.status.get().startswith("Type the") + tab.vars.logs.fat_day.set(next(iter(body_fats()))) + tab.delete_body_fat() + assert body_fats() == {} + + +class TestGoalAndGraph: + def test_goal_change_saves_and_reanswers(self, tab: BodyTab) -> None: + _person() + tab.vars.goal.weight_unit.set("lb") + tab.vars.goal.amount.set("oops") + tab.goal_changed() + assert saved_goal().weight_unit == "lb" + assert saved_goal().amount == 0.5 # unreadable amount kept + assert tab.vars.goal.amount.get() == "0.5" + assert tab.vars.goal.answer.get().startswith("eat ") + + def test_range_and_body_fat_graph(self, tab: BodyTab) -> None: + _person() + tab.vars.plan.days[0].set("2500") + tab.vars.plan.target.set("") + tab.set_range(None) + tab.vars.logs.graph.set("fat") + tab.refresh() + assert "Mon" in tab.vars.blocks["plan"].get() + + +class TestGraph: + def test_in_range_and_span(self) -> None: + log = {"2026-01-01": 80.0, "2026-09-27": 72.0} + assert in_range(log, date(2026, 9, 28), None) == log + assert in_range(log, date(2026, 9, 28), 30) == {"2026-09-27": 72.0} + low, high = value_span([72.0], (50.0, 71.0)) + assert low < 71.0 < high + low, high = value_span([72.0], (40.0, 45.0)) + assert low > 45.0 + + def test_single_point_is_centred(self) -> None: + box = GraphBox(700, 300) + ((x, _),) = graph_points({"2026-09-27": 72.0}, box, (71.0, 73.0)) + assert x == pytest.approx(box.x(0.5)) + + def test_draws_band_line_and_labels_or_a_hint(self) -> None: + canvas = FakeCanvas() + box = GraphBox(700, 300) + draw_weight_graph(canvas, {}, box, None) + assert canvas.drawn[0][0] == "text" + log = {"2026-09-20": 74.0, "2026-09-27": 72.0} + draw_weight_graph(canvas, log, box, (52.8, 71.1)) + kinds = [kind for kind, _, _ in canvas.drawn] + assert "rectangle" in kinds + assert kinds.count("oval") == 2 + draw_weight_graph(canvas, log, box, (10.0, 20.0)) # band far off-axis + assert "rectangle" not in [kind for kind, _, _ in canvas.drawn] diff --git a/diet_guard/tests/test_gatelock_calendar.py b/diet_guard/tests/test_gatelock_calendar.py index 317eb19..9852fa5 100644 --- a/diet_guard/tests/test_gatelock_calendar.py +++ b/diet_guard/tests/test_gatelock_calendar.py @@ -19,9 +19,9 @@ if TYPE_CHECKING: class TestNotebookWiring: """The gate builds a two-tab notebook without breaking construction.""" - def test_builds_a_notebook_with_both_tabs(self, gate: MealGate) -> None: + def test_builds_a_notebook_with_all_three_tabs(self, gate: MealGate) -> None: labels = [text for _child, text in gate._notebook.tabs] - assert labels == ["Log Meal", "History"] + assert labels == ["Log Meal", "History", "Body"] def test_calendar_state_starts_on_the_current_month(self, gate: MealGate) -> None: assert 1 <= gate._cal_month <= 12 diff --git a/diet_guard/tests/test_manual_workout_parse.py b/diet_guard/tests/test_manual_workout_parse.py new file mode 100644 index 0000000..8d89fa3 --- /dev/null +++ b/diet_guard/tests/test_manual_workout_parse.py @@ -0,0 +1,49 @@ +"""Manual workouts: sport and distance from free text, and time windows.""" + +from __future__ import annotations + +import pytest + +from diet_guard._manual_workout_parse import logged_window, manual_session + + +class TestManualParse: + @pytest.mark.parametrize( + ("item", "kind", "km"), + [ + ({"activity_type": "Running", "distance_km": "7.5"}, "run", 7.5), + ( + {"sport": "other", "source": "evening run 5,2km by the river"}, + "run", + 5.2, + ), + ({"sport": "other", "source": "walked 3500 m"}, "walk", 3.5), + ({"sport": "other", "source": "climbing a 5 m wall"}, "other", None), + ( + {"sport": "other", "source": "long walk", "distance_km": "bad"}, + "walk", + None, + ), + ({"activity_type": "Cycling", "source": "20 km"}, "cycle", None), + ], + ) + def test_kind_and_distance( + self, item: dict[str, object], kind: str, km: float | None + ) -> None: + session = manual_session("2026-09-20", item, 45.0, "l") + assert (session.kind, session.km) == (kind, km) + + def test_clock_window_including_past_midnight(self) -> None: + night = manual_session( + "2026-09-20", {"start_time": "23:30", "end_time": "00:30"}, 60, "l" + ) + assert night.start is not None + assert night.end is not None + assert night.end > night.start + assert manual_session("2026-09-20", {"start_time": "x"}, 60, "l").start is None + + def test_logged_window(self) -> None: + start, end = logged_window("2026-09-27T15:15:00+00:00", 60) + assert start is not None + assert end is not None + assert logged_window("never", 60) == (None, None) diff --git a/diet_guard/tests/test_phone_weight.py b/diet_guard/tests/test_phone_weight.py index 918b6f7..ef2c7eb 100644 --- a/diet_guard/tests/test_phone_weight.py +++ b/diet_guard/tests/test_phone_weight.py @@ -1,41 +1,14 @@ -"""Tests for _phone_weight.py -- the phone's weigh-in into the stored weight. +"""Tests for _phone_weight.py -- the weight log's newest weigh-in into ``w``. The on-disk budget is redirected by conftest's ``_isolate_state``. """ from __future__ import annotations -from dataclasses import dataclass -import sys -from unittest.mock import patch - -import pytest - -from diet_guard import _phone_weight +from diet_guard._body_store import set_weight from diet_guard._budget import read_raw_record, write_raw_record from diet_guard._budget_derived import budget_weight -from diet_guard._phone_weight import refresh_weight_from_phone, update_weight - - -@dataclass(frozen=True) -class FakeWeight: - """Shape of ``wake_alarm._weight.Weight``, without the dependency.""" - - date: str - kg: float - device_id: str = "phone" - - -class FakeWeightModule: - """Stand-in for ``wake_alarm._weight`` with a canned ``latest_weight``.""" - - def __init__(self, weight: FakeWeight | None) -> None: - self.weight = weight - self.calls = 0 - - def latest_weight(self) -> FakeWeight | None: - self.calls += 1 - return self.weight +from diet_guard._phone_weight import refresh_weight_from_log, update_weight def _seed(weight: float | None, edited: str) -> None: @@ -45,17 +18,11 @@ def _seed(weight: float | None, edited: str) -> None: write_raw_record(record) -@pytest.fixture -def phone(request: pytest.FixtureRequest) -> FakeWeightModule: - module = FakeWeightModule(getattr(request, "param", None)) - with patch.object(_phone_weight, "import_module", return_value=module): - yield module - - -def _lines_after(module: FakeWeightModule, weight: FakeWeight | None) -> list[str]: - module.weight = weight +def _lines_after(day: str | None, kg: float = 72.4) -> list[str]: + if day is not None: + set_weight(day, kg, "phone") lines: list[str] = [] - refresh_weight_from_phone(lines.append) + refresh_weight_from_log(lines.append) return lines @@ -76,66 +43,56 @@ class TestUpdateWeight: assert read_raw_record() is None -class TestRefreshWeightFromPhone: - def test_applies_a_newer_weigh_in_and_reports_it( - self, phone: FakeWeightModule - ) -> None: +class TestRefreshWeightFromLog: + def test_applies_a_newer_weigh_in_and_reports_it(self) -> None: _seed(78.5, "2026-08-15T18:38:45+02:00") - lines = _lines_after(phone, FakeWeight("2026-09-17", 72.4)) - assert lines == ["weight: 72.4 kg from the phone (2026-09-17)."] + lines = _lines_after("2026-09-17", 72.4) + assert lines == ["weight: 72.4 kg from the weight log (2026-09-17)."] assert budget_weight() == 72.4 - def test_applies_when_the_record_has_no_weight_yet( - self, phone: FakeWeightModule - ) -> None: + def test_applies_when_the_record_has_no_weight_yet(self) -> None: _seed(None, "2026-08-15T18:38:45+02:00") - assert _lines_after(phone, FakeWeight("2026-09-17", 72.4)) + assert _lines_after("2026-09-17", 72.4) assert budget_weight() == 72.4 - def test_applies_when_the_record_has_no_edit_stamp( - self, phone: FakeWeightModule - ) -> None: + def test_applies_when_the_record_has_no_edit_stamp(self) -> None: write_raw_record({"v": 1, "b": 2000}) - assert _lines_after(phone, FakeWeight("2026-09-17", 72.4)) + assert _lines_after("2026-09-17", 72.4) assert budget_weight() == 72.4 - def test_skips_a_weigh_in_from_the_budget_edit_day_or_earlier( - self, phone: FakeWeightModule - ) -> None: + def test_skips_a_weigh_in_from_the_budget_edit_day_or_earlier(self) -> None: _seed(78.5, "2026-09-17T21:30:00+02:00") - assert _lines_after(phone, FakeWeight("2026-09-17", 72.4)) == [] - assert _lines_after(phone, FakeWeight("2026-09-16", 72.4)) == [] + assert _lines_after("2026-09-17", 72.4) == [] + assert _lines_after("2026-09-16", 72.4) == [] assert budget_weight() == 78.5 - def test_skips_a_weight_that_matches_within_tolerance( - self, phone: FakeWeightModule - ) -> None: + def test_skips_a_weight_that_matches_within_tolerance(self) -> None: _seed(72.4, "2026-08-15T18:38:45+02:00") - assert _lines_after(phone, FakeWeight("2026-09-17", 72.44)) == [] + assert _lines_after("2026-09-17", 72.44) == [] record = read_raw_record() assert record is not None assert record["t"] == "2026-08-15T18:38:45+02:00" - def test_a_bool_weight_in_the_record_is_not_a_weight( - self, phone: FakeWeightModule - ) -> None: + def test_a_bool_weight_in_the_record_is_not_a_weight(self) -> None: write_raw_record({"v": 2, "b": 2000, "t": "2026-08-15", "w": True}) - assert _lines_after(phone, FakeWeight("2026-09-17", 72.4)) + assert _lines_after("2026-09-17", 72.4) assert budget_weight() == 72.4 - def test_skips_when_nothing_is_published(self, phone: FakeWeightModule) -> None: + def test_skips_when_nothing_is_published(self) -> None: _seed(78.5, "2026-08-15T18:38:45+02:00") - assert _lines_after(phone, None) == [] + assert _lines_after(None) == [] assert budget_weight() == 78.5 - def test_skips_when_there_is_no_budget(self, phone: FakeWeightModule) -> None: - assert _lines_after(phone, FakeWeight("2026-09-17", 72.4)) == [] + def test_skips_when_there_is_no_budget(self) -> None: + assert _lines_after("2026-09-17", 72.4) == [] assert read_raw_record() is None - def test_without_wake_alarm_installed_does_nothing(self) -> None: + def test_a_deleted_newest_day_falls_back_to_the_previous_one(self) -> None: _seed(78.5, "2026-08-15T18:38:45+02:00") - lines: list[str] = [] - with patch.dict(sys.modules, {"wake_alarm._weight": None}): - refresh_weight_from_phone(lines.append) - assert lines == [] - assert budget_weight() == 78.5 + set_weight("2026-09-16", 74.0) + set_weight("2026-09-17", 72.4) + set_weight("2026-09-17", None) + assert _lines_after(None) == [ + "weight: 74.0 kg from the weight log (2026-09-16)." + ] + assert budget_weight() == 74.0 diff --git a/diet_guard/tests/test_sync.py b/diet_guard/tests/test_sync.py index 36dd8d9..8d75d4f 100644 --- a/diet_guard/tests/test_sync.py +++ b/diet_guard/tests/test_sync.py @@ -79,8 +79,10 @@ class TestRunSync: ) with patch.object(_sync_client, "GitHubSyncClient", return_value=client): _sync.run_sync() - # Every pull -- food log, budget, both banks, catering credential -- - # skips "pc" (this device) and only ever reads "phone"'s files. + # Every pull -- food log, budget, both banks, catering credential, + # body -- skips "pc" (this device) and only ever reads "phone"'s files. + # The wake-alarm weigh-ins are another app's devices, all read: the + # mock lists the same two ids for every directory. requested_paths = [call.args[0] for call in client.get_file_text.call_args_list] assert requested_paths == [ "diet-guard-sync/devices/phone/food_log.json", @@ -88,6 +90,9 @@ class TestRunSync: "diet-guard-sync/devices/phone/food_bank.json", "diet-guard-sync/devices/phone/food_bank_manual.json", "diet-guard-sync/devices/phone/kuchnia.json", + "wake-alarm-sync/devices/pc/alarm.json", + "wake-alarm-sync/devices/phone/alarm.json", + "diet-guard-sync/devices/phone/body.json", ] def test_skips_a_device_with_no_pushed_file_yet(self) -> None: diff --git a/diet_guard/tests/test_sync_budget.py b/diet_guard/tests/test_sync_budget.py index 7e50f3f..e54658d 100644 --- a/diet_guard/tests/test_sync_budget.py +++ b/diet_guard/tests/test_sync_budget.py @@ -38,14 +38,14 @@ class TestSyncBudget: pushed_paths = {call.args[0] for call in client.put_file_text.call_args_list} assert f"diet-guard-sync/devices/{device_id()}/budget.json" in pushed_paths - def test_refreshes_the_weight_from_the_phone_after_the_merge(self) -> None: + def test_refreshes_the_weight_from_the_log_after_the_merge(self) -> None: """The hook runs last, on every successful tick, into the sync log.""" _write_token() write_budget(2000) client = _mock_client(devices=()) with ( patch.object(_sync_client, "GitHubSyncClient", return_value=client), - patch.object(_sync, "refresh_weight_from_phone") as refresh, + patch.object(_sync, "refresh_weight_from_log") as refresh, ): _sync.run_sync() refresh.assert_called_once() diff --git a/docs/DOCS-body.md b/docs/DOCS-body.md new file mode 100644 index 0000000..ac987f3 --- /dev/null +++ b/docs/DOCS-body.md @@ -0,0 +1,143 @@ +# Body: profile, weight log, BMI, ideal weight, calorie targets, week plan + +The **Body** tab (PC gate, `_gatelock_body*.py`) and the **Body** screen +(phone, `app/lib/screens/body_screen.dart`) show the same numbers from the +same synced data. Everything here is **informational**: nothing on either +surface writes the daily budget. The budget stays the number the user edits +by hand. + +## What is stored, and where + +One local document per device, `~/.local/share/diet_guard/body.json` +(phone: document `body` in the app's `DocumentStore`): + +```json +{"v": 1, + "profile": {"birth": {"v": "1995-03-01", "t": ""}, + "height_cm": {"v": 180.0, "t": "..."}, + "sex": {"v": "m", "t": "..."}}, + "weights": {"2026-09-27": {"kg": 81.2, "src": "phone", "t": "..."}}, + "bodyfat": {"2026-09-27": {"pct": 16.8, "t": "..."}}, + "steps": {"2026-09-27": {"n": 8421, "t": "..."}}, + "activity": {"sessions": [, ...], "t": "..."}} +``` + +- **Profile** fields are independent: each has its own edit time and merges + last-writer-wins on its own, so editing height on the phone cannot revert a + birth date edited on the PC. `sex` is `"m"` or `"f"` (the Mifflin constant). + This **reverses** the old "biometrics are used once and discarded" rule -- + the user asked for BMI and ideal weight, which need them permanently. +- **Weights**: one entry per local date. `kg: null` is a tombstone (a deleted + day). `src` is `phone` (wake-alarm weigh-in), `manual` (typed on either + device) or `init`. +- **Profile** also carries `goal` (the remembered calorie goal, below). +- **Body fat**: a dated log like weights (`pct: null` deletes a day). The + newest reading switches the goal's BMR to Katch-McArdle. +- **Activity** is written **only by the PC** (the workout sources live + there); the phone relays and displays it. +- **Steps** are written **only by the phone** (Health Connect, foreground + only): per day, the steps whose intervals do not overlap any published + session's `start..end`, so a recorded walk is not also counted as steps. + +## Wire format + +Pushed as `diet-guard-sync/devices//body.json`, a `crdt_sync` Log: + +| record id | field | value | +|------------------|------------|-----------------------------------------| +| `profile` | `birth` | `"YYYY-MM-DD"` | +| `profile` | `height_cm`| number | +| `profile` | `sex` | `"m"` / `"f"` | +| `profile` | `goal` | `{"dir","amt","wu","tu","act"}` | +| `w:` | `kg` | `{"kg": number or null, "src": string}` | +| `bf:`| `pct` | `{"pct": number or null}` | +| `st:`| `n` | `{"n": integer}` | +| `activity` | `sessions` | list of session objects (below) | + +Every field's Hlc is `wall_time_ms` = its `t`, node = this device's sync id +(same determinism trick as `sync_merge/_budget.py`: an unchanged entry +re-derives the same clock, so re-syncing is a no-op). Unparsable `t` → epoch. +Malformed values are skipped on read, never raised. + +Session object: `{"day": "YYYY-MM-DD", "kind": "strength|run|walk|cycle|other", +"min": 60.5, "km": 5.61 or null, "label": "...", "src": "runnerup|screen-locker", +"start": ISO or absent, "end": ISO or absent}`. Windows come from the TCX start ++ duration, a manual workout's `start_time`/`end_time`, or a verified +workout's log stamp (its end) minus its minutes. + +## Phone weigh-ins (wake-alarm) → weights log + +Both devices ingest on every sync tick, straight from Firebase: +`wake-alarm-sync/devices/*/alarm.json`, record `alarm`, fields +`[value, hlc]`: + +- `latest_weight_kg` = `{"date", "kg"}` → entry `{kg, src: "phone"}` stamped + with **that field's Hlc wall time** (a re-typed weigh-in is newer and wins). +- `morning_sessions` = list of sessions with `date` and `weight_kg` → entries + stamped `T00:00:00+00:00`: they only fill gaps. The list is + republished every morning with a new Hlc, so using that Hlc would overwrite + every manual correction daily. + +A candidate replaces the local entry for its date only if the local entry is +missing or has an **older** `t`. Any manual edit is stamped "now", so it +outranks an ingested weigh-in of the same day. kg outside 20..400 is ignored. +`morning_sessions` keeps only the newest few mornings, so the backfill is +shallow -- history accumulates from here on. + +The newest non-deleted weight also refreshes the budget record's `w` (the +protein target) on the PC, under the same rule `_phone_weight` always used: +only when its date is later than the budget's last edit day. + +## Workouts → exercise kcal (PC, `_activity_sources.py`) + +Sources, last 28 days, deduplicated so one workout counts once: + +- **RunnerUp TCX** in `~/data/cloud/RunnerUp/` and `processed/`: sport, + total seconds, distance. The files carry `0` and no + heart rate, so energy must be estimated. +- **screen-locker** `log.json` via lazy `import screen_locker` (never raises; + missing package → no strength sessions): + `pc_workout_verified` (StrongLifts, `duration_minutes`) → strength; + `phone_verified` only on a day with no `pc_workout_verified` (it is the same + session reported from the phone; minutes parsed from `(86 min`); + `manual_workout` → kind from `activity_type`/`sport`, else from the words + walk/run/jog/hike in any text field, with a distance ("5 km", "3500 m") + read from that text (`_manual_workout_parse.py`); + `runnerup_verified` only on a day with no TCX (same run, TCX is richer); + `relaxed_day_skip` → nothing. + +Formulas (`_activity_kcal.py`), all **net** of resting burn: MET (Compendium +2011, speed-interpolated for runs/walks; lifting 5.0), ACSM run/walk +equations from speed, and Per-km (0.9 / 0.5 kcal/kg/km). Steps above 4000/day +(`_body_steps.py`: stride = height × 0.415 m, 0.5 kcal/kg/km) are added to +every formula's daily total. Heart-rate input was dropped at the user's call. + +## Calculations (`_body_calc.py`, `_body_energy.py`, `_week_plan.py`) + +- **BMI** kg/m², BMI Prime, Trefethen; WHO bands, not applied under 20. +- **Ideal weight**: Devine, Robinson, Miller, Hamwi (undefined < 152.4 cm), + Broca, modified Broca, Lorentz, Creff (the one that uses age), Peterson; + then their average and min-max "ideal range". The BMI 18.5-24.9 healthy + range sits with BMI. +- **BMR**: Mifflin-St Jeor, Harris-Benedict (1984), and with a body fat + Katch-McArdle and Cunningham (lean mass). The goal uses Katch-McArdle when + body fat exists, else Mifflin. +- **Goal** (`_body_energy.py`, the top line): "To lose 0.5 kg / week on + Moderate eat N kcal". Direction lose/maintain/gain; any amount; weight unit + kg/lb/g/st; time unit day/week/month (30.436875 d)/year (365.2425 d); + activity = one of 5 factors (1.2 / 1.375 / 1.55 / 1.725 / 1.9) or Measured + (MET/ACSM/Per-km) = BMR × 1.2 + mean daily net exercise over the 14 full + days before today. Target = TDEE − kg/day × 7700. Under 1200 is flagged, + never clamped. Saved (synced) on every change. +- **Week plan**: Monday-Sunday; typed days and logged past days are known, + the rest share `(target×7 − known) / remaining`. A past day with no log is + `unlogged` and planned like a remaining day. + +Rounding is `half_up` on both sides, never `round`. + +## Parity + +`tests/fixtures/body_calc.json` (from `scripts/build_body_fixture.py`) is +asserted by `diet_guard/tests/test_body_parity.py` and +`app/test/services/body_parity_test.dart`. Change a formula → regenerate → +read the diff → both suites must pass. diff --git a/pyproject.toml b/pyproject.toml index 8855e26..72765c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,8 @@ requires-python = ">=3.14" dependencies = [ "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock", "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync", - "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays", + "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays", + "defusedxml==0.7.1", "requests==2.34.2", ] diff --git a/requirements.txt b/requirements.txt index 10e34b2..6c2da47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,9 +2,10 @@ # Runtime: tkinter/json/hmac/fcntl (stdlib) plus gatelock and requests below. bandit==1.9.4 codespell==2.4.3 -coverage==7.16.1 +coverage==7.16.2 crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync -freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays +defusedxml==0.7.1 +freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock mcp==2.2.0 mypy==2.3.1 diff --git a/scripts/build_body_fixture.py b/scripts/build_body_fixture.py new file mode 100755 index 0000000..0c3dbda --- /dev/null +++ b/scripts/build_body_fixture.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Regenerate the shared cross-language Body-tab parity fixture. + +The fixture (``tests/fixtures/body_calc.json``) is read by *both* +``diet_guard/tests/test_body_parity.py`` and +``app/test/services/body_parity_test.dart``: one shared input, one shared +expected result, so the PC and the phone print the same BMI, ideal weights, +calorie table, exercise kcal and week plan for the same person. + +``expected`` comes from the Python implementation, the reference the Dart +port must reproduce -- so regenerating blesses whatever Python does now. Only +run it after an intended change, and re-read the diff before committing. + + python3 scripts/build_body_fixture.py +""" + +from __future__ import annotations + +from dataclasses import asdict +from datetime import date +import json +from pathlib import Path + +from diet_guard._activity_kcal import Session, daily_exercise, session_kcal +from diet_guard._body_calc import ( + bmi_report, + bmr_formulas, + healthy_range, + ideal_summary, + ideal_weights, + table_bmr, +) +from diet_guard._body_energy import DEFAULT_GOAL, Goal, goal_target +from diet_guard._body_prefs import goal_to_json +from diet_guard._body_steps import step_kcal, stride_m +from diet_guard._budget_biometrics import Biometrics +from diet_guard._week_plan import plan_week + +FIXTURE = Path(__file__).resolve().parent.parent / "tests/fixtures/body_calc.json" + +_PEOPLE = ( + Biometrics(weight_kg=80.0, height_cm=180.0, age_years=30, is_male=True), + Biometrics(weight_kg=62.5, height_cm=165.0, age_years=45, is_male=False), + # Under five feet: the inch-based formulas must come out null. + Biometrics(weight_kg=50.0, height_cm=150.0, age_years=25, is_male=False), + # Under 20: adult BMI bands do not apply. + Biometrics(weight_kg=65.0, height_cm=175.0, age_years=17, is_male=True), + # Big and heavy: deep into the obese bands, targets far above the floor. + Biometrics(weight_kg=121.3, height_cm=191.0, age_years=41, is_male=True), +) + +_SESSIONS = ( + Session("2026-09-20", "run", 60.5, 5.61, "Running 5.6 km", "runnerup"), + Session("2026-09-21", "run", 30.0, 7.5, "fast run", "runnerup"), + Session("2026-09-22", "run", 45.0, 3.0, "slow jog", "runnerup"), + Session("2026-09-23", "walk", 60.0, 5.0, "walk", "screen-locker"), + Session("2026-09-24", "walk", 60.0, None, "walk, no distance", "screen-locker"), + Session("2026-09-25", "strength", 86.2, None, "StrongLifts B", "screen-locker"), + Session("2026-09-26", "cycle", 40.0, 12.0, "bike", "screen-locker"), + Session("2026-09-27", "other", 50.0, None, "climbing", "screen-locker"), + Session("2026-09-19", "walk", 45.0, 3.5, "walked 3500 m", "screen-locker"), + # Outside the 14-day window for today=2026-09-28: must not count. + Session("2026-09-13", "run", 60.0, 10.0, "old run", "runnerup"), + # Today: in progress, must not count either. + Session("2026-09-28", "run", 60.0, 10.0, "today's run", "runnerup"), +) + +_PLANS = ( + # Wednesday: Mon/Tue logged, nothing typed. + ("2026-09-30", 2000, {"2026-09-28": 2500.4, "2026-09-29": 1800.0}, {}), + # Typed Saturday feast wins over nothing; typed Monday wins over the log. + ("2026-09-30", 2000, {"2026-09-28": 2500.0}, {0: 2200, 5: 3500}), + # Sunday with everything known: no remaining days. + ("2026-10-04", 1800, {}, dict.fromkeys(range(7), 1900)), + # Over-eaten early: the rest must go under the floor. + ("2026-10-01", 1500, {"2026-09-28": 4000, "2026-09-29": 3900}, {}), +) + + +_BODY_FAT = 18.5 + + +# Goals exercising every direction, unit and activity kind. +_GOALS = ( + DEFAULT_GOAL, + Goal("gain", 0.25, "kg", "week", "sedentary"), + Goal("maintain", 0.5, "kg", "week", "extra"), + Goal("lose", 1.0, "lb", "week", "light"), + Goal("lose", 500.0, "g", "day", "very"), + Goal("lose", 0.5, "st", "month", "measured:ACSM"), + Goal("lose", 12.0, "kg", "year", "measured:Per-km"), + Goal("lose", 3.0, "kg", "week", "sedentary"), # far below the floor +) +# Phone-published steps outside workouts (one day under the baseline). +_STEPS = {"2026-09-20": 9000, "2026-09-21": 3500, "2026-09-25": 12000} + + +def _person(bio: Biometrics) -> dict[str, object]: + today = date(2026, 9, 28) + by_day = {day: step_kcal(n, bio) for day, n in _STEPS.items()} + exercise = daily_exercise(_SESSIONS, bio.weight_kg, today, by_day) + targets = [] + for fat in (None, _BODY_FAT): + bmr = table_bmr(bio, fat) + for goal in _GOALS: + target = goal_target(goal, bmr.value or 0.0, bmr.name, exercise) + targets.append( + {"body_fat_pct": fat, "goal": goal_to_json(goal), **asdict(target)} + ) + return { + "bio": asdict(bio), + "bmi": asdict(bmi_report(bio)), + "ideal": [[f.name, f.value] for f in ideal_weights(bio)], + "bmr": [[f.name, f.value] for f in bmr_formulas(bio)], + "body_fat_pct": _BODY_FAT, + "bmr_with_fat": [[f.name, f.value] for f in bmr_formulas(bio, _BODY_FAT)], + "ideal_summary": ideal_summary(ideal_weights(bio)), + "healthy_range": healthy_range(bio), + "stride_m": stride_m(bio), + "step_kcal": {day: step_kcal(n, bio) for day, n in _STEPS.items()}, + "exercise": exercise, + "exercise_no_steps": daily_exercise(_SESSIONS, bio.weight_kg, today), + # History younger than 14 days: averaged from its first day. + "exercise_short": daily_exercise( + tuple(x for x in _SESSIONS if x.day >= "2026-09-24"), bio.weight_kg, today + ), + # Only today's (in-progress) session: no past data, all zero. + "exercise_today_only": daily_exercise( + tuple(x for x in _SESSIONS if x.day == "2026-09-28"), bio.weight_kg, today + ), + "targets": targets, + } + + +def build() -> dict[str, object]: + """Evaluate every vector against the Python reference.""" + return { + "today": "2026-09-28", + "people": [_person(bio) for bio in _PEOPLE], + "steps": _STEPS, + "sessions": [asdict(s) for s in _SESSIONS], + "session_kcal_80kg": [ + { + "kcal": result.kcal, + "fallback": sorted(result.fallback), + } + for result in (session_kcal(s, 80.0) for s in _SESSIONS) + ], + "plans": [ + { + "today": today, + "target": target, + "logged": logged, + "typed": {str(k): v for k, v in typed.items()}, + "expected": asdict( + plan_week(date.fromisoformat(today), target, logged, typed) + ), + } + for today, target, logged, typed in _PLANS + ], + } + + +def main() -> None: + """Write the fixture.""" + FIXTURE.write_text(json.dumps(build(), indent=1) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/body_calc.json b/tests/fixtures/body_calc.json new file mode 100644 index 0000000..5df3791 --- /dev/null +++ b/tests/fixtures/body_calc.json @@ -0,0 +1,2135 @@ +{ + "today": "2026-09-28", + "people": [ + { + "bio": { + "weight_kg": 80.0, + "height_cm": 180.0, + "age_years": 30, + "is_male": true + }, + "bmi": { + "bmi": 24.691358024691358, + "prime": 0.9876543209876543, + "trefethen": 23.925007166664415, + "category": "normal" + }, + "ideal": [ + [ + "Devine", + 74.99212598425197 + ], + [ + "Robinson", + 72.64566929133858 + ], + [ + "Miller", + 71.5212598425197 + ], + [ + "Hamwi", + 77.33858267716536 + ], + [ + "Broca", + 80.0 + ], + [ + "Broca (modified)", + 72.0 + ], + [ + "Lorentz", + 72.5 + ], + [ + "Creff (age)", + 74.7 + ], + [ + "Peterson (BMI 22)", + 71.50000000000001 + ] + ], + "bmr": [ + [ + "Mifflin-St Jeor", + 1780.0 + ], + [ + "Harris-Benedict (revised 1984)", + 1853.632 + ], + [ + "Katch-McArdle", + null + ], + [ + "Cunningham", + null + ] + ], + "body_fat_pct": 18.5, + "bmr_with_fat": [ + [ + "Mifflin-St Jeor", + 1780.0 + ], + [ + "Harris-Benedict (revised 1984)", + 1853.632 + ], + [ + "Katch-McArdle", + 1778.32 + ], + [ + "Cunningham", + 1934.3999999999996 + ] + ], + "ideal_summary": [ + 74.13307086614174, + 71.50000000000001, + 80.0 + ], + "healthy_range": [ + 59.94000000000001, + 80.676 + ], + "stride_m": 0.747, + "step_kcal": { + "2026-09-20": 149.4, + "2026-09-21": 0.0, + "2026-09-25": 239.04 + }, + "exercise": { + "MET": 225.17309523809521, + "ACSM": 230.26, + "Per-km": 221.05428571428573 + }, + "exercise_no_steps": { + "MET": 197.42738095238093, + "ACSM": 202.5142857142857, + "Per-km": 193.30857142857144 + }, + "exercise_short": { + "MET": 301.6, + "ACSM": 301.6, + "Per-km": 301.6 + }, + "exercise_today_only": { + "MET": 0.0, + "ACSM": 0.0, + "Per-km": 0.0 + }, + "targets": [ + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "moderate" + }, + "kcal": 2209, + "tdee": 2759.0, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "gain", + "amt": 0.25, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": 2411, + "tdee": 2136.0, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "maintain", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "extra" + }, + "kcal": 3382, + "tdee": 3382.0, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 1.0, + "wu": "lb", + "tu": "week", + "act": "light" + }, + "kcal": 1949, + "tdee": 2447.5, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 500.0, + "wu": "g", + "tu": "day", + "act": "very" + }, + "kcal": -779, + "tdee": 3070.5, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "st", + "tu": "month", + "act": "measured:ACSM" + }, + "kcal": 1563, + "tdee": 2366.26, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 12.0, + "wu": "kg", + "tu": "year", + "act": "measured:Per-km" + }, + "kcal": 2104, + "tdee": 2357.054285714286, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 3.0, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": -1164, + "tdee": 2136.0, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "moderate" + }, + "kcal": 2206, + "tdee": 2756.396, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "gain", + "amt": 0.25, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": 2409, + "tdee": 2133.984, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "maintain", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "extra" + }, + "kcal": 3379, + "tdee": 3378.8079999999995, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 1.0, + "wu": "lb", + "tu": "week", + "act": "light" + }, + "kcal": 1946, + "tdee": 2445.19, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 500.0, + "wu": "g", + "tu": "day", + "act": "very" + }, + "kcal": -782, + "tdee": 3067.602, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "st", + "tu": "month", + "act": "measured:ACSM" + }, + "kcal": 1561, + "tdee": 2364.2439999999997, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 12.0, + "wu": "kg", + "tu": "year", + "act": "measured:Per-km" + }, + "kcal": 2102, + "tdee": 2355.038285714286, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 3.0, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": -1166, + "tdee": 2133.984, + "bmr_name": "Katch-McArdle" + } + ] + }, + { + "bio": { + "weight_kg": 62.5, + "height_cm": 165.0, + "age_years": 45, + "is_male": false + }, + "bmi": { + "bmi": 22.956841138659325, + "prime": 0.918273645546373, + "trefethen": 23.233439564049128, + "category": "normal" + }, + "ideal": [ + [ + "Devine", + 56.90944881889762 + ], + [ + "Robinson", + 57.43307086614172 + ], + [ + "Miller", + 59.84645669291338 + ], + [ + "Hamwi", + 56.413385826771645 + ], + [ + "Broca", + 65.0 + ], + [ + "Broca (modified)", + 55.25 + ], + [ + "Lorentz", + 57.5 + ], + [ + "Creff (age)", + 62.550000000000004 + ], + [ + "Peterson (BMI 22)", + 59.95 + ] + ], + "bmr": [ + [ + "Mifflin-St Jeor", + 1270.25 + ], + [ + "Harris-Benedict (revised 1984)", + 1341.8505 + ], + [ + "Katch-McArdle", + null + ], + [ + "Cunningham", + null + ] + ], + "body_fat_pct": 18.5, + "bmr_with_fat": [ + [ + "Mifflin-St Jeor", + 1270.25 + ], + [ + "Harris-Benedict (revised 1984)", + 1341.8505 + ], + [ + "Katch-McArdle", + 1470.25 + ], + [ + "Cunningham", + 1620.625 + ] + ], + "ideal_summary": [ + 58.98359580052493, + 55.25, + 65.0 + ], + "healthy_range": [ + 50.366249999999994, + 67.79024999999999 + ], + "stride_m": 0.6814499999999999, + "step_kcal": { + "2026-09-20": 106.47656249999999, + "2026-09-21": 0.0, + "2026-09-25": 170.36249999999998 + }, + "exercise": { + "MET": 174.01436011904767, + "ACSM": 177.98850446428574, + "Per-km": 170.79654017857143 + }, + "exercise_no_steps": { + "MET": 154.24014136904765, + "ACSM": 158.21428571428572, + "Per-km": 151.02232142857142 + }, + "exercise_short": { + "MET": 235.625, + "ACSM": 235.625, + "Per-km": 235.625 + }, + "exercise_today_only": { + "MET": 0.0, + "ACSM": 0.0, + "Per-km": 0.0 + }, + "targets": [ + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "moderate" + }, + "kcal": 1419, + "tdee": 1968.8875, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "gain", + "amt": 0.25, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": 1799, + "tdee": 1524.3, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "maintain", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "extra" + }, + "kcal": 2413, + "tdee": 2413.475, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 1.0, + "wu": "lb", + "tu": "week", + "act": "light" + }, + "kcal": 1248, + "tdee": 1746.59375, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 500.0, + "wu": "g", + "tu": "day", + "act": "very" + }, + "kcal": -1659, + "tdee": 2191.18125, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "st", + "tu": "month", + "act": "measured:ACSM" + }, + "kcal": 899, + "tdee": 1702.2885044642858, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 12.0, + "wu": "kg", + "tu": "year", + "act": "measured:Per-km" + }, + "kcal": 1442, + "tdee": 1695.0965401785713, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 3.0, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": -1776, + "tdee": 1524.3, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "moderate" + }, + "kcal": 1729, + "tdee": 2278.8875000000003, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "gain", + "amt": 0.25, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": 2039, + "tdee": 1764.3, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "maintain", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "extra" + }, + "kcal": 2793, + "tdee": 2793.475, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 1.0, + "wu": "lb", + "tu": "week", + "act": "light" + }, + "kcal": 1523, + "tdee": 2021.59375, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 500.0, + "wu": "g", + "tu": "day", + "act": "very" + }, + "kcal": -1314, + "tdee": 2536.18125, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "st", + "tu": "month", + "act": "measured:ACSM" + }, + "kcal": 1139, + "tdee": 1942.2885044642858, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 12.0, + "wu": "kg", + "tu": "year", + "act": "measured:Per-km" + }, + "kcal": 1682, + "tdee": 1935.0965401785713, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 3.0, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": -1536, + "tdee": 1764.3, + "bmr_name": "Katch-McArdle" + } + ] + }, + { + "bio": { + "weight_kg": 50.0, + "height_cm": 150.0, + "age_years": 25, + "is_male": false + }, + "bmi": { + "bmi": 22.22222222222222, + "prime": 0.8888888888888888, + "trefethen": 23.587679004578753, + "category": "normal" + }, + "ideal": [ + [ + "Devine", + null + ], + [ + "Robinson", + null + ], + [ + "Miller", + null + ], + [ + "Hamwi", + null + ], + [ + "Broca", + 50.0 + ], + [ + "Broca (modified)", + 42.5 + ], + [ + "Lorentz", + 50.0 + ], + [ + "Creff (age)", + 47.25 + ], + [ + "Peterson (BMI 22)", + 48.400000000000006 + ] + ], + "bmr": [ + [ + "Mifflin-St Jeor", + 1151.5 + ], + [ + "Harris-Benedict (revised 1984)", + 1266.393 + ], + [ + "Katch-McArdle", + null + ], + [ + "Cunningham", + null + ] + ], + "body_fat_pct": 18.5, + "bmr_with_fat": [ + [ + "Mifflin-St Jeor", + 1151.5 + ], + [ + "Harris-Benedict (revised 1984)", + 1266.393 + ], + [ + "Katch-McArdle", + 1250.2 + ], + [ + "Cunningham", + 1396.5 + ] + ], + "ideal_summary": [ + 47.63, + 42.5, + 50.0 + ], + "healthy_range": [ + 41.625, + 56.02499999999999 + ], + "stride_m": 0.6194999999999999, + "step_kcal": { + "2026-09-20": 77.43749999999999, + "2026-09-21": 0.0, + "2026-09-25": 123.89999999999999 + }, + "exercise": { + "MET": 137.7733630952381, + "ACSM": 140.9526785714286, + "Per-km": 135.19910714285714 + }, + "exercise_no_steps": { + "MET": 123.39211309523809, + "ACSM": 126.57142857142858, + "Per-km": 120.81785714285715 + }, + "exercise_short": { + "MET": 188.5, + "ACSM": 188.5, + "Per-km": 188.5 + }, + "exercise_today_only": { + "MET": 0.0, + "ACSM": 0.0, + "Per-km": 0.0 + }, + "targets": [ + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "moderate" + }, + "kcal": 1235, + "tdee": 1784.825, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "gain", + "amt": 0.25, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": 1657, + "tdee": 1381.8, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "maintain", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "extra" + }, + "kcal": 2188, + "tdee": 2187.85, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 1.0, + "wu": "lb", + "tu": "week", + "act": "light" + }, + "kcal": 1084, + "tdee": 1583.3125, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 500.0, + "wu": "g", + "tu": "day", + "act": "very" + }, + "kcal": -1864, + "tdee": 1986.3375, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "st", + "tu": "month", + "act": "measured:ACSM" + }, + "kcal": 719, + "tdee": 1522.7526785714285, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 12.0, + "wu": "kg", + "tu": "year", + "act": "measured:Per-km" + }, + "kcal": 1264, + "tdee": 1516.9991071428572, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 3.0, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": -1918, + "tdee": 1381.8, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "moderate" + }, + "kcal": 1388, + "tdee": 1937.8100000000002, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "gain", + "amt": 0.25, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": 1775, + "tdee": 1500.24, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "maintain", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "extra" + }, + "kcal": 2375, + "tdee": 2375.38, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 1.0, + "wu": "lb", + "tu": "week", + "act": "light" + }, + "kcal": 1220, + "tdee": 1719.025, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 500.0, + "wu": "g", + "tu": "day", + "act": "very" + }, + "kcal": -1693, + "tdee": 2156.5950000000003, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "st", + "tu": "month", + "act": "measured:ACSM" + }, + "kcal": 838, + "tdee": 1641.1926785714286, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 12.0, + "wu": "kg", + "tu": "year", + "act": "measured:Per-km" + }, + "kcal": 1382, + "tdee": 1635.4391071428572, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 3.0, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": -1800, + "tdee": 1500.24, + "bmr_name": "Katch-McArdle" + } + ] + }, + { + "bio": { + "weight_kg": 65.0, + "height_cm": 175.0, + "age_years": 17, + "is_male": true + }, + "bmi": { + "bmi": 21.224489795918366, + "prime": 0.8489795918367347, + "trefethen": 20.857468061570415, + "category": "adult categories do not apply under 20" + }, + "ideal": [ + [ + "Devine", + 70.46456692913384 + ], + [ + "Robinson", + 68.90551181102362 + ], + [ + "Miller", + 68.74566929133857 + ], + [ + "Hamwi", + 72.02362204724409 + ], + [ + "Broca", + 75.0 + ], + [ + "Broca (modified)", + 67.5 + ], + [ + "Lorentz", + 68.75 + ], + [ + "Creff (age)", + 69.03 + ], + [ + "Peterson (BMI 22)", + 67.65 + ] + ], + "bmr": [ + [ + "Mifflin-St Jeor", + 1663.75 + ], + [ + "Harris-Benedict (revised 1984)", + 1702.4830000000002 + ], + [ + "Katch-McArdle", + null + ], + [ + "Cunningham", + null + ] + ], + "body_fat_pct": 18.5, + "bmr_with_fat": [ + [ + "Mifflin-St Jeor", + 1663.75 + ], + [ + "Harris-Benedict (revised 1984)", + 1702.4830000000002 + ], + [ + "Katch-McArdle", + 1514.26 + ], + [ + "Cunningham", + 1665.4499999999998 + ] + ], + "ideal_summary": [ + 69.78548556430445, + 67.5, + 75.0 + ], + "healthy_range": [ + 56.65625, + 76.25625 + ], + "stride_m": 0.72625, + "step_kcal": { + "2026-09-20": 118.01562499999999, + "2026-09-21": 0.0, + "2026-09-25": 188.825 + }, + "exercise": { + "MET": 182.3269345238095, + "ACSM": 186.46004464285713, + "Per-km": 178.98040178571426 + }, + "exercise_no_steps": { + "MET": 160.40974702380953, + "ACSM": 164.54285714285714, + "Per-km": 157.06321428571428 + }, + "exercise_short": { + "MET": 245.04999999999998, + "ACSM": 245.04999999999998, + "Per-km": 245.04999999999998 + }, + "exercise_today_only": { + "MET": 0.0, + "ACSM": 0.0, + "Per-km": 0.0 + }, + "targets": [ + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "moderate" + }, + "kcal": 2029, + "tdee": 2578.8125, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "gain", + "amt": 0.25, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": 2272, + "tdee": 1996.5, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "maintain", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "extra" + }, + "kcal": 3161, + "tdee": 3161.125, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 1.0, + "wu": "lb", + "tu": "week", + "act": "light" + }, + "kcal": 1789, + "tdee": 2287.65625, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 500.0, + "wu": "g", + "tu": "day", + "act": "very" + }, + "kcal": -980, + "tdee": 2869.96875, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "st", + "tu": "month", + "act": "measured:ACSM" + }, + "kcal": 1380, + "tdee": 2182.960044642857, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 12.0, + "wu": "kg", + "tu": "year", + "act": "measured:Per-km" + }, + "kcal": 1922, + "tdee": 2175.480401785714, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 3.0, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": -1303, + "tdee": 1996.5, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "moderate" + }, + "kcal": 1797, + "tdee": 2347.103, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "gain", + "amt": 0.25, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": 2092, + "tdee": 1817.1119999999999, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "maintain", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "extra" + }, + "kcal": 2877, + "tdee": 2877.094, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 1.0, + "wu": "lb", + "tu": "week", + "act": "light" + }, + "kcal": 1583, + "tdee": 2082.1075, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 500.0, + "wu": "g", + "tu": "day", + "act": "very" + }, + "kcal": -1238, + "tdee": 2612.0985, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "st", + "tu": "month", + "act": "measured:ACSM" + }, + "kcal": 1200, + "tdee": 2003.572044642857, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 12.0, + "wu": "kg", + "tu": "year", + "act": "measured:Per-km" + }, + "kcal": 1743, + "tdee": 1996.0924017857142, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 3.0, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": -1483, + "tdee": 1817.1119999999999, + "bmr_name": "Katch-McArdle" + } + ] + }, + { + "bio": { + "weight_kg": 121.3, + "height_cm": 191.0, + "age_years": 41, + "is_male": true + }, + "bmi": { + "bmi": 33.2501850278227, + "prime": 1.3300074011129082, + "trefethen": 31.276686359628844, + "category": "obese class I" + }, + "ideal": [ + [ + "Devine", + 84.9527559055118 + ], + [ + "Robinson", + 80.8740157480315 + ], + [ + "Miller", + 77.62755905511811 + ], + [ + "Hamwi", + 89.03149606299212 + ], + [ + "Broca", + 91.0 + ], + [ + "Broca (modified)", + 81.9 + ], + [ + "Lorentz", + 80.75 + ], + [ + "Creff (age)", + 85.59 + ], + [ + "Peterson (BMI 22)", + 79.97 + ] + ], + "bmr": [ + [ + "Mifflin-St Jeor", + 2206.75 + ], + [ + "Harris-Benedict (revised 1984)", + 2397.2701 + ], + [ + "Katch-McArdle", + null + ], + [ + "Cunningham", + null + ] + ], + "body_fat_pct": 18.5, + "bmr_with_fat": [ + [ + "Mifflin-St Jeor", + 2206.75 + ], + [ + "Harris-Benedict (revised 1984)", + 2397.2701 + ], + [ + "Katch-McArdle", + 2505.3652 + ], + [ + "Cunningham", + 2674.909 + ] + ], + "ideal_summary": [ + 83.52175853018372, + 77.62755905511811, + 91.0 + ], + "healthy_range": [ + 67.48985, + 90.83769 + ], + "stride_m": 0.79265, + "step_kcal": { + "2026-09-20": 240.37111249999998, + "2026-09-21": 0.0, + "2026-09-25": 384.59378 + }, + "exercise": { + "MET": 343.9896158333333, + "ACSM": 351.70263517857137, + "Per-km": 337.74447089285707 + }, + "exercise_no_steps": { + "MET": 299.3492663690476, + "ACSM": 307.0622857142857, + "Per-km": 293.1041214285714 + }, + "exercise_short": { + "MET": 457.301, + "ACSM": 457.301, + "Per-km": 457.301 + }, + "exercise_today_only": { + "MET": 0.0, + "ACSM": 0.0, + "Per-km": 0.0 + }, + "targets": [ + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "moderate" + }, + "kcal": 2870, + "tdee": 3420.4625, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "gain", + "amt": 0.25, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": 2923, + "tdee": 2648.1, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "maintain", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "extra" + }, + "kcal": 4193, + "tdee": 4192.825, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 1.0, + "wu": "lb", + "tu": "week", + "act": "light" + }, + "kcal": 2535, + "tdee": 3034.28125, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 500.0, + "wu": "g", + "tu": "day", + "act": "very" + }, + "kcal": -43, + "tdee": 3806.64375, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "st", + "tu": "month", + "act": "measured:ACSM" + }, + "kcal": 2197, + "tdee": 2999.802635178571, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 12.0, + "wu": "kg", + "tu": "year", + "act": "measured:Per-km" + }, + "kcal": 2733, + "tdee": 2985.844470892857, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": null, + "goal": { + "dir": "lose", + "amt": 3.0, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": -652, + "tdee": 2648.1, + "bmr_name": "Mifflin-St Jeor" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "moderate" + }, + "kcal": 3333, + "tdee": 3883.3160600000006, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "gain", + "amt": 0.25, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": 3281, + "tdee": 3006.43824, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "maintain", + "amt": 0.5, + "wu": "kg", + "tu": "week", + "act": "extra" + }, + "kcal": 4760, + "tdee": 4760.19388, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 1.0, + "wu": "lb", + "tu": "week", + "act": "light" + }, + "kcal": 2946, + "tdee": 3444.8771500000003, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 500.0, + "wu": "g", + "tu": "day", + "act": "very" + }, + "kcal": 472, + "tdee": 4321.754970000001, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 0.5, + "wu": "st", + "tu": "month", + "act": "measured:ACSM" + }, + "kcal": 2555, + "tdee": 3358.140875178571, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 12.0, + "wu": "kg", + "tu": "year", + "act": "measured:Per-km" + }, + "kcal": 3091, + "tdee": 3344.182710892857, + "bmr_name": "Katch-McArdle" + }, + { + "body_fat_pct": 18.5, + "goal": { + "dir": "lose", + "amt": 3.0, + "wu": "kg", + "tu": "week", + "act": "sedentary" + }, + "kcal": -294, + "tdee": 3006.43824, + "bmr_name": "Katch-McArdle" + } + ] + } + ], + "steps": { + "2026-09-20": 9000, + "2026-09-21": 3500, + "2026-09-25": 12000 + }, + "sessions": [ + { + "day": "2026-09-20", + "kind": "run", + "minutes": 60.5, + "km": 5.61, + "label": "Running 5.6 km", + "source": "runnerup", + "start": null, + "end": null + }, + { + "day": "2026-09-21", + "kind": "run", + "minutes": 30.0, + "km": 7.5, + "label": "fast run", + "source": "runnerup", + "start": null, + "end": null + }, + { + "day": "2026-09-22", + "kind": "run", + "minutes": 45.0, + "km": 3.0, + "label": "slow jog", + "source": "runnerup", + "start": null, + "end": null + }, + { + "day": "2026-09-23", + "kind": "walk", + "minutes": 60.0, + "km": 5.0, + "label": "walk", + "source": "screen-locker", + "start": null, + "end": null + }, + { + "day": "2026-09-24", + "kind": "walk", + "minutes": 60.0, + "km": null, + "label": "walk, no distance", + "source": "screen-locker", + "start": null, + "end": null + }, + { + "day": "2026-09-25", + "kind": "strength", + "minutes": 86.2, + "km": null, + "label": "StrongLifts B", + "source": "screen-locker", + "start": null, + "end": null + }, + { + "day": "2026-09-26", + "kind": "cycle", + "minutes": 40.0, + "km": 12.0, + "label": "bike", + "source": "screen-locker", + "start": null, + "end": null + }, + { + "day": "2026-09-27", + "kind": "other", + "minutes": 50.0, + "km": null, + "label": "climbing", + "source": "screen-locker", + "start": null, + "end": null + }, + { + "day": "2026-09-19", + "kind": "walk", + "minutes": 45.0, + "km": 3.5, + "label": "walked 3500 m", + "source": "screen-locker", + "start": null, + "end": null + }, + { + "day": "2026-09-13", + "kind": "run", + "minutes": 60.0, + "km": 10.0, + "label": "old run", + "source": "runnerup", + "start": null, + "end": null + }, + { + "day": "2026-09-28", + "kind": "run", + "minutes": 60.0, + "km": 10.0, + "label": "today's run", + "source": "runnerup", + "start": null, + "end": null + } + ], + "session_kcal_80kg": [ + { + "kcal": { + "MET": 403.3333333333333, + "ACSM": 448.8, + "Per-km": 403.92 + }, + "fallback": [] + }, + { + "kcal": { + "MET": 493.25, + "ACSM": 600.0, + "Per-km": 540.0 + }, + "fallback": [] + }, + { + "kcal": { + "MET": 300.0, + "ACSM": 240.00000000000006, + "Per-km": 216.0 + }, + "fallback": [] + }, + { + "kcal": { + "MET": 216.0, + "ACSM": 200.00000000000003, + "Per-km": 200.0 + }, + "fallback": [] + }, + { + "kcal": { + "MET": 200.0, + "ACSM": 200.0, + "Per-km": 200.0 + }, + "fallback": [ + "ACSM", + "Per-km" + ] + }, + { + "kcal": { + "MET": 459.73333333333335, + "ACSM": 459.73333333333335, + "Per-km": 459.73333333333335 + }, + "fallback": [ + "ACSM", + "Per-km" + ] + }, + { + "kcal": { + "MET": 346.66666666666663, + "ACSM": 346.66666666666663, + "Per-km": 346.66666666666663 + }, + "fallback": [ + "ACSM", + "Per-km" + ] + }, + { + "kcal": { + "MET": 200.0, + "ACSM": 200.0, + "Per-km": 200.0 + }, + "fallback": [ + "ACSM", + "Per-km" + ] + }, + { + "kcal": { + "MET": 145.00000000000003, + "ACSM": 140.00000000000003, + "Per-km": 140.0 + }, + "fallback": [] + }, + { + "kcal": { + "MET": 719.2727272727274, + "ACSM": 800.0000000000001, + "Per-km": 720.0 + }, + "fallback": [] + }, + { + "kcal": { + "MET": 719.2727272727274, + "ACSM": 800.0000000000001, + "Per-km": 720.0 + }, + "fallback": [] + } + ], + "plans": [ + { + "today": "2026-09-30", + "target": 2000, + "logged": { + "2026-09-28": 2500.4, + "2026-09-29": 1800.0 + }, + "typed": {}, + "expected": { + "days": [ + { + "day": "2026-09-28", + "weekday": "Mon", + "kcal": 2500, + "source": "logged" + }, + { + "day": "2026-09-29", + "weekday": "Tue", + "kcal": 1800, + "source": "logged" + }, + { + "day": "2026-09-30", + "weekday": "Wed", + "kcal": 1940, + "source": "plan" + }, + { + "day": "2026-10-01", + "weekday": "Thu", + "kcal": 1940, + "source": "plan" + }, + { + "day": "2026-10-02", + "weekday": "Fri", + "kcal": 1940, + "source": "plan" + }, + { + "day": "2026-10-03", + "weekday": "Sat", + "kcal": 1940, + "source": "plan" + }, + { + "day": "2026-10-04", + "weekday": "Sun", + "kcal": 1940, + "source": "plan" + } + ], + "target_avg": 2000, + "per_remaining": 1940, + "week_avg": 2000.0 + } + }, + { + "today": "2026-09-30", + "target": 2000, + "logged": { + "2026-09-28": 2500.0 + }, + "typed": { + "0": 2200, + "5": 3500 + }, + "expected": { + "days": [ + { + "day": "2026-09-28", + "weekday": "Mon", + "kcal": 2200, + "source": "typed" + }, + { + "day": "2026-09-29", + "weekday": "Tue", + "kcal": 1660, + "source": "unlogged" + }, + { + "day": "2026-09-30", + "weekday": "Wed", + "kcal": 1660, + "source": "plan" + }, + { + "day": "2026-10-01", + "weekday": "Thu", + "kcal": 1660, + "source": "plan" + }, + { + "day": "2026-10-02", + "weekday": "Fri", + "kcal": 1660, + "source": "plan" + }, + { + "day": "2026-10-03", + "weekday": "Sat", + "kcal": 3500, + "source": "typed" + }, + { + "day": "2026-10-04", + "weekday": "Sun", + "kcal": 1660, + "source": "plan" + } + ], + "target_avg": 2000, + "per_remaining": 1660, + "week_avg": 2000.0 + } + }, + { + "today": "2026-10-04", + "target": 1800, + "logged": {}, + "typed": { + "0": 1900, + "1": 1900, + "2": 1900, + "3": 1900, + "4": 1900, + "5": 1900, + "6": 1900 + }, + "expected": { + "days": [ + { + "day": "2026-09-28", + "weekday": "Mon", + "kcal": 1900, + "source": "typed" + }, + { + "day": "2026-09-29", + "weekday": "Tue", + "kcal": 1900, + "source": "typed" + }, + { + "day": "2026-09-30", + "weekday": "Wed", + "kcal": 1900, + "source": "typed" + }, + { + "day": "2026-10-01", + "weekday": "Thu", + "kcal": 1900, + "source": "typed" + }, + { + "day": "2026-10-02", + "weekday": "Fri", + "kcal": 1900, + "source": "typed" + }, + { + "day": "2026-10-03", + "weekday": "Sat", + "kcal": 1900, + "source": "typed" + }, + { + "day": "2026-10-04", + "weekday": "Sun", + "kcal": 1900, + "source": "typed" + } + ], + "target_avg": 1800, + "per_remaining": null, + "week_avg": 1900.0 + } + }, + { + "today": "2026-10-01", + "target": 1500, + "logged": { + "2026-09-28": 4000, + "2026-09-29": 3900 + }, + "typed": {}, + "expected": { + "days": [ + { + "day": "2026-09-28", + "weekday": "Mon", + "kcal": 4000, + "source": "logged" + }, + { + "day": "2026-09-29", + "weekday": "Tue", + "kcal": 3900, + "source": "logged" + }, + { + "day": "2026-09-30", + "weekday": "Wed", + "kcal": 520, + "source": "unlogged" + }, + { + "day": "2026-10-01", + "weekday": "Thu", + "kcal": 520, + "source": "plan" + }, + { + "day": "2026-10-02", + "weekday": "Fri", + "kcal": 520, + "source": "plan" + }, + { + "day": "2026-10-03", + "weekday": "Sat", + "kcal": 520, + "source": "plan" + }, + { + "day": "2026-10-04", + "weekday": "Sun", + "kcal": 520, + "source": "plan" + } + ], + "target_avg": 1500, + "per_remaining": 520, + "week_avg": 1500.0 + } + } + ] +}