diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index f3a0070..8fcdb6e 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -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 = ""; }; 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = ""; }; 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskContextMenu.swift; sourceTree = ""; }; + 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityHistoryView.swift; sourceTree = ""; }; 72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = ""; }; @@ -134,6 +137,7 @@ BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = ""; }; C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; }; + D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakLogicTests.swift; sourceTree = ""; }; D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = ""; }; D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceTests.swift; sourceTree = ""; }; DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = ""; }; @@ -152,6 +156,7 @@ 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */, 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */, D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */, + D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */, ); path = KisaniCalTests; sourceTree = ""; @@ -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 */, diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index 7240523..a0a5fda 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -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, + 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 } diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index d1ca5e2..5c18b80 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -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 { diff --git a/KisaniCal/Views/ActivityHistoryView.swift b/KisaniCal/Views/ActivityHistoryView.swift new file mode 100644 index 0000000..e61596a --- /dev/null +++ b/KisaniCal/Views/ActivityHistoryView.swift @@ -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.. 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)) + } +} diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift index 3c8f3b7..e902201 100644 --- a/KisaniCal/Views/SettingsView.swift +++ b/KisaniCal/Views/SettingsView.swift @@ -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 { diff --git a/KisaniCalTests/StreakLogicTests.swift b/KisaniCalTests/StreakLogicTests.swift new file mode 100644 index 0000000..498a236 --- /dev/null +++ b/KisaniCalTests/StreakLogicTests.swift @@ -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() + 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() + // 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() { + 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() + 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) + } +}