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
160 lines
5.1 KiB
Dart
160 lines
5.1 KiB
Dart
import 'dart:math';
|
|
|
|
import '../about/external/data/Commitment.dart';
|
|
import '../about/external/data/Habit.dart';
|
|
import '../about/internal/application/CommitmentClass.dart';
|
|
import '../about/internal/application/CommitmentStatus.dart';
|
|
|
|
/// The single number the whole system runs on.
|
|
///
|
|
/// ```
|
|
/// debt = Sum over open/missed commitments:
|
|
/// w(class) x severity(days_overdue) x recency_decay(t)
|
|
///
|
|
/// w(class): non-negotiable 5.0 | standard 2.0 | elective 0.0
|
|
/// severity(d): 1 + log2(1 + d)
|
|
/// recency_decay: 0.5 ^ (days_since / half_life)
|
|
/// abandonment: one-time +2x w(class), no decay for 30 days
|
|
/// late_complete: debt reduced to 30% of accrued, not 0
|
|
/// ```
|
|
///
|
|
/// Sublinear severity is the load-bearing choice: with linear growth a single
|
|
/// ancient task swamps the score and the number stops meaning anything.
|
|
class DebtEngine {
|
|
/// Half-life of the recency decay, in days.
|
|
static const double halfLifeDays = 14;
|
|
|
|
/// Multiplier applied once when a commitment is abandoned.
|
|
static const double abandonmentMultiplier = 2.0;
|
|
|
|
/// Days an abandonment resists decay.
|
|
static const int abandonmentProtectionDays = 30;
|
|
|
|
/// What a late completion leaves behind. Never zero — otherwise you learn
|
|
/// that everything is negotiable.
|
|
static const double lateCompleteRetention = 0.30;
|
|
|
|
/// Debt weight per habit shortfall unit.
|
|
static const double habitShortfallWeight = 1.0;
|
|
|
|
/// `severity(d) = 1 + log2(1 + d)`.
|
|
static double severity(int daysOverdue) {
|
|
final int days = daysOverdue < 0 ? 0 : daysOverdue;
|
|
return 1 + (log(1 + days) / ln2);
|
|
}
|
|
|
|
/// `recency_decay(t) = 0.5 ^ (days_since / half_life)`.
|
|
static double recencyDecay(int daysSince) {
|
|
final int days = daysSince < 0 ? 0 : daysSince;
|
|
return pow(0.5, days / halfLifeDays).toDouble();
|
|
}
|
|
|
|
/// Debt contributed by one commitment, as of [now].
|
|
static double commitmentDebt(Commitment commitment, {DateTime? now}) {
|
|
final DateTime moment = now ?? DateTime.now();
|
|
|
|
final double weight = classWeight(commitment.commitmentClass);
|
|
|
|
// Electives never accrue debt, whatever happens to them.
|
|
if (weight == 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Cleanly completed and archived items are settled.
|
|
if (commitment.status == CommitmentStatus.Completed ||
|
|
commitment.status == CommitmentStatus.Archived) {
|
|
return 0;
|
|
}
|
|
|
|
if (commitment.dueEnd == null) {
|
|
return 0;
|
|
}
|
|
|
|
final int daysSince = moment.difference(commitment.dueEnd!).inDays;
|
|
|
|
// The window is still open — nothing is owed yet.
|
|
if (daysSince < 0) {
|
|
return 0;
|
|
}
|
|
|
|
if (commitment.status == CommitmentStatus.Abandoned) {
|
|
// Abandonment is the most expensive outcome and resists decay for a
|
|
// month, so it cannot be waited out.
|
|
final double base = weight * abandonmentMultiplier * severity(daysSince);
|
|
if (daysSince <= abandonmentProtectionDays) {
|
|
return base;
|
|
}
|
|
return base * recencyDecay(daysSince - abandonmentProtectionDays);
|
|
}
|
|
|
|
final double accrued =
|
|
weight * severity(daysSince) * recencyDecay(daysSince);
|
|
|
|
if (commitment.status == CommitmentStatus.LateCompleted) {
|
|
return accrued * lateCompleteRetention;
|
|
}
|
|
|
|
return accrued;
|
|
}
|
|
|
|
/// Debt contributed by a habit. A single miss costs nothing — only falling
|
|
/// below the frequency target in the rolling window does.
|
|
static double habitDebt(Habit habit) {
|
|
if (!habit.behindTarget) {
|
|
return 0;
|
|
}
|
|
return habit.shortfall * habitShortfallWeight;
|
|
}
|
|
|
|
/// The whole score, as of [now].
|
|
static double totalDebt(
|
|
List<Commitment> commitments, {
|
|
List<Habit> habits = const <Habit>[],
|
|
DateTime? now,
|
|
}) {
|
|
final DateTime moment = now ?? DateTime.now();
|
|
|
|
double total = 0;
|
|
|
|
for (Commitment commitment in commitments) {
|
|
total = total + commitmentDebt(commitment, now: moment);
|
|
}
|
|
|
|
for (Habit habit in habits) {
|
|
total = total + habitDebt(habit);
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
/// Open items past their window — the count that [Thresholds.maxOpenOverdue]
|
|
/// caps.
|
|
static int openOverdueCount(List<Commitment> commitments) {
|
|
return commitments
|
|
.where((commitment) =>
|
|
commitment.status == CommitmentStatus.Overdue ||
|
|
(commitment.status == CommitmentStatus.Open &&
|
|
commitment.windowClosed))
|
|
.length;
|
|
}
|
|
|
|
/// Missed non-negotiables — three of these force Grounded regardless of the
|
|
/// numeric score.
|
|
static int missedNonNegotiables(List<Commitment> commitments) {
|
|
return commitments
|
|
.where((commitment) =>
|
|
commitment.commitmentClass == CommitmentClass.NonNegotiable &&
|
|
(commitment.status == CommitmentStatus.Overdue ||
|
|
commitment.status == CommitmentStatus.Abandoned ||
|
|
(commitment.status == CommitmentStatus.Open &&
|
|
commitment.windowClosed)))
|
|
.length;
|
|
}
|
|
|
|
/// What clearing this item would remove from the score — used to show the
|
|
/// user the actual price of each row in the overdue queue.
|
|
static double reliefFromClearing(Commitment commitment, {DateTime? now}) {
|
|
return commitmentDebt(commitment, now: now);
|
|
}
|
|
}
|