Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8049bcf
support NutritionalPlan start & end dates
Dieterbe Jun 4, 2025
f18a60b
dart run build_runner build
Dieterbe Jun 27, 2025
eabe425
show start & end dates in the relevant places
Dieterbe Jun 27, 2025
7986df3
consistent style
Dieterbe Jun 27, 2025
d1cfce7
adjust older code for 'current plan' and showing weight during plan
Dieterbe Jun 27, 2025
56adac2
display weight change per NP on the NP list page
Dieterbe Jun 27, 2025
8ee1195
update tests for new start&end fields
Dieterbe Jun 27, 2025
450b4da
fix
Dieterbe Jun 27, 2025
1e5b44c
fix tests part 1
Dieterbe Jun 27, 2025
a0e2659
dart format --line-length=100 .
Dieterbe Jun 27, 2025
650ef38
simpler date formatting
Dieterbe Jun 28, 2025
20713ee
fix tests
Dieterbe Jun 28, 2025
e382387
fix localisation for 'open ended'
Dieterbe Jun 28, 2025
68d9a1f
update goldens
Dieterbe Jun 28, 2025
40f94c2
cleanup
Dieterbe Jun 28, 2025
29f6c87
show charts for all nutritional plans + more elegant filtering
Dieterbe Jun 28, 2025
d4a272c
use interpolation for higher accuracy
Dieterbe Jun 28, 2025
40dbb76
fix tests
Dieterbe Jun 28, 2025
ac043ba
Merge branch 'master' into nutrition-plan-stats
rolandgeider Sep 3, 2025
4dff6ea
Fix import so that "isSameDayAs" becomes available again
rolandgeider Sep 4, 2025
0fd2af0
Merge branch 'refs/heads/master' into nutrition-plan-stats
rolandgeider Sep 12, 2025
6cc9631
Simplify currentPlan
rolandgeider Sep 12, 2025
d9b4d66
Add tests for interpolation logic
rolandgeider Sep 12, 2025
dbd3fa9
Refactor PlanForm
rolandgeider Sep 12, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/helpers/consts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,7 @@ enum WeightUnitEnum { kg, lb }
const textInputTypeDecimal = TextInputType.numberWithOptions(decimal: true);

const String API_MAX_PAGE_SIZE = '999';

/// Marker used for identifying interpolated values in a list, e.g. for measurements
/// the milliseconds in the entry date are set to this value
const INTERPOLATION_MARKER = 123;
113 changes: 113 additions & 0 deletions lib/helpers/measurements.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* This file is part of wger Workout Manager <https://github.com/wger-project>.
* Copyright (C) 2020, 2021 wger Team
*
* wger Workout Manager is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/date.dart';
import 'package:wger/widgets/measurements/charts.dart';

extension MeasurementChartEntryListExtensions on List<MeasurementChartEntry> {
List<MeasurementChartEntry> whereDate(DateTime start, DateTime? end) {
return where((e) => e.date.isAfter(start) && (end == null || e.date.isBefore(end))).toList();
}

// assures values on the start (and optionally end) dates exist, by interpolating if needed
// this is used for when you are looking at a specific time frame (e.g. for a nutrition plan)
// while gaps in the middle of a chart can be "visually interpolated", it's good to have a clearer
// explicit interpolation for the start and end dates (if needed)
// this also helps with computing delta's across the entire window
List<MeasurementChartEntry> whereDateWithInterpolation(DateTime start, DateTime? end) {
// Make sure our list is sorted by date
sort((a, b) => a.date.compareTo(b.date));

// Initialize result list
final List<MeasurementChartEntry> result = [];

// Check if we have any entries on the same day as start/end
bool hasEntryOnStartDay = false;
bool hasEntryOnEndDay = false;

// Track entries for potential interpolation
MeasurementChartEntry? lastBeforeStart;
MeasurementChartEntry? lastBeforeEnd;

// Single pass through the data
for (final entry in this) {
if (entry.date.isSameDayAs(start)) {
hasEntryOnStartDay = true;
}
if (end != null && entry.date.isSameDayAs(end)) {
hasEntryOnEndDay = true;
}

if (end != null && entry.date.isBefore(end)) {
lastBeforeEnd = entry;
}

if (entry.date.isBefore(start)) {
lastBeforeStart = entry;
} else {
// insert interpolated start value if needed
if (!hasEntryOnStartDay && lastBeforeStart != null) {
result.insert(0, interpolateBetween(lastBeforeStart, entry, start));
hasEntryOnStartDay = true;
}

if (end == null || entry.date.isBefore(end)) {
result.add(entry);
}
if (end != null && entry.date.isAfter(end)) {
// insert interpolated end value if needed
// note: we only interpolate end if we have data going beyond end
// if let's say your plan ends in a week from now, we wouldn't want to fake data until next week.
if (!hasEntryOnEndDay && lastBeforeEnd != null) {
result.add(interpolateBetween(lastBeforeEnd, entry, end));
hasEntryOnEndDay = true;
}
// we added all our values and did all interpolations
// surely all input values from here on are irrelevant.
return result;
}
}
}
return result;
}
}

// caller needs to make sure that before.date < date < after.date
MeasurementChartEntry interpolateBetween(
MeasurementChartEntry before, MeasurementChartEntry after, DateTime date) {
final totalDuration = after.date.difference(before.date).inMilliseconds;
final startDuration = date.difference(before.date).inMilliseconds;

// Create a special DateTime with milliseconds ending in 123 to mark it as interpolated
// which we leverage in the UI
final markedDate = DateTime(
date.year,
date.month,
date.day,
date.hour,
date.minute,
date.second,
INTERPOLATION_MARKER,
);

return MeasurementChartEntry(
before.value + (after.value - before.value) * (startDuration / totalDuration),
markedDate,
);
}
12 changes: 12 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,18 @@
"@date": {
"description": "The date of a workout log or body weight entry"
},
"creationDate": "Start date",
"@creationDate": {
"description": "The Start date of a nutritional plan"
},
"endDate": "End date",
"@endDate": {
"description": "The End date of a nutritional plan"
},
"openEnded": "Open ended",
"@openEnded": {
"description": "When a nutrition plan has no pre-defined end date"
},
"value": "Value",
"@value": {
"description": "The value of a measurement entry"
Expand Down
25 changes: 23 additions & 2 deletions lib/models/nutrition/nutritional_plan.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:logging/logging.dart';
import 'package:wger/helpers/consts.dart';
import 'package:wger/helpers/json.dart';
import 'package:wger/l10n/generated/app_localizations.dart';
Expand All @@ -32,6 +33,8 @@ part 'nutritional_plan.g.dart';

@JsonSerializable(explicitToJson: true)
class NutritionalPlan {
final _logger = Logger('NutritionalPlan Model');

@JsonKey(required: true)
int? id;

Expand All @@ -41,6 +44,12 @@ class NutritionalPlan {
@JsonKey(required: true, name: 'creation_date', toJson: dateToUtcIso8601)
late DateTime creationDate;

@JsonKey(required: true, name: 'start', toJson: dateToYYYYMMDD)
late DateTime startDate;

@JsonKey(required: true, name: 'end', toJson: dateToYYYYMMDD)
late DateTime? endDate;

@JsonKey(required: true, name: 'only_logging')
late bool onlyLogging;

Expand Down Expand Up @@ -68,7 +77,9 @@ class NutritionalPlan {
NutritionalPlan({
this.id,
required this.description,
required this.creationDate,
DateTime? creationDate,
required this.startDate,
this.endDate,
this.onlyLogging = false,
this.goalEnergy,
this.goalProtein,
Expand All @@ -77,13 +88,23 @@ class NutritionalPlan {
this.goalFiber,
List<Meal>? meals,
List<Log>? diaryEntries,
}) {
}) : creationDate = creationDate ?? DateTime.now() {
this.meals = meals ?? [];
this.diaryEntries = diaryEntries ?? [];

if (endDate != null && endDate!.isBefore(startDate)) {
_logger.warning(
'The end date of a nutritional plan is before the start. Setting to null! '
'PlanId: $id, startDate: $startDate, endDate: $endDate',
);
endDate = null;
}
}

NutritionalPlan.empty() {
creationDate = DateTime.now();
startDate = DateTime.now();
endDate = null;
description = '';
onlyLogging = false;
goalEnergy = null;
Expand Down
9 changes: 8 additions & 1 deletion lib/models/nutrition/nutritional_plan.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 13 additions & 6 deletions lib/providers/nutrition.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import 'dart:convert';

import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:wger/core/locator.dart';
Expand Down Expand Up @@ -66,13 +67,19 @@ class NutritionPlansProvider with ChangeNotifier {
ingredients = [];
}

/// Returns the current active nutritional plan. At the moment this is just
/// the latest, but this might change in the future.
/// Returns the current active nutritional plan.
/// A plan is considered active if:
/// - Its start date is before now
/// - Its end date is after now or not set
/// If multiple plans match these criteria, the one with the most recent creation date is returned.
NutritionalPlan? get currentPlan {
if (_plans.isNotEmpty) {
return _plans.first;
}
return null;
final now = DateTime.now();
return _plans
.where((plan) =>
plan.startDate.isBefore(now) && (plan.endDate == null || plan.endDate!.isAfter(now)))
.toList()
.sorted((a, b) => b.creationDate.compareTo(a.creationDate))
.firstOrNull;
}

NutritionalPlan findById(int id) {
Expand Down
7 changes: 6 additions & 1 deletion lib/widgets/measurements/charts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,17 @@ class _MeasurementChartWidgetFlState extends State<MeasurementChartWidgetFl> {
NumberFormat.decimalPattern(Localizations.localeOf(context).toString());

return touchedSpots.map((touchedSpot) {
final msSinceEpoch = touchedSpot.x.toInt();
final DateTime date = DateTime.fromMillisecondsSinceEpoch(touchedSpot.x.toInt());
final dateStr =
DateFormat.Md(Localizations.localeOf(context).languageCode).format(date);

// Check if this is an interpolated point (milliseconds ending with 123)
final bool isInterpolated = msSinceEpoch % 1000 == INTERPOLATION_MARKER;
final String interpolatedMarker = isInterpolated ? ' (interpolated)' : '';

return LineTooltipItem(
'$dateStr: ${numberFormat.format(touchedSpot.y)} ${widget._unit}',
'$dateStr: ${numberFormat.format(touchedSpot.y)} ${widget._unit}$interpolatedMarker',
TextStyle(color: touchedSpot.bar.color),
);
}).toList();
Expand Down
4 changes: 2 additions & 2 deletions lib/widgets/measurements/entries.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class EntriesList extends StatelessWidget {

@override
Widget build(BuildContext context) {
final plan = Provider.of<NutritionPlansProvider>(context, listen: false).currentPlan;
final plans = Provider.of<NutritionPlansProvider>(context, listen: false).items;
final numberFormat = NumberFormat.decimalPattern(Localizations.localeOf(context).toString());
final provider = Provider.of<MeasurementProvider>(context, listen: false);

Expand All @@ -49,7 +49,7 @@ class EntriesList extends StatelessWidget {
_category.name,
entriesAll,
entries7dAvg,
plan,
plans,
_category.unit,
context,
),
Expand Down
Loading