Stats: schedule-aware weekly streaks, recurring-aware counts, activity history

- Workout streak now honors the weekly goal ('reach your goal every week'):
  counts workout days across consecutive kept weeks, so rest days and a
  skipped day within goal no longer reset it to 1 (KC: profile showed
  streak 1 with 5 workouts that week).
- Task 7-day bars / 'this week' now count recurring occurrences
  (completedOccurrences), fixing permanently-empty bars alongside a
  nonzero done rate.
- New task day-streak stat on the profile Tasks card.
- New ActivityHistoryView: 90-day day-by-day archive of completed tasks
  (times, repeat markers), workout logs (sets/volume), missed/made-up days.
- StreakLogicTests: 8 unit tests; algorithm additionally verified via
  standalone harness (7/7 scenarios incl. the skipped-Thursday case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-07-05 15:05:14 +03:00
parent dd22e94efe
commit 6b5f6bd3ea
6 changed files with 432 additions and 19 deletions

View File

@@ -249,17 +249,48 @@ class WorkoutViewModel: ObservableObject {
@Published var restTimerTotal: Int = 0
@Published var sessionStartDate: Date? = nil
/// Weekly-goal streak, per the Settings promise: "Reach your goal every week
/// to maintain your streak." Counts WORKOUT DAYS across consecutive kept weeks,
/// so rest days never break it and one skipped day within the goal doesn't
/// reset it. The current (in-progress) week never breaks the chain.
var streakDays: Int {
let cal = Calendar.current
let fmt = iso; let dateSet = Set(workoutDates)
var streak = 0
var check = cal.startOfDay(for: Date())
if !dateSet.contains(fmt.string(from: check)) {
check = cal.date(byAdding: .day, value: -1, to: check)!
let goal = UserDefaults.standard.object(forKey: "streakGoal") as? Int ?? 5
return Self.weeklyGoalStreak(workoutDates: Set(workoutDates),
scheduledDayCount: schedule.count,
goal: goal,
today: Date(),
calendar: Calendar.current)
}
/// Pure streak math (unit-tested in StreakLogicTests).
/// - A past week is "kept" when its workout days min(goal, scheduled days).
/// - Streak = total workout days over the current week plus consecutive kept weeks.
static func weeklyGoalStreak(workoutDates: Set<String>,
scheduledDayCount: Int,
goal: Int,
today: Date,
calendar cal: Calendar) -> Int {
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
let target = scheduledDayCount > 0 ? min(max(goal, 1), scheduledDayCount) : max(goal, 1)
func daysDone(inWeekOf ref: Date) -> Int {
guard let week = cal.dateInterval(of: .weekOfYear, for: ref) else { return 0 }
var n = 0, d = week.start
while d < week.end {
if workoutDates.contains(fmt.string(from: d)) { n += 1 }
d = cal.date(byAdding: .day, value: 1, to: d)!
}
return n
}
while dateSet.contains(fmt.string(from: check)) {
streak += 1
check = cal.date(byAdding: .day, value: -1, to: check)!
var streak = daysDone(inWeekOf: today) // current week: always counts, never breaks
var ref = today
for _ in 0..<520 { // walk back up to 10 years
guard let prev = cal.date(byAdding: .weekOfYear, value: -1, to: ref) else { break }
let done = daysDone(inWeekOf: prev)
guard done >= target else { break }
streak += done
ref = prev
}
return streak
}

View File

@@ -280,6 +280,56 @@ class TaskViewModel: ObservableObject {
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
}
// MARK: - Completion stats (counts recurring occurrences, not just one-offs)
/// Completions on a given day: one-off tasks by `completedAt`, recurring
/// tasks by their `completedOccurrences` entry for that day.
func completionCount(on day: Date) -> Int {
Self.completionCount(in: tasks, on: day, calendar: Calendar.current)
}
/// Pure counting (unit-tested in StreakLogicTests).
static func completionCount(in tasks: [TaskItem], on day: Date, calendar cal: Calendar) -> Int {
let d0 = cal.startOfDay(for: day)
let next = cal.date(byAdding: .day, value: 1, to: d0)!
let key = occFmt.string(from: d0)
return tasks.reduce(0) { n, t in
let isRecurring = t.isRecurring && (t.recurrenceLabel ?? "None") != "None" && t.dueDate != nil
if isRecurring {
return n + ((t.completedOccurrences?.contains(key) ?? false) ? 1 : 0)
}
if t.isComplete, let at = t.completedAt, at >= d0, at < next { return n + 1 }
return n
}
}
/// Completed tasks on a day, recurring occurrences included (for history views).
func completions(on day: Date) -> [TaskItem] {
let cal = Calendar.current
let d0 = cal.startOfDay(for: day)
let next = cal.date(byAdding: .day, value: 1, to: d0)!
let nonRec = tasks.filter { !recurs($0) && $0.isComplete && (($0.completedAt ?? .distantPast) >= d0) && (($0.completedAt ?? .distantPast) < next) }
let rec = tasks.filter { recurs($0) && occurrenceComplete($0, on: d0) }
.map { occurrenceCopy($0, on: d0) }
return (nonRec + rec).sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
}
/// Consecutive days with 1 completion, walking back from today.
/// A quiet day today doesn't break the streak it just isn't counted yet.
var taskStreakDays: Int {
let cal = Calendar.current
var check = cal.startOfDay(for: Date())
var streak = 0
if completionCount(on: check) == 0 {
check = cal.date(byAdding: .day, value: -1, to: check)!
}
while completionCount(on: check) > 0 {
streak += 1
check = cal.date(byAdding: .day, value: -1, to: check)!
}
return streak
}
func toggle(_ task: TaskItem) {
// A recurring row carries its occurrence date in dueDate toggle just that day.
if recurs(task), let d = task.dueDate {