import '../about/external/data/CommitmentEvent.dart'; import '../about/external/data/ExcuseCluster.dart'; import '../about/internal/application/EventType.dart'; /// Clusters excuses over time and turns the pattern into a confrontation: /// "Too tired has appeared 14 times this month, 11 of them on gym days, /// 9 of them after 7pm. Consider moving gym to morning." /// /// The on-device pass is a cheap keyword bucketing so the confrontation works /// offline; the server refines clusters and overwrites [ExcuseCluster.insight]. class ExcuseAnalyser { /// Excuse families the local pass recognises. Order matters — the first /// family whose keyword appears wins. static const Map> families = >{ "Too tired": ["tired", "exhausted", "knackered", "no energy", "sleepy"], "No time": ["no time", "busy", "ran out of time", "swamped"], "Not feeling it": ["not feeling", "no motivation", "cant be", "cannot be"], "Unwell": ["sick", "ill", "headache", "pain", "sore", "injured"], "Interrupted": ["interrupted", "came up", "emergency", "had to"], "Forgot": ["forgot", "slipped my mind", "missed it"], "Weather": ["rain", "cold", "hot", "weather"], "Travel": ["travel", "away", "trip", "commute", "traffic"], }; static const String unclustered = "Other"; /// The family this excuse belongs to. static String classify(String excuse) { final String text = excuse.toLowerCase(); for (MapEntry> family in families.entries) { for (String keyword in family.value) { if (text.contains(keyword)) { return family.key; } } } return unclustered; } /// Build the taxonomy from the event log. Only deferrals and misses carry /// excuses worth clustering. static List cluster(List events) { final Map clusters = {}; final Map> categoryCounts = >{}; for (CommitmentEvent event in events) { if (event.event != EventType.DEFERRED && event.event != EventType.MISSED && event.event != EventType.ABANDONED) { continue; } if (event.excuseText.isEmpty) { continue; } final String label = classify(event.excuseText); final ExcuseCluster cluster = clusters.putIfAbsent(label, () => ExcuseCluster(label: label)); cluster.occurrences = cluster.occurrences + 1; if (event.at != null) { final int weekday = event.at!.weekday; final int hour = event.at!.hour; cluster.byWeekday[weekday] = (cluster.byWeekday[weekday] ?? 0) + 1; cluster.byHour[hour] = (cluster.byHour[hour] ?? 0) + 1; } if (event.excuseClusterId != null && event.excuseClusterId!.isNotEmpty) { final Map counts = categoryCounts.putIfAbsent(label, () => {}); counts[event.excuseClusterId!] = (counts[event.excuseClusterId!] ?? 0) + 1; } } final List result = clusters.values.toList(); for (ExcuseCluster cluster in result) { cluster.dominantCategory = _dominant(categoryCounts[cluster.label]); cluster.insight = describe(cluster); } result.sort((a, b) => b.occurrences.compareTo(a.occurrences)); return result; } /// The confrontation copy. Only the concentrations that are actually /// meaningful get mentioned — a flat distribution says nothing. static String describe(ExcuseCluster cluster) { if (cluster.occurrences < 3) { return ""; } final StringBuffer buffer = StringBuffer(); buffer.write( "'${cluster.label}' has appeared ${cluster.occurrences} times"); final MapEntry? weekday = _peak(cluster.byWeekday); if (weekday != null && weekday.value >= (cluster.occurrences * 0.4)) { buffer.write(", ${weekday.value} of them on ${_weekdayName(weekday.key)}s"); } final MapEntry? hour = _peak(cluster.byHour); if (hour != null && hour.value >= (cluster.occurrences * 0.35)) { buffer.write(", ${hour.value} of them after ${_hourLabel(hour.key)}"); } buffer.write("."); final String suggestion = _suggest(cluster, weekday, hour); if (suggestion.isNotEmpty) { buffer.write(" $suggestion"); } return buffer.toString(); } static String _suggest( ExcuseCluster cluster, MapEntry? weekday, MapEntry? hour, ) { if (hour == null) { return ""; } if (hour.key >= 18 && cluster.label == "Too tired") { return "Consider moving this to the morning."; } if (hour.key >= 18) { return "Evenings are not working for this. Try scheduling it earlier."; } if (weekday != null) { return "${_weekdayName(weekday.key)} is where this keeps failing."; } return ""; } static MapEntry? _peak(Map histogram) { if (histogram.isEmpty) { return null; } MapEntry? peak; for (MapEntry entry in histogram.entries) { if (peak == null || entry.value > peak.value) { peak = entry; } } return peak; } static String _dominant(Map? counts) { if (counts == null || counts.isEmpty) { return ""; } String label = ""; int best = 0; for (MapEntry entry in counts.entries) { if (entry.value > best) { best = entry.value; label = entry.key; } } return label; } static String _weekdayName(int weekday) { const List names = [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", ]; if (weekday < 1 || weekday > 7) { return ""; } return names[weekday - 1]; } static String _hourLabel(int hour) { if (hour == 0) { return "midnight"; } if (hour < 12) { return "${hour}am"; } if (hour == 12) { return "noon"; } return "${hour - 12}pm"; } }