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 {

View File

@@ -0,0 +1,161 @@
import SwiftUI
// MARK: - Activity History
// Day-by-day archive of everything logged: tasks completed (recurring
// occurrences included), workouts done/missed/compensated. Only days with
// activity are shown quiet days stay quiet.
struct ActivityHistoryView: View {
@EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
/// How far back the archive reaches.
private let daysBack = 90
private static let keyFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
}()
private static let pretty: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE, MMM d"; return f
}()
private static let timeFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "HH:mm"; return f
}()
private struct DayEntry: Identifiable {
let id: String // "yyyy-MM-dd"
let date: Date
let tasks: [TaskItem]
let workout: WorkoutDayLog? // full log if one was saved
let workoutDone: Bool // in workoutDates (streak record)
let missed: Bool // marked "didn't do it"
let compensated: Bool // rest-day makeup scheduled
}
private var entries: [DayEntry] {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
let logByDate = Dictionary(uniqueKeysWithValues: workoutVM.history.map { ($0.date, $0) })
let doneSet = Set(workoutVM.workoutDates)
let missedSet = Set(workoutVM.missedDates)
return (0..<daysBack).compactMap { ago -> DayEntry? in
guard let day = cal.date(byAdding: .day, value: -ago, to: today) else { return nil }
let key = Self.keyFmt.string(from: day)
let tasks = taskVM.completions(on: day)
let log = logByDate[key]
let done = doneSet.contains(key)
let missed = missedSet.contains(key)
let comp = workoutVM.compensations[key] != nil
guard !tasks.isEmpty || log != nil || done || missed || comp else { return nil }
return DayEntry(id: key, date: day, tasks: tasks, workout: log,
workoutDone: done, missed: missed, compensated: comp)
}
}
private func label(_ date: Date) -> String {
if Calendar.current.isDateInToday(date) { return "Today" }
if Calendar.current.isDateInYesterday(date) { return "Yesterday" }
return Self.pretty.string(from: date)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Activity").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button("Done") { dismiss() }
.font(AppFonts.sans(13, weight: .semibold))
.foregroundColor(AppColors.accent).buttonStyle(.plain)
}
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
Divider().background(AppColors.border(cs))
if entries.isEmpty {
VStack(spacing: 8) {
Image(systemName: "clock.arrow.circlepath").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
Text("Nothing here yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Text("Completed tasks and workouts show up here, day by day.")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
} else {
ScrollView(showsIndicators: false) {
VStack(spacing: 12) {
ForEach(entries) { day in
dayCard(day)
}
}
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
}
}
}
.background(AppColors.background(cs).ignoresSafeArea())
}
@ViewBuilder
private func dayCard(_ day: DayEntry) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(label(day.date))
.font(AppFonts.sans(13, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
if !day.tasks.isEmpty {
Text("\(day.tasks.count) task\(day.tasks.count == 1 ? "" : "s")")
.font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.green)
}
}
// Workout line
if let log = day.workout {
HStack(spacing: 8) {
Image(systemName: "dumbbell.fill").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
Text(log.programName).font(AppFonts.sans(12, weight: .semibold)).foregroundColor(AppColors.text2(cs))
Spacer()
Text("\(log.doneSets)/\(log.totalSets) sets\(log.volume > 0 ? " · \(Int(log.volume)) kg" : "")")
.font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
} else if day.workoutDone {
HStack(spacing: 8) {
Image(systemName: "dumbbell.fill").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
Text("Workout done").font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
Spacer()
}
}
if day.missed {
HStack(spacing: 8) {
Image(systemName: "minus.circle").font(.system(size: 10)).foregroundColor(AppColors.accent).frame(width: 18)
Text("Workout missed\(day.compensated ? " · made up on a rest day" : "")")
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
Spacer()
}
} else if day.compensated && !day.workoutDone {
HStack(spacing: 8) {
Image(systemName: "arrow.uturn.forward.circle").font(.system(size: 10)).foregroundColor(AppColors.blue).frame(width: 18)
Text("Rest-day makeup scheduled").font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
Spacer()
}
}
// Task lines
ForEach(day.tasks) { t in
HStack(spacing: 8) {
Image(systemName: "checkmark.square").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
Text(t.title).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs)).lineLimit(1)
if t.isRecurring {
Image(systemName: "repeat").font(.system(size: 8)).foregroundColor(AppColors.text3(cs))
}
Spacer()
if t.hasTime, let at = t.completedAt {
Text(Self.timeFmt.string(from: at)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
}
}
}
}
.padding(14)
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
}
}

View File

@@ -1053,6 +1053,7 @@ private struct ProfileStatsSheet: View {
@AppStorage("streakGoal") private var streakGoal = 5
@ObservedObject private var hk = HealthKitManager.shared
@ObservedObject private var auth = AuthManager.shared
@State private var showActivityHistory = false
// Task metrics
private var totalTasks: Int { taskVM.tasks.count }
@@ -1063,24 +1064,19 @@ private struct ProfileStatsSheet: View {
return Int(Double(doneTasks) / Double(totalTasks) * 100)
}
private var completedThisWeek: Int {
let start = Calendar.current.date(byAdding: .day, value: -6, to: Calendar.current.startOfDay(for: Date()))!
return taskVM.tasks.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= start }.count
// Sum of per-day counts includes recurring occurrences.
last7.reduce(0) { $0 + $1.1 }
}
private var urgentOpen: Int {
taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count
}
// 7-day task activity
// 7-day task activity (recurring occurrences included)
private var last7: [(Date, Int)] {
let cal = Calendar.current
return (0..<7).reversed().map { ago -> (Date, Int) in
let day = cal.date(byAdding: .day, value: -ago, to: cal.startOfDay(for: Date()))!
let next = cal.date(byAdding: .day, value: 1, to: day)!
let n = taskVM.tasks.filter { t in
guard t.isComplete, let at = t.completedAt else { return false }
return at >= day && at < next
}.count
return (day, n)
return (day, taskVM.completionCount(on: day))
}
}
private var maxDay: Int { max(1, last7.map { $0.1 }.max() ?? 1) }
@@ -1121,9 +1117,19 @@ private struct ProfileStatsSheet: View {
// Tasks card
VStack(alignment: .leading, spacing: 12) {
Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
HStack {
Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
Spacer()
Button { showActivityHistory = true } label: {
Text("History")
.font(AppFonts.mono(9, weight: .bold))
.foregroundColor(AppColors.text2(cs))
}
.buttonStyle(.plain)
}
HStack(spacing: 8) {
PStatCell(value: "\(taskVM.taskStreakDays)", label: "day streak", color: taskVM.taskStreakDays > 0 ? AppColors.green : AppColors.text3(cs))
PStatCell(value: "\(completedThisWeek)", label: "this week", color: AppColors.green)
PStatCell(value: "\(overdueCount)", label: "overdue", color: overdueCount > 0 ? AppColors.accent : AppColors.text3(cs))
PStatCell(value: "\(completionRate)%", label: "done rate", color: AppColors.blue)
@@ -1296,6 +1302,11 @@ private struct ProfileStatsSheet: View {
}
}
.background(AppColors.background(cs).ignoresSafeArea())
.sheet(isPresented: $showActivityHistory) {
ActivityHistoryView()
.environmentObject(taskVM)
.environmentObject(workoutVM)
}
}
private func dayLetter(_ date: Date) -> String {