Merge develop -> main: full session (KC-51 through KC-71) #28
@@ -249,50 +249,17 @@ class WorkoutViewModel: ObservableObject {
|
|||||||
@Published var restTimerTotal: Int = 0
|
@Published var restTimerTotal: Int = 0
|
||||||
@Published var sessionStartDate: Date? = nil
|
@Published var sessionStartDate: Date? = nil
|
||||||
|
|
||||||
/// Weekly-goal streak, per the Settings promise: "Reach your goal every week
|
/// Lifetime tally of workout days achieved. Per the user's model this only ever
|
||||||
/// to maintain your streak." Counts WORKOUT DAYS across consecutive kept weeks,
|
/// grows — a missed day, an off week, even a fully-blank week never reduce it.
|
||||||
/// so rest days never break it and one skipped day within the goal doesn't
|
/// A 4/5 week still contributes its 4; there is no "break." Not a consecutive
|
||||||
/// reset it. The current (in-progress) week never breaks the chain.
|
/// streak: it's the sum of everything you've actually done.
|
||||||
var streakDays: Int {
|
var streakDays: Int {
|
||||||
let goal = UserDefaults.standard.object(forKey: "streakGoal") as? Int ?? 5
|
Self.totalAchievedWorkoutDays(workoutDates)
|
||||||
return Self.weeklyGoalStreak(workoutDates: Set(workoutDates),
|
|
||||||
scheduledDayCount: schedule.count,
|
|
||||||
goal: goal,
|
|
||||||
today: Date(),
|
|
||||||
calendar: Calendar.current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure streak math (unit-tested in StreakLogicTests).
|
/// Count of distinct workout days ever logged (unit-tested in StreakLogicTests).
|
||||||
/// - A past week is "kept" when its workout days ≥ min(goal, scheduled days).
|
static func totalAchievedWorkoutDays(_ workoutDates: [String]) -> Int {
|
||||||
/// - Streak = total workout days over the current week plus consecutive kept weeks.
|
Set(workoutDates).count
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let iso: DateFormatter = {
|
private let iso: DateFormatter = {
|
||||||
|
|||||||
@@ -781,7 +781,7 @@ private struct WorkoutSettingsSheet: View {
|
|||||||
}.buttonStyle(.plain)
|
}.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))
|
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,101 +12,70 @@ final class StreakLogicTests: XCTestCase {
|
|||||||
fmt.string(from: cal.date(byAdding: .day, value: offset, to: ref)!)
|
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.
|
/// The field scenario: 6-day week, Thursday skipped, 5 workouts done →
|
||||||
/// Scenario from the field: 6-day schedule, goal 5, Thursday skipped,
|
/// the count is 5, not 1. A missed day never reduces it.
|
||||||
/// 5 workouts done that week → streak counts all 5, not 1.
|
func testSkippedDayStillCountsAchieved() {
|
||||||
func testSkippedDayWithinGoalKeepsStreak() {
|
|
||||||
let today = cal.startOfDay(for: Date())
|
let today = cal.startOfDay(for: Date())
|
||||||
// Build "this week": every day up to today except one gap two days ago.
|
var dates: [String] = []
|
||||||
var dates = Set<String>()
|
|
||||||
guard let week = cal.dateInterval(of: .weekOfYear, for: today) else { return XCTFail() }
|
guard let week = cal.dateInterval(of: .weekOfYear, for: today) else { return XCTFail() }
|
||||||
var d = week.start, added = 0
|
var d = week.start, added = 0
|
||||||
while d <= today {
|
while d <= today {
|
||||||
let key = fmt.string(from: d)
|
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)!
|
d = cal.date(byAdding: .day, value: 1, to: d)!
|
||||||
}
|
}
|
||||||
guard added >= 2 else { return } // too early in the week to be meaningful
|
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), added,
|
||||||
|
"every logged day counts; the skipped day is simply absent")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rest days (no workout, none scheduled) never break the chain:
|
/// A below-goal week (4/5) and a full week (5/5) both contribute all their
|
||||||
/// a full previous week meeting the goal chains into this week.
|
/// achieved days — 9 total, nothing lost to the 4/5 week "failing."
|
||||||
func testRestDaysDoNotBreakChainAcrossWeeks() {
|
func testBelowGoalAndFullWeekBothCountFully() {
|
||||||
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() {
|
|
||||||
let today = cal.startOfDay(for: Date())
|
let today = cal.startOfDay(for: Date())
|
||||||
guard let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today),
|
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() }
|
else { return XCTFail() }
|
||||||
|
|
||||||
var dates = Set<String>()
|
var dates: [String] = []
|
||||||
var d = prevWeek.start
|
var d = prevWeek.start
|
||||||
for i in 0..<7 {
|
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
|
||||||
if i < 3 { dates.insert(fmt.string(from: d)) } // 3 of 3 scheduled
|
d = thisWeek.start
|
||||||
d = cal.date(byAdding: .day, value: 1, to: d)!
|
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(
|
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 9,
|
||||||
workoutDates: dates, scheduledDayCount: 3, goal: 5,
|
"4/5 + 5/5 = 9; a below-goal week is never discarded")
|
||||||
today: today, calendar: cal)
|
}
|
||||||
XCTAssertEqual(streak, 3, "3-day schedule meets its capped goal and chains")
|
|
||||||
|
/// 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() {
|
func testEmptyDataIsZero() {
|
||||||
let streak = WorkoutViewModel.weeklyGoalStreak(
|
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([]), 0)
|
||||||
workoutDates: [], scheduledDayCount: 6, goal: 5,
|
|
||||||
today: Date(), calendar: cal)
|
|
||||||
XCTAssertEqual(streak, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Task completion counting (recurring occurrences)
|
// MARK: - Task completion counting (recurring occurrences)
|
||||||
|
|||||||
Reference in New Issue
Block a user