import '../models/tracker_entry.dart'; enum RewardTier { t0, t1, t2, t3, t4 } extension RewardTierX on RewardTier { String get dbValue { switch (this) { case RewardTier.t0: return 'T0'; case RewardTier.t1: return 'T1'; case RewardTier.t2: return 'T2'; case RewardTier.t3: return 'T3'; case RewardTier.t4: return 'T4'; } } int get rank => RewardTier.values.indexOf(this); } class StreakState { final int currentStreak; final int longestStreak; final int doneCountInPhase42; final int doneCountInWindow30; final RewardTier currentTier; final bool neverMissTwiceBroken; const StreakState({ required this.currentStreak, required this.longestStreak, required this.doneCountInPhase42, required this.doneCountInWindow30, required this.currentTier, required this.neverMissTwiceBroken, }); static const StreakState empty = StreakState( currentStreak: 0, longestStreak: 0, doneCountInPhase42: 0, doneCountInWindow30: 0, currentTier: RewardTier.t0, neverMissTwiceBroken: false, ); } /// fn-compute-streak: computes streak + 5-tier milestone with Never miss twice. /// /// OQ-5 decision (2026-06-11): /// - 2+ consecutive blank → tier demoted (T3→T2, T2→T1, T1→T0), streak = 0, /// neverMissTwiceBroken = true. /// - 1 blank → streak = 0, tier stays. Next done starts at 1. /// /// Pure function. [habitStartedAt] is the habit's started_at (YYYY-MM-DD). StreakState computeStreak({ required Iterable entries, required DateTime asOf, required String habitStartedAt, }) { // 1. Index by date string (YYYY-MM-DD). final byDate = {}; for (final e in entries) { byDate[e.date] = e; } if (byDate.isEmpty) return StreakState.empty; final startDate = DateTime.parse(habitStartedAt); final asOfDate = DateTime(asOf.year, asOf.month, asOf.day); // 2. currentStreak (Never miss twice). // // Semantics: // - Walk back from asOf, stopping at habit start. // - "No entry record" for a date means tracking hasn't reached that day (or // user hasn't synced) — treat as the end of streak history, do not penalize. // - Explicit TrackerValue.blank is the penalty signal. 1 blank zeroes the // streak (tier stays); 2 consecutive blanks set neverMissTwiceBroken. var streak = 0; var consecutiveBlank = 0; var neverMissTwiceBroken = false; var cursor = asOfDate; while (!cursor.isBefore(startDate)) { final e = byDate[_ymd(cursor)]; if (e == null) { // End of recorded history. break; } if (e.value == TrackerValue.blank) { consecutiveBlank += 1; if (consecutiveBlank >= 2) { neverMissTwiceBroken = true; streak = 0; break; } // 1 blank so far: streak is broken, but keep walking one more day to // detect a possible double-blank. streak = 0; cursor = cursor.subtract(const Duration(days: 1)); continue; } // Done. if (consecutiveBlank > 0) { // Previous step saw a single blank, now done → single-blank confirmed. break; } streak += 1; cursor = cursor.subtract(const Duration(days: 1)); } // 3. longestStreak over all entries. final sortedDates = byDate.keys.toList()..sort(); var longest = 0; var run = 0; for (final d in sortedDates) { if (byDate[d]!.value == TrackerValue.done) { run += 1; if (run > longest) longest = run; } else { run = 0; } } // 4. Window counts. final win30Start = asOfDate.subtract(const Duration(days: 29)); final win42Start = asOfDate.subtract(const Duration(days: 41)); var window30 = 0; var window42 = 0; for (final e in byDate.values) { if (e.value != TrackerValue.done) continue; final d = DateTime.parse(e.date); if (!d.isBefore(win30Start) && !d.isAfter(asOfDate)) window30 += 1; if (!d.isBefore(win42Start) && !d.isAfter(asOfDate)) window42 += 1; } // 5. Tier judgment. var tier = RewardTier.t0; if (streak >= 3) tier = RewardTier.t1; if (streak >= 7) tier = RewardTier.t2; if (window30 >= 24 && tier.rank < RewardTier.t3.rank) tier = RewardTier.t3; final phaseDay = asOfDate.difference(startDate).inDays + 1; if (phaseDay >= 42 && window42 >= 30) tier = RewardTier.t4; return StreakState( currentStreak: streak, longestStreak: longest, doneCountInPhase42: window42, doneCountInWindow30: window30, currentTier: tier, neverMissTwiceBroken: neverMissTwiceBroken, ); } String _ymd(DateTime d) => '${d.year.toString().padLeft(4, '0')}-' '${d.month.toString().padLeft(2, '0')}-' '${d.day.toString().padLeft(2, '0')}';