Add Progress over time and a targets and reminders setup

Progress gives a Day/Week/Month view of routines held against a weekly
target, compared with the same point of the previous period, with active
time and the daily streak. Targets & reminders sets routines and minutes
per week and schedules a heads-up before each routine. The runner's summary
now shows today's held count and streak. Finished runs are mirrored to a
device run log that feeds both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhQh614DMeYoQEH9SoMRUw
This commit is contained in:
alvocool
2026-11-08 20:17:58 +03:00
parent 4b71257429
commit add94f621c
26 changed files with 1590 additions and 4 deletions

View File

@@ -0,0 +1,163 @@
import '../about/external/data/RoutineChain.dart';
import '../about/internal/application/ProgressPeriod.dart';
import '../about/internal/application/ProgressTargets.dart';
import '../about/internal/application/RoutineRunRecord.dart';
/// Progress over time, derived from the device's run log. Pure functions:
/// the screen asks, this answers, nothing is stored.
class ProgressEngine {
static DateTime dayOf(DateTime value) =>
DateTime(value.year, value.month, value.day);
/// Weeks start on Monday, matching routine `days` (1 = Monday).
static DateTime weekStart(DateTime value) =>
dayOf(value).subtract(Duration(days: value.weekday - 1));
static DateTime monthStart(DateTime value) =>
DateTime(value.year, value.month);
/// The start of the period containing [now], and of the one before it.
static DateTime periodStart(ProgressPeriod period, DateTime now) {
switch (period) {
case ProgressPeriod.Day:
return dayOf(now);
case ProgressPeriod.Week:
return weekStart(now);
case ProgressPeriod.Month:
return monthStart(now);
}
}
static DateTime periodEnd(ProgressPeriod period, DateTime start) {
switch (period) {
case ProgressPeriod.Day:
return start.add(const Duration(days: 1));
case ProgressPeriod.Week:
return start.add(const Duration(days: 7));
case ProgressPeriod.Month:
return DateTime(start.year, start.month + 1);
}
}
static DateTime previousStart(ProgressPeriod period, DateTime start) {
switch (period) {
case ProgressPeriod.Day:
return start.subtract(const Duration(days: 1));
case ProgressPeriod.Week:
return start.subtract(const Duration(days: 7));
case ProgressPeriod.Month:
return DateTime(start.year, start.month - 1);
}
}
/// Routine slots the schedule asks for each week.
static int scheduledPerWeek(List<RoutineChain> routines) {
int count = 0;
for (RoutineChain routine in routines) {
count = count + routine.days.length;
}
return count;
}
static List<RoutineRunRecord> between(
List<RoutineRunRecord> records, DateTime start, DateTime end) {
return records
.where((record) =>
!record.startedAt.isBefore(start) && record.startedAt.isBefore(end))
.toList();
}
static int held(List<RoutineRunRecord> records) =>
records.where((record) => record.held).length;
static int minutes(List<RoutineRunRecord> records) {
int seconds = 0;
for (RoutineRunRecord record in records) {
seconds = seconds + record.seconds;
}
return (seconds / 60).round();
}
/// The weekly targets scaled to the period: a day is a seventh of a week,
/// a month is its days over seven.
static int routineTarget(
ProgressTargets targets, ProgressPeriod period, DateTime start) {
return _scale(targets.routinesPerWeek, period, start);
}
static int minuteTarget(
ProgressTargets targets, ProgressPeriod period, DateTime start) {
return _scale(targets.minutesPerWeek, period, start);
}
static int _scale(int weekly, ProgressPeriod period, DateTime start) {
switch (period) {
case ProgressPeriod.Day:
return (weekly / 7).ceil();
case ProgressPeriod.Week:
return weekly;
case ProgressPeriod.Month:
final int days = periodEnd(period, start).difference(start).inDays;
return (weekly * days / 7).round();
}
}
/// Held routines per bucket: days of the week, or weeks of the month.
/// A day has no buckets; it lists its runs instead.
static List<int> buckets(
List<RoutineRunRecord> records, ProgressPeriod period, DateTime start) {
switch (period) {
case ProgressPeriod.Day:
return <int>[];
case ProgressPeriod.Week:
return List<int>.generate(7, (index) {
final DateTime day = start.add(Duration(days: index));
return held(between(records, day, day.add(const Duration(days: 1))));
});
case ProgressPeriod.Month:
final DateTime end = periodEnd(period, start);
final List<int> result = <int>[];
DateTime week = weekStart(start);
while (week.isBefore(end)) {
final DateTime from = week.isBefore(start) ? start : week;
final DateTime next = week.add(const Duration(days: 7));
result.add(held(between(records, from, next.isAfter(end) ? end : next)));
week = next;
}
return result;
}
}
/// Which bucket [now] falls in, so the chart can highlight it.
static int currentBucket(ProgressPeriod period, DateTime start, DateTime now) {
switch (period) {
case ProgressPeriod.Day:
return 0;
case ProgressPeriod.Week:
return now.weekday - 1;
case ProgressPeriod.Month:
return dayOf(now).difference(weekStart(start)).inDays ~/ 7;
}
}
/// Consecutive days with at least one held routine, ending today — or
/// yesterday, so a streak is not shown as broken before the day is out.
static int streak(List<RoutineRunRecord> records, DateTime now) {
final Set<DateTime> days = records
.where((record) => record.held)
.map((record) => dayOf(record.startedAt))
.toSet();
DateTime cursor = dayOf(now);
if (!days.contains(cursor)) {
cursor = cursor.subtract(const Duration(days: 1));
}
int count = 0;
while (days.contains(cursor)) {
count = count + 1;
cursor = cursor.subtract(const Duration(days: 1));
}
return count;
}
}