diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index a0a5fda..d1a171d 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -249,50 +249,17 @@ 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. + /// Lifetime tally of workout days achieved. Per the user's model this only ever + /// grows — a missed day, an off week, even a fully-blank week never reduce it. + /// A 4/5 week still contributes its 4; there is no "break." Not a consecutive + /// streak: it's the sum of everything you've actually done. var streakDays: Int { - 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) + Self.totalAchievedWorkoutDays(workoutDates) } - /// 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, - 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 - } - - 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 + /// Count of distinct workout days ever logged (unit-tested in StreakLogicTests). + static func totalAchievedWorkoutDays(_ workoutDates: [String]) -> Int { + Set(workoutDates).count } private let iso: DateFormatter = { diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift index e902201..63db2fe 100644 --- a/KisaniCal/Views/SettingsView.swift +++ b/KisaniCal/Views/SettingsView.swift @@ -781,7 +781,7 @@ private struct WorkoutSettingsSheet: View { }.buttonStyle(.plain) } } - Text("Reach your goal every week to maintain your streak.") + Text("Your weekly workout target. Every workout you log adds to your total — a missed day never takes it away.") .font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)) } diff --git a/KisaniCalTests/StreakLogicTests.swift b/KisaniCalTests/StreakLogicTests.swift index 498a236..78d4c0f 100644 --- a/KisaniCalTests/StreakLogicTests.swift +++ b/KisaniCalTests/StreakLogicTests.swift @@ -12,101 +12,70 @@ final class StreakLogicTests: XCTestCase { fmt.string(from: cal.date(byAdding: .day, value: offset, to: ref)!) } - // MARK: - Workout weekly-goal streak + // MARK: - Workout tally (lifetime; only ever grows, never resets) - /// A skipped day within the weekly goal must NOT reset the streak. - /// Scenario from the field: 6-day schedule, goal 5, Thursday skipped, - /// 5 workouts done that week → streak counts all 5, not 1. - func testSkippedDayWithinGoalKeepsStreak() { + /// The field scenario: 6-day week, Thursday skipped, 5 workouts done → + /// the count is 5, not 1. A missed day never reduces it. + func testSkippedDayStillCountsAchieved() { let today = cal.startOfDay(for: Date()) - // Build "this week": every day up to today except one gap two days ago. - var dates = Set() + var dates: [String] = [] guard let week = cal.dateInterval(of: .weekOfYear, for: today) else { return XCTFail() } var d = week.start, added = 0 while d <= today { let key = fmt.string(from: d) - if key != day(-2, from: today) { dates.insert(key); added += 1 } + if key != day(-2, from: today) { dates.append(key); added += 1 } // skip one day d = cal.date(byAdding: .day, value: 1, to: d)! } - guard added >= 2 else { return } // too early in the week to be meaningful - - let streak = WorkoutViewModel.weeklyGoalStreak( - workoutDates: dates, scheduledDayCount: 6, goal: 5, - today: today, calendar: cal) - XCTAssertEqual(streak, added, "days this week minus the gap should all count") - XCTAssertGreaterThan(streak, 1, "a mid-week skip must not collapse the streak to 1") + XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), added, + "every logged day counts; the skipped day is simply absent") } - /// Rest days (no workout, none scheduled) never break the chain: - /// a full previous week meeting the goal chains into this week. - func testRestDaysDoNotBreakChainAcrossWeeks() { - let today = cal.startOfDay(for: Date()) - guard let thisWeek = cal.dateInterval(of: .weekOfYear, for: today), - let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today), - let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef) - else { return XCTFail() } - - var dates = Set() - // Previous week: 5 workout days (goal met), 2 rest days. - var d = prevWeek.start - for i in 0..<7 { - if i < 5 { dates.insert(fmt.string(from: d)) } - d = cal.date(byAdding: .day, value: 1, to: d)! - } - // This week: workout on the first day only. - dates.insert(fmt.string(from: thisWeek.start)) - guard thisWeek.start <= today else { return XCTFail() } - - let streak = WorkoutViewModel.weeklyGoalStreak( - workoutDates: dates, scheduledDayCount: 5, goal: 5, - today: today, calendar: cal) - XCTAssertEqual(streak, 6, "5 from the kept previous week + 1 this week") - } - - /// A previous week below the goal ends the chain — current week still counts. - func testFailedWeekBreaksChain() { - let today = cal.startOfDay(for: Date()) - guard let thisWeek = cal.dateInterval(of: .weekOfYear, for: today), - let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today), - let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef) - else { return XCTFail() } - - var dates = Set() - dates.insert(fmt.string(from: prevWeek.start)) // only 1 day done last week - dates.insert(fmt.string(from: thisWeek.start)) // 1 day this week - - let streak = WorkoutViewModel.weeklyGoalStreak( - workoutDates: dates, scheduledDayCount: 5, goal: 5, - today: today, calendar: cal) - XCTAssertEqual(streak, 1, "chain stops at the failed week; this week still counts") - } - - /// Goal is capped by the schedule: a 3-day program with goal 5 keeps the - /// chain at 3 workouts/week. - func testGoalCappedByScheduledDays() { + /// A below-goal week (4/5) and a full week (5/5) both contribute all their + /// achieved days — 9 total, nothing lost to the 4/5 week "failing." + func testBelowGoalAndFullWeekBothCountFully() { let today = cal.startOfDay(for: Date()) guard let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today), - let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef) + let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef), + let thisWeek = cal.dateInterval(of: .weekOfYear, for: today) else { return XCTFail() } - var dates = Set() + var dates: [String] = [] var d = prevWeek.start - for i in 0..<7 { - if i < 3 { dates.insert(fmt.string(from: d)) } // 3 of 3 scheduled - d = cal.date(byAdding: .day, value: 1, to: d)! - } + for i in 0..<7 { if i < 4 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } // 4/5 + d = thisWeek.start + for i in 0..<7 { if i < 5 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } // 5/5 - let streak = WorkoutViewModel.weeklyGoalStreak( - workoutDates: dates, scheduledDayCount: 3, goal: 5, - today: today, calendar: cal) - XCTAssertEqual(streak, 3, "3-day schedule meets its capped goal and chains") + XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 9, + "4/5 + 5/5 = 9; a below-goal week is never discarded") + } + + /// A completely blank week between two active stretches does not reset the + /// tally — earlier achievements are preserved. + func testBlankWeekDoesNotReset() { + let today = cal.startOfDay(for: Date()) + guard let twoBack = cal.date(byAdding: .weekOfYear, value: -2, to: today), + let oldWeek = cal.dateInterval(of: .weekOfYear, for: twoBack), + let thisWeek = cal.dateInterval(of: .weekOfYear, for: today) + else { return XCTFail() } + + var dates: [String] = [] + var d = oldWeek.start + for i in 0..<7 { if i < 3 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } + // (previous week: nothing — a blank week) + dates.append(fmt.string(from: thisWeek.start)) + + XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 4, + "3 old + 1 now; the empty week in between changes nothing") + } + + /// Duplicate day entries (e.g. HealthKit merge) never double-count. + func testDuplicatesCountOnce() { + let key = fmt.string(from: cal.startOfDay(for: Date())) + XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([key, key, key]), 1) } func testEmptyDataIsZero() { - let streak = WorkoutViewModel.weeklyGoalStreak( - workoutDates: [], scheduledDayCount: 6, goal: 5, - today: Date(), calendar: cal) - XCTAssertEqual(streak, 0) + XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([]), 0) } // MARK: - Task completion counting (recurring occurrences)