From ec115096566d81e6be3ba9163f430ba64f420fde Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:13:19 +0545 Subject: [PATCH 1/2] refactor(models): update workout session time tracking to DateTime - Convert timeStart and timeEnd from TimeOfDay to DateTime for better precision and easier duration calculation. - Includes overnight session detection logic - Limits active session fetching to a new sessionMaxDuration constant (5 hours). - Relates to: wger-project/wger#2379 --- lib/helpers/routines/validators.dart | 4 +- lib/models/workouts/session.dart | 43 ++++++++++++------- lib/providers/gym_state.dart | 8 ++-- lib/widgets/dashboard/calendar.dart | 3 +- lib/widgets/routines/forms/session.dart | 33 ++++++++++++-- .../routines/gym_mode/session_page.dart | 9 +++- 6 files changed, 71 insertions(+), 29 deletions(-) diff --git a/lib/helpers/routines/validators.dart b/lib/helpers/routines/validators.dart index 5e79191f2..9867c1818 100644 --- a/lib/helpers/routines/validators.dart +++ b/lib/helpers/routines/validators.dart @@ -39,8 +39,8 @@ String? validateWorkoutLogCrossField({ /// - [timeStart] and [timeEnd] must both be set or both be empty, /// - if both are set, [timeStart] must not be after [timeEnd]. String? validateWorkoutSessionTimes({ - required TimeOfDay? timeStart, - required TimeOfDay? timeEnd, + required DateTime? timeStart, + required DateTime? timeEnd, required AppLocalizations i18n, }) { if ((timeStart == null) != (timeEnd == null)) { diff --git a/lib/models/workouts/session.dart b/lib/models/workouts/session.dart index 864f596f6..2f7e266b0 100644 --- a/lib/models/workouts/session.dart +++ b/lib/models/workouts/session.dart @@ -54,6 +54,8 @@ extension WorkoutImpressionL10n on WorkoutImpression { } } +const sessionMaxDuration = Duration(hours: 5); + class WorkoutSession { /// Inclusive upper bound for [notes] static const maxNotesChars = 1000; @@ -65,10 +67,10 @@ class WorkoutSession { DateTime date; WorkoutImpression impression; late String? notes; - late TimeOfDay? timeStart; - late TimeOfDay? timeEnd; + DateTime? timeStart; + DateTime? timeEnd; - List logs = []; + List logs; WorkoutSession({ this.id, @@ -87,28 +89,34 @@ class WorkoutSession { id: id != null ? drift.Value(id!) : const drift.Value.absent(), routineId: routineId != null ? drift.Value(routineId) : const drift.Value.absent(), dayId: dayId != null ? drift.Value(dayId) : const drift.Value.absent(), - // Server-side `date` is a `DateField` (no time, no TZ). We send here the - // calendar day the user picked, packaged as midnight-UTC so it round-trips + // Server-side `date` is a `DateTimeField`. It round-trips // through PowerSync's ISO8601 wire format and lands on the right day on // the server. date: drift.Value(DateTime.utc(date.year, date.month, date.day)), notes: drift.Value(notes), impression: drift.Value(impression), - timeStart: timeStart != null ? drift.Value(timeStart) : const drift.Value.absent(), - timeEnd: timeEnd != null ? drift.Value(timeEnd) : const drift.Value.absent(), + timeStart: timeStart != null + ? drift.Value(TimeOfDay.fromDateTime(timeStart!)) + : const drift.Value.absent(), + timeEnd: timeEnd != null + ? drift.Value(TimeOfDay.fromDateTime(timeEnd!)) + : const drift.Value.absent(), ); } + /// Calculates the duration between [timeStart] and [timeEnd]. + /// Returns null if either is missing. Duration? get duration { - if (timeStart == null || timeEnd == null) { + final start = timeStart; + final end = timeEnd; + if (start == null || end == null) { return null; } - final now = DateTime.now(); - final startDate = DateTime(now.year, now.month, now.day, timeStart!.hour, timeStart!.minute); - final endDate = DateTime(now.year, now.month, now.day, timeEnd!.hour, timeEnd!.minute); - return endDate.difference(startDate); + + return end.difference(start); } + /// Returns a localized string representation of the duration (e.g., "2h 30m"). String durationTxt(BuildContext context) { final duration = this.duration; if (duration == null) { @@ -119,14 +127,17 @@ class WorkoutSession { ).durationHoursMinutes(duration.inHours, duration.inMinutes.remainder(60)); } + /// Returns a formatted string: "2h 30m (09:00 AM - 11:30 AM)". String durationTxtWithStartEnd(BuildContext context) { - final duration = this.duration; - if (duration == null) { + final start = timeStart; + final end = timeEnd; + if (end == null || start == null) { return '-/-'; } - final startTime = MaterialLocalizations.of(context).formatTimeOfDay(timeStart!); - final endTime = MaterialLocalizations.of(context).formatTimeOfDay(timeEnd!); + final localizations = MaterialLocalizations.of(context); + final startTime = localizations.formatTimeOfDay(TimeOfDay.fromDateTime(start)); + final endTime = localizations.formatTimeOfDay(TimeOfDay.fromDateTime(end)); return '${durationTxt(context)} ($startTime - $endTime)'; } diff --git a/lib/providers/gym_state.dart b/lib/providers/gym_state.dart index 44644b3e2..1a5e9388e 100644 --- a/lib/providers/gym_state.dart +++ b/lib/providers/gym_state.dart @@ -168,7 +168,7 @@ class GymModeState { final List pages; final int currentPage; - final TimeOfDay startTime; + final DateTime startTime; final DateTime validUntil; // User settings @@ -199,9 +199,9 @@ class GymModeState { Routine? routine, DateTime? validUntil, - TimeOfDay? startTime, + DateTime? startTime, }) : validUntil = validUntil ?? clock.now().add(DEFAULT_DURATION), - startTime = startTime ?? TimeOfDay.fromDateTime(clock.now()) { + startTime = startTime ?? clock.now() { if (dayId != null) { this.dayId = dayId; } @@ -225,7 +225,7 @@ class GymModeState { int? dayId, int? iteration, DateTime? validUntil, - TimeOfDay? startTime, + DateTime? startTime, Routine? routine, // User settings diff --git a/lib/widgets/dashboard/calendar.dart b/lib/widgets/dashboard/calendar.dart index b60927ac2..9f0e3c534 100644 --- a/lib/widgets/dashboard/calendar.dart +++ b/lib/widgets/dashboard/calendar.dart @@ -111,7 +111,8 @@ class _DashboardCalendarWidgetState extends riverpod.ConsumerState []); var time = ''; if (session.timeStart != null && session.timeEnd != null) { - time = '(${timeToString(session.timeStart)} - ${timeToString(session.timeEnd)})'; + time = + '(${timeToString(TimeOfDay.fromDateTime(session.timeStart!))} - ${timeToString(TimeOfDay.fromDateTime(session.timeEnd!))})'; } events[date]!.add( Event( diff --git a/lib/widgets/routines/forms/session.dart b/lib/widgets/routines/forms/session.dart index d41dc0f7d..bba147c73 100644 --- a/lib/widgets/routines/forms/session.dart +++ b/lib/widgets/routines/forms/session.dart @@ -120,19 +120,44 @@ class _SessionFormState extends ConsumerState { Flexible( child: TimeInputWidget( key: const ValueKey('time-start'), - value: widget._session.timeStart, + value: widget._session.timeStart != null + ? TimeOfDay.fromDateTime(widget._session.timeStart!) + : null, labelText: AppLocalizations.of(context).timeStart, onCleared: () => widget._session.timeStart = null, - onChanged: (time) => widget._session.timeStart = time, + + onChanged: (time) { + final now = clock.now(); + widget._session.timeStart = (widget._session.timeStart ?? now).copyWith( + hour: time.hour, + minute: time.minute, + ); + }, ), ), Flexible( child: TimeInputWidget( key: const ValueKey('time-end'), - value: widget._session.timeEnd, + value: widget._session.timeEnd != null + ? TimeOfDay.fromDateTime(widget._session.timeEnd!) + : null, labelText: AppLocalizations.of(context).timeEnd, onCleared: () => widget._session.timeEnd = null, - onChanged: (time) => widget._session.timeEnd = time, + + onChanged: (time) { + final now = clock.now(); + final start = widget._session.timeStart ?? now; + final end = (widget._session.timeEnd ?? now).copyWith( + hour: time.hour, + minute: time.minute, + ); + + if (end.isBefore(start)) { + widget._session.timeEnd = end.add(const Duration(days: 1)); + } else { + widget._session.timeEnd = end; + } + }, ), ), ], diff --git a/lib/widgets/routines/gym_mode/session_page.dart b/lib/widgets/routines/gym_mode/session_page.dart index 6180cbbe7..d58d33408 100644 --- a/lib/widgets/routines/gym_mode/session_page.dart +++ b/lib/widgets/routines/gym_mode/session_page.dart @@ -52,14 +52,19 @@ class _SessionPageState extends ConsumerState { value: ref.watch(workoutSessionProvider), loggerName: 'SessionPage', data: (sessions) { + final nowDt = clock.now(); + final start = gymState.startTime; final session = sessions.firstWhere( - (s) => s.date.isSameDayAs(clock.now()) && s.routineId == gymState.routine.id, + (s) => + s.timeEnd == null && + s.routineId == gymState.routine.id && + nowDt.difference(s.timeStart!) <= sessionMaxDuration, orElse: () => WorkoutSession( dayId: gymState.dayId, date: clock.now(), routineId: gymState.routine.id, timeStart: gymState.startTime, - timeEnd: TimeOfDay.fromDateTime(clock.now()), + timeEnd: nowDt, ), ); From 13218a2dd6eb5c2b2b5ef7ccfce60589d6951602 Mon Sep 17 00:00:00 2001 From: pankaj-basnet <165250380+pankaj-basnet@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:58:09 +0545 Subject: [PATCH 2/2] feat(database): upgrade session time tracking to full DateTime Refactors WorkoutSession storage from TimeOfDay to DateTime to enable accurate cross-midnight session tracking. - Adds DateTimeTextConverter to handle ISO8601 serialization/deserialization. - Updates WorkoutSessionTable to use the new DateTime converter. - Refactors WorkoutLogRepository to use UTC ISO8601 strings for time range queries in Drift. - Improves lazy session creation logic to handle closed vs. open session states. --- .../converters/date_only_text_converter.dart | 19 +++++++++++++ lib/database/powersync/tables/routines.dart | 5 ++-- lib/models/workouts/session.dart | 8 ++---- lib/providers/workout_logs_repository.dart | 27 ++++++++++++++++--- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/lib/database/converters/date_only_text_converter.dart b/lib/database/converters/date_only_text_converter.dart index 7507c216b..62cdbe910 100644 --- a/lib/database/converters/date_only_text_converter.dart +++ b/lib/database/converters/date_only_text_converter.dart @@ -42,3 +42,22 @@ class DateOnlyTextConverter extends TypeConverter { '${value.month.toString().padLeft(2, '0')}-' '${value.day.toString().padLeft(2, '0')}'; } + +class DateTimeTextConverter extends TypeConverter { + const DateTimeTextConverter(); + + @override + DateTime fromSql(String fromDb) { + final day = DateTime.parse(fromDb.substring(0, 19)); + return DateTime.utc(day.year, day.month, day.day, day.hour, day.minute, day.second); + } + + @override + String toSql(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-' + '${value.month.toString().padLeft(2, '0')}-' + '${value.day.toString().padLeft(2, '0')}T' + '${value.hour.toString().padLeft(2, '0')}:' + '${value.minute.toString().padLeft(2, '0')}:' + '${value.second.toString().padLeft(2, '0')}'; +} diff --git a/lib/database/powersync/tables/routines.dart b/lib/database/powersync/tables/routines.dart index 21cbf322e..3438ca354 100644 --- a/lib/database/powersync/tables/routines.dart +++ b/lib/database/powersync/tables/routines.dart @@ -124,8 +124,9 @@ class WorkoutSessionTable extends Table { TextColumn get notes => text().nullable()(); TextColumn get impression => text().map(const WorkoutImpressionConverter())(); TextColumn get timeStart => - text().named('time_start').nullable().map(const TimeOfDayConverter())(); - TextColumn get timeEnd => text().named('time_end').nullable().map(const TimeOfDayConverter())(); + text().named('time_start').nullable().map(const DateTimeTextConverter())(); + TextColumn get timeEnd => + text().named('time_end').nullable().map(const DateTimeTextConverter())(); } const PowersyncWorkoutSessionTable = ps.Table( diff --git a/lib/models/workouts/session.dart b/lib/models/workouts/session.dart index 2f7e266b0..74af5ee8e 100644 --- a/lib/models/workouts/session.dart +++ b/lib/models/workouts/session.dart @@ -95,12 +95,8 @@ class WorkoutSession { date: drift.Value(DateTime.utc(date.year, date.month, date.day)), notes: drift.Value(notes), impression: drift.Value(impression), - timeStart: timeStart != null - ? drift.Value(TimeOfDay.fromDateTime(timeStart!)) - : const drift.Value.absent(), - timeEnd: timeEnd != null - ? drift.Value(TimeOfDay.fromDateTime(timeEnd!)) - : const drift.Value.absent(), + timeStart: timeStart != null ? drift.Value(timeStart) : const drift.Value.absent(), + timeEnd: timeEnd != null ? drift.Value(timeEnd) : const drift.Value.absent(), ); } diff --git a/lib/providers/workout_logs_repository.dart b/lib/providers/workout_logs_repository.dart index a667fb5b0..d81065e0e 100644 --- a/lib/providers/workout_logs_repository.dart +++ b/lib/providers/workout_logs_repository.dart @@ -23,6 +23,7 @@ import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:wger/helpers/json.dart'; import 'package:wger/models/workouts/log.dart'; import 'package:wger/models/workouts/session.dart'; @@ -110,18 +111,22 @@ class WorkoutLogRepository { await _db.transaction(() async { if (log.sessionId == null) { final dayMidnightUtc = DateTime.utc(log.date.year, log.date.month, log.date.day); + final windowStart = log.date.subtract(sessionMaxDuration); final existing = await (_db.select(_db.workoutSessionTable) ..where( - (t) => t.routineId.equals(log.routineId) & t.date.equalsValue(dayMidnightUtc), + (t) => + t.routineId.equals(log.routineId) & + t.timeStart.isBiggerOrEqualValue(dateToUtcIso8601(windowStart)) & + t.timeStart.isSmallerOrEqualValue(dateToUtcIso8601(log.date)), ) + ..orderBy([(t) => OrderingTerm.desc(t.timeStart)]) ..limit(1)) .getSingleOrNull(); - if (existing != null) { - log.sessionId = existing.id; - } else { + if (existing == null) { + // No session at all -> create new session final newSession = WorkoutSession( routineId: log.routineId, date: dayMidnightUtc, @@ -131,6 +136,20 @@ class WorkoutLogRepository { .insertReturning(newSession.toCompanion()); log.sessionId = inserted.id; _logger.finer('Created lazy session ${inserted.id} for log'); + } else if (existing.timeEnd != null && log.date.isAfter(existing.timeEnd!)) { + // Session found but already closed -> create new session + final newSession = WorkoutSession( + routineId: log.routineId, + date: dayMidnightUtc, + ); + final inserted = await _db + .into(_db.workoutSessionTable) + .insertReturning(newSession.toCompanion()); + log.sessionId = inserted.id; + _logger.finer('Created lazy session ${inserted.id} for log'); + } else { + // Session is open and within window -> reuse it + log.sessionId = existing.id; } }