A to-do app that doesn't believe you — an enforcement layer rather than a
neutral ledger.
Architecture ported from Autoreceptives/Frontend/Receptive: stacked MVVM with
the mandatory 4-file screen pattern, one ParentViewModel owning the loading /
network / error overlays and the handleError decision tree, one AppDataManager
gateway, dio comms carrying the three identity headers, secure storage with
random-suffixed keys, and a single-chokepoint Navigator. Package root and Dart
package name are both Grounded; org is nya.
The enforcement engine, one unit per formula in utils/:
- DebtEngine w(class) x severity(d) x decay(t), sublinear severity so old
misses cannot swamp the score; abandonment 2x with 30-day
decay immunity; late complete retains 30%
- StandingEngine Good -> Warned -> Grounded -> Lockdown, derived not set;
Grounded replaces home with the overdue queue
- CapacityEngine blocks over-scheduling against p50 of historically completed
minutes, with a learned per-category estimation multiplier
- IntegrityEngine session integrity, weekly volume, plyometric contact ceiling
and enforced recovery gaps
- ExcuseAnalyser on-device excuse clustering plus the confrontation copy
- GuardrailEngine distress detection and rationed amnesty
- ToneEngine all enforcement copy, so the tone cap lives in one place
CommitmentEvent is append-only and is the source of truth rather than the
status field, which is what makes honest history and excuse analysis possible.
Goals contain commitments via parentId, and a task can be run from a
full-screen runner that derives elapsed time from wall-clock so screen-off
cannot lose time. Backgrounding pauses the clock and is counted. The runner is
mirrored into an ongoing notification, with alarm-class full-screen intents
reserved for non-negotiables.
Design language, fonts, icon and native splash are in place; Mason bricks are
retargeted to this project and verified end-to-end.
flutter analyze lib/ reports no errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mfu2gQLSFN21YRBcU2NrTt
212 lines
6.1 KiB
Dart
212 lines
6.1 KiB
Dart
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<String, List<String>> families = <String, List<String>>{
|
|
"Too tired": <String>["tired", "exhausted", "knackered", "no energy", "sleepy"],
|
|
"No time": <String>["no time", "busy", "ran out of time", "swamped"],
|
|
"Not feeling it": <String>["not feeling", "no motivation", "cant be", "cannot be"],
|
|
"Unwell": <String>["sick", "ill", "headache", "pain", "sore", "injured"],
|
|
"Interrupted": <String>["interrupted", "came up", "emergency", "had to"],
|
|
"Forgot": <String>["forgot", "slipped my mind", "missed it"],
|
|
"Weather": <String>["rain", "cold", "hot", "weather"],
|
|
"Travel": <String>["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<String, List<String>> 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<ExcuseCluster> cluster(List<CommitmentEvent> events) {
|
|
final Map<String, ExcuseCluster> clusters = <String, ExcuseCluster>{};
|
|
final Map<String, Map<String, int>> categoryCounts =
|
|
<String, Map<String, int>>{};
|
|
|
|
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<String, int> counts =
|
|
categoryCounts.putIfAbsent(label, () => <String, int>{});
|
|
counts[event.excuseClusterId!] =
|
|
(counts[event.excuseClusterId!] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
final List<ExcuseCluster> 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<int, int>? 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<int, int>? 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<int, int>? weekday,
|
|
MapEntry<int, int>? 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<int, int>? _peak(Map<int, int> histogram) {
|
|
if (histogram.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
MapEntry<int, int>? peak;
|
|
for (MapEntry<int, int> entry in histogram.entries) {
|
|
if (peak == null || entry.value > peak.value) {
|
|
peak = entry;
|
|
}
|
|
}
|
|
return peak;
|
|
}
|
|
|
|
static String _dominant(Map<String, int>? counts) {
|
|
if (counts == null || counts.isEmpty) {
|
|
return "";
|
|
}
|
|
|
|
String label = "";
|
|
int best = 0;
|
|
|
|
for (MapEntry<String, int> entry in counts.entries) {
|
|
if (entry.value > best) {
|
|
best = entry.value;
|
|
label = entry.key;
|
|
}
|
|
}
|
|
|
|
return label;
|
|
}
|
|
|
|
static String _weekdayName(int weekday) {
|
|
const List<String> names = <String>[
|
|
"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";
|
|
}
|
|
}
|