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

@@ -51,7 +51,9 @@
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; };
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; };
CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */; };
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */; };
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; };
E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */; };
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; };
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; };
@@ -112,6 +114,7 @@
57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = "<group>"; };
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = "<group>"; };
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityHistoryView.swift; sourceTree = "<group>"; };
72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = "<group>"; };
@@ -134,6 +137,7 @@
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakLogicTests.swift; sourceTree = "<group>"; };
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; };
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceTests.swift; sourceTree = "<group>"; };
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
@@ -152,6 +156,7 @@
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */,
);
path = KisaniCalTests;
sourceTree = "<group>";
@@ -210,6 +215,7 @@
5E9A0E064E153429180400E6 /* Views */ = {
isa = PBXGroup;
children = (
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */,
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
@@ -439,6 +445,7 @@
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -454,6 +461,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */,
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,

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)
}
while dateSet.contains(fmt.string(from: check)) {
streak += 1
check = cal.date(byAdding: .day, value: -1, to: check)!
/// 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
}
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) {
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 {

View File

@@ -0,0 +1,152 @@
import XCTest
@testable import KisaniCal
final class StreakLogicTests: XCTestCase {
private let cal = Calendar.current
private let fmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
}()
private func day(_ offset: Int, from ref: Date) -> String {
fmt.string(from: cal.date(byAdding: .day, value: offset, to: ref)!)
}
// MARK: - Workout weekly-goal streak
/// 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() {
let today = cal.startOfDay(for: Date())
// Build "this week": every day up to today except one gap two days ago.
var dates = Set<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 }
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")
}
/// 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() {
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)
else { return XCTFail() }
var dates = Set<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)!
}
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")
}
func testEmptyDataIsZero() {
let streak = WorkoutViewModel.weeklyGoalStreak(
workoutDates: [], scheduledDayCount: 6, goal: 5,
today: Date(), calendar: cal)
XCTAssertEqual(streak, 0)
}
// MARK: - Task completion counting (recurring occurrences)
private func makeRecurring(title: String, completedOn keys: [String]) -> TaskItem {
var t = TaskItem(title: title, dueDate: cal.date(byAdding: .day, value: -30, to: Date()))
t.isRecurring = true
t.recurrenceLabel = "Daily"
t.completedOccurrences = keys
return t
}
/// Recurring completions must count toward per-day stats even though the
/// stored task's isComplete/completedAt stay untouched.
func testRecurringOccurrencesCountedPerDay() {
let today = cal.startOfDay(for: Date())
let todayKey = fmt.string(from: today)
let recurring = makeRecurring(title: "Morning routine", completedOn: [todayKey])
var oneOff = TaskItem(title: "Buy milk")
oneOff.isComplete = true
oneOff.completedAt = today.addingTimeInterval(3600)
let n = TaskViewModel.completionCount(in: [recurring, oneOff], on: today, calendar: cal)
XCTAssertEqual(n, 2, "one recurring occurrence + one one-off completion")
}
func testRecurringNotCountedOnOtherDays() {
let today = cal.startOfDay(for: Date())
let yesterdayKey = day(-1, from: today)
let recurring = makeRecurring(title: "Morning routine", completedOn: [yesterdayKey])
XCTAssertEqual(TaskViewModel.completionCount(in: [recurring], on: today, calendar: cal), 0)
let yesterday = cal.date(byAdding: .day, value: -1, to: today)!
XCTAssertEqual(TaskViewModel.completionCount(in: [recurring], on: yesterday, calendar: cal), 1)
}
func testIncompleteOneOffNotCounted() {
let today = cal.startOfDay(for: Date())
let t = TaskItem(title: "Open task")
XCTAssertEqual(TaskViewModel.completionCount(in: [t], on: today, calendar: cal), 0)
}
}