Workout streak: lifetime tally that only grows (never resets)

Per user model: the workout count should reflect every workout achieved and
never break. Replaces the consecutive/weekly-goal streak with a simple count
of distinct workout days (totalAchievedWorkoutDays). A below-goal week (4/5)
and a full week (5/5) both count fully (=9); a missed day or blank week never
reduces it; duplicate HealthKit days count once.

- ExerciseModels: streakDays = Set(workoutDates).count
- StreakLogicTests: retargeted to tally semantics (4/5+5/5=9, blank-week,
  duplicates, skipped-day, empty)
- SettingsView: goal caption no longer claims the streak can be lost

NOTE: not built/tested locally — CLI xcodebuild wedges on actool via the
broken CoreSimulator daemon (missing sim runtimes, machine-wide). Verify in
Xcode GUI (prefs now fixed) or on device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-07-05 19:51:46 +03:00
parent 6b5f6bd3ea
commit c0dd298e5b
3 changed files with 54 additions and 118 deletions

View File

@@ -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<String>()
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<String>()
// 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<String>()
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<String>()
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)