import '../about/external/data/Commitment.dart'; import '../about/external/data/RoutineChain.dart'; import '../about/internal/application/CoachAction.dart'; import '../about/internal/application/CoachActionKind.dart'; import '../about/internal/application/CoachChart.dart'; import '../about/internal/application/CoachContext.dart'; import '../about/internal/application/CoachMessage.dart'; import '../about/internal/application/CommitmentStatus.dart'; import '../about/internal/application/RoutineRunRecord.dart'; import 'CommonUtils.dart'; import 'ProgressEngine.dart'; /// The coach: answers a fixed set of questions from the user's own data. /// No model, no guessing — every number in an answer is computed from the /// run log, the routines, the plan and the overdue queue in [CoachContext]. /// A question it cannot place gets the list of what it can answer. class CoachEngine { static const String askKeeping = "Am I keeping my routines?"; static const String askSlip = "Where do I slip?"; static const String askStreak = "How's my streak?"; static const String askToday = "What's left today?"; static const String askActive = "Am I hitting my active time?"; static const String askDebt = "Why is my debt where it is?"; static const List suggestions = [ askKeeping, askSlip, askToday, askStreak, askActive, askDebt, ]; static const List _letters = [ "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" ]; static CoachMessage answer(String question, CoachContext data, DateTime now) { final String q = question.toLowerCase(); if (_has(q, ["debt", "overdue", "owe", "standing", "late"])) { return _debt(data, now); } if (_has(q, ["streak", "in a row"])) { return _streak(data, now); } if (_has(q, [ "slip", "miss", "break", "fail", "worst", "weak", "struggl" ])) { return _slip(data, now); } if (_has(q, ["today", "left", "next", "now", "should i"])) { return _today(data, now); } if (_has(q, ["time", "minute", "active", "hour", "long"])) { return _active(data, now); } if (_has(q, [ "keep", "routine", "consistent", "held", "track", "enough", "doing", "habit" ])) { return _keeping(data, now); } return _unknown(); } static bool _has(String q, List words) => words.any((word) => q.contains(word)); // ── Shared maths ────────────────────────────────────────────────────────── /// The last [count] days, oldest first, ending today. static List _days(DateTime now, int count) { final DateTime today = ProgressEngine.dayOf(now); return List.generate( count, (index) => today.subtract(Duration(days: count - 1 - index))); } static List _on( List runs, DateTime day) => ProgressEngine.between(runs, day, day.add(const Duration(days: 1))); /// A day's target: the saved weekly target spread evenly, or, until one is /// saved, what the schedule asks for that day. static double _targetOn(CoachContext data, DateTime day) { if (data.targets.set) { return data.targets.routinesPerWeek / 7; } return data.routines .where((routine) => routine.scheduledOn(day)) .length .toDouble(); } static String _label(DateTime day, DateTime now) => ProgressEngine.dayOf(day) == ProgressEngine.dayOf(now) ? "Today" : _letters[day.weekday - 1]; static String _times(int count) => count == 1 ? "once" : "$count times"; /// Held rate per routine over the last [days], counted against the days it /// was scheduled. Routines with no schedule in the window are skipped. static List<({RoutineChain routine, int held, int due})> _rates( CoachContext data, DateTime now, int days) { final List window = _days(now, days + 1).take(days).toList(); final List<({RoutineChain routine, int held, int due})> result = []; for (RoutineChain routine in data.routines) { int due = 0; int held = 0; for (DateTime day in window) { if (!routine.scheduledOn(day)) { continue; } due = due + 1; if (_on(data.runs, day) .any((run) => run.routineId == routine.id && run.held)) { held = held + 1; } } if (due > 0) { result.add((routine: routine, held: held, due: due)); } } result.sort((a, b) => (a.held / a.due).compareTo(b.held / b.due)); return result; } static RoutineChain? _dueNow(CoachContext data, DateTime now) { final List due = data.routines .where((routine) => routine.scheduledOn(now) && !routine.complete && routine.steps.isNotEmpty) .toList() ..sort((a, b) => a.startMinute.compareTo(b.startMinute)); return due.isEmpty ? null : due.first; } static CoachAction _start(RoutineChain routine, {bool primary = true}) => CoachAction( label: "Start ${routine.title.toLowerCase()}", kind: CoachActionKind.StartRoutine, routineId: routine.id ?? "", primary: primary, ); static CoachAction _ask(String question) => CoachAction( label: question, kind: CoachActionKind.Ask, question: question); // ── Answers ─────────────────────────────────────────────────────────────── static CoachMessage _keeping(CoachContext data, DateTime now) { final List days = _days(now, 7); final List held = days .map((day) => ProgressEngine.held(_on(data.runs, day)).toDouble()) .toList(); final List targets = days.map((day) => _targetOn(data, day)).toList(); int missed = 0; int best = -1; double bestGap = 0; for (int index = 0; index < 6; index++) { if (targets[index] > 0 && held[index] < targets[index]) { missed = missed + 1; } final double gap = held[index] - targets[index]; if (gap > bestGap) { bestGap = gap; best = index; } } final List<({RoutineChain routine, int held, int due})> rates = _rates(data, now, 14); final weakest = rates.isEmpty ? null : rates.first; String body; if (missed == 0) { body = "Yes. You met your target on **every one of the last 6 days**."; } else { body = "Not quite. You missed your target on **$missed of the last 6 days**"; body = weakest == null ? "$body." : "$body, mostly because **${weakest.routine.title}** held just " "${weakest.held} of ${weakest.due} times in two weeks."; } final RoutineChain? due = _dueNow(data, now); final double todayGap = targets[6] - held[6]; String followUp; if (todayGap > 0) { followUp = "Today needs **${todayGap.ceil()} more** to hit target."; if (due != null) { final int minuteNow = now.hour * 60 + now.minute; followUp = due.startMinute < 0 ? "$followUp ${due.title} is still to do." : due.startMinute < minuteNow ? "$followUp ${due.title} was due at ${formatStartMinute(due.startMinute)} and is still open." : "$followUp ${due.title} is next, at ${formatStartMinute(due.startMinute)}."; } } else { followUp = "Today's target is already met."; } return CoachMessage( mine: false, body: body, chart: CoachChart( title: data.targets.set ? "Held per day · target ${data.targets.routinesPerWeek} a week" : "Held per day · target is your schedule", labels: days.map((day) => _label(day, now)).toList(), values: held, targets: targets, highlight: best, callout: best < 0 ? "" : "+${bestGap.round()}", lastComplete: 5, ), followUp: followUp, actions: [ if (due != null && todayGap > 0) _start(due), if (!data.targets.set) CoachAction( label: "Set a target", kind: CoachActionKind.OpenTargets, primary: due == null || todayGap <= 0, ), _ask(askSlip), ], ); } static CoachMessage _slip(CoachContext data, DateTime now) { final List<({RoutineChain routine, int held, int due})> rates = _rates(data, now, 28); if (rates.isEmpty) { return CoachMessage( mine: false, body: "Nothing to go on yet: **no scheduled routines** in the last four weeks.", followUp: "Give a routine some days of the week and this fills in.", ); } final weakest = rates.first; final RoutineChain routine = weakest.routine; // Held and scheduled per weekday over the four weeks. final List heldBy = List.filled(7, 0); final List dueBy = List.filled(7, 0); for (DateTime day in _days(now, 29).take(28)) { if (!routine.scheduledOn(day)) { continue; } dueBy[day.weekday - 1] = dueBy[day.weekday - 1] + 1; if (_on(data.runs, day) .any((run) => run.routineId == routine.id && run.held)) { heldBy[day.weekday - 1] = heldBy[day.weekday - 1] + 1; } } int weakDay = -1; double weakRate = 2; for (int index = 0; index < 7; index++) { if (dueBy[index] == 0) { continue; } final double rate = heldBy[index] / dueBy[index]; if (rate < weakRate) { weakRate = rate; weakDay = index; } } final int percent = (weakest.held / weakest.due * 100).round(); final RoutineChain? due = routine.scheduledOn(now) && !routine.complete && routine.steps.isNotEmpty ? routine : null; return CoachMessage( mine: false, body: "**${routine.title}** is where it slips: held **${weakest.held} of " "${weakest.due}** scheduled days in four weeks ($percent%).", chart: CoachChart( title: "${routine.title} · held by weekday, last 4 weeks", labels: _letters, values: heldBy, targets: dueBy, highlight: weakDay, callout: weakDay < 0 ? "" : "${heldBy[weakDay].round()}/${dueBy[weakDay].round()}", ), followUp: weakDay < 0 ? "" : "**${weekdayName(weakDay + 1)}** is the weak day. " "${rates.length > 1 ? 'Your steadiest is ${rates.last.routine.title}, at ${(rates.last.held / rates.last.due * 100).round()}%.' : ''}", actions: [ if (due != null) _start(due), _ask(askKeeping), ], ); } static CoachMessage _streak(CoachContext data, DateTime now) { final int streak = ProgressEngine.streak(data.runs, now); // Longest run of held days in the log. final List heldDays = data.runs .where((run) => run.held) .map((run) => ProgressEngine.dayOf(run.startedAt)) .toSet() .toList() ..sort(); int longest = 0; int current = 0; DateTime? previous; for (DateTime day in heldDays) { current = previous != null && day.difference(previous).inDays == 1 ? current + 1 : 1; if (current > longest) { longest = current; } previous = day; } final bool heldToday = ProgressEngine.held(_on(data.runs, now)) > 0; final RoutineChain? due = _dueNow(data, now); final List days = _days(now, 7); return CoachMessage( mine: false, body: streak == 0 ? "No streak right now. Your longest was **$longest days**." : "**$streak ${streak == 1 ? 'day' : 'days'}** in a row with a routine held. " "Your longest is **$longest days**.", chart: CoachChart( title: "Routines held per day · a streak needs one", labels: days.map((day) => _label(day, now)).toList(), values: days .map((day) => ProgressEngine.held(_on(data.runs, day)).toDouble()) .toList(), targets: List.filled(7, 1), lastComplete: heldToday ? 6 : 5, ), followUp: heldToday ? "Today already counts." : "Today doesn't count yet. **One held routine** keeps it going.", actions: [ if (!heldToday && due != null) _start(due), CoachAction( label: "Open progress", kind: CoachActionKind.OpenProgress, primary: heldToday || due == null), ], ); } static CoachMessage _today(CoachContext data, DateTime now) { final List left = data.routines .where((routine) => routine.scheduledOn(now) && !routine.complete) .toList() ..sort((a, b) => a.startMinute.compareTo(b.startMinute)); final int open = data.plan.where((item) => item.status == CommitmentStatus.Open).length; final RoutineChain? due = _dueNow(data, now); final List parts = [ "**${left.length} ${left.length == 1 ? 'routine' : 'routines'}**", "**$open ${open == 1 ? 'commitment' : 'commitments'}**", ]; String body = left.isEmpty && open == 0 ? "Nothing left today. **Every routine and commitment is done.**" : "Still to do today: ${parts.join(' and ')}."; if (data.overdue.isNotEmpty) { body = "$body Plus **${data.overdue.length} overdue** from before."; } return CoachMessage( mine: false, body: body, followUp: left.isEmpty ? "" : left .map((routine) => "${routine.startMinute >= 0 ? formatStartMinute(routine.startMinute) : 'Any time'} · ${routine.title}") .join("\n"), actions: [ if (due != null) _start(due), if (data.overdue.isNotEmpty) CoachAction( label: "Clear overdue", kind: CoachActionKind.OpenOverdue, primary: due == null), ], ); } static CoachMessage _active(CoachContext data, DateTime now) { final List days = _days(now, 7); final double daily = data.targets.minutesPerWeek / 7; final List minutes = days .map((day) => ProgressEngine.minutes(_on(data.runs, day)).toDouble()) .toList(); final int week = ProgressEngine.minutes(ProgressEngine.between( data.runs, days.first, days.last.add(const Duration(days: 1)))); int under = 0; for (int index = 0; index < 6; index++) { if (minutes[index] < daily) { under = under + 1; } } int best = -1; double bestGap = 0; for (int index = 0; index < 7; index++) { if (minutes[index] - daily > bestGap) { bestGap = minutes[index] - daily; best = index; } } return CoachMessage( mine: false, body: week >= data.targets.minutesPerWeek ? "Yes: **${formatMinutes(week)}** in the last 7 days, over your " "${formatMinutes(data.targets.minutesPerWeek)} target." : "**${formatMinutes(week)}** in the last 7 days, short of " "${formatMinutes(data.targets.minutesPerWeek)}. You were under on " "**$under of the last 6 days**.", chart: CoachChart( title: "Minutes per day · target ${formatMinutes(daily.round())}", labels: days.map((day) => _label(day, now)).toList(), values: minutes, targets: List.filled(7, daily), highlight: best, callout: best < 0 ? "" : "+${bestGap.round()}m", lastComplete: 5, ), followUp: "Partial runs count toward time even when the chain broke.", actions: [ CoachAction( label: "Change target", kind: CoachActionKind.OpenTargets, primary: true), _ask(askKeeping), ], ); } static CoachMessage _debt(CoachContext data, DateTime now) { if (data.overdue.isEmpty) { return CoachMessage( mine: false, body: "Debt is **${formatDebt(data.debtScore)}** and **nothing is overdue**. " "What's left is decaying on its own.", actions: [_ask(askToday)], ); } final List sorted = List.from(data.overdue) ..sort((a, b) => b.deferralCount.compareTo(a.deferralCount)); final Commitment worst = sorted.first; return CoachMessage( mine: false, body: "Debt is **${formatDebt(data.debtScore)}**, from **${data.overdue.length} " "overdue** ${data.overdue.length == 1 ? 'item' : 'items'}.", followUp: worst.deferralCount > 0 ? "**${worst.title}** carries the most: deferred ${_times(worst.deferralCount)} " "and ${worst.daysOverdue} ${worst.daysOverdue == 1 ? 'day' : 'days'} past its window." : "**${worst.title}** is ${worst.daysOverdue} ${worst.daysOverdue == 1 ? 'day' : 'days'} past its window.", actions: [ CoachAction( label: "Clear overdue", kind: CoachActionKind.OpenOverdue, primary: true), _ask(askToday), ], ); } static CoachMessage _unknown() { return CoachMessage( mine: false, body: "I only answer from **what's logged here**: routines, streaks, " "active time, today's plan and debt. Try one of these.", actions: suggestions.take(4).map(_ask).toList(), ); } }