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:
@@ -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 */,
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
161
KisaniCal/Views/ActivityHistoryView.swift
Normal file
161
KisaniCal/Views/ActivityHistoryView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
152
KisaniCalTests/StreakLogicTests.swift
Normal file
152
KisaniCalTests/StreakLogicTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user