Merge develop -> main: full session (KC-51 through KC-71) #28
@@ -51,7 +51,9 @@
|
|||||||
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; };
|
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; };
|
||||||
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; };
|
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; };
|
||||||
CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.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 */; };
|
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 */; };
|
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; };
|
||||||
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
|
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
|
||||||
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
|
||||||
@@ -152,6 +156,7 @@
|
|||||||
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
|
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
|
||||||
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
|
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
|
||||||
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
|
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
|
||||||
|
D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */,
|
||||||
);
|
);
|
||||||
path = KisaniCalTests;
|
path = KisaniCalTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -210,6 +215,7 @@
|
|||||||
5E9A0E064E153429180400E6 /* Views */ = {
|
5E9A0E064E153429180400E6 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */,
|
||||||
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
|
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
|
||||||
4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
|
4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
|
||||||
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
|
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
|
||||||
@@ -439,6 +445,7 @@
|
|||||||
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
|
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
|
||||||
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
|
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
|
||||||
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
|
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
|
||||||
|
E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -454,6 +461,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */,
|
||||||
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
|
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
|
||||||
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
|
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
|
||||||
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
|
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
|
||||||
|
|||||||
@@ -249,17 +249,48 @@ 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
|
||||||
|
/// 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 {
|
var streakDays: Int {
|
||||||
let cal = Calendar.current
|
let goal = UserDefaults.standard.object(forKey: "streakGoal") as? Int ?? 5
|
||||||
let fmt = iso; let dateSet = Set(workoutDates)
|
return Self.weeklyGoalStreak(workoutDates: Set(workoutDates),
|
||||||
var streak = 0
|
scheduledDayCount: schedule.count,
|
||||||
var check = cal.startOfDay(for: Date())
|
goal: goal,
|
||||||
if !dateSet.contains(fmt.string(from: check)) {
|
today: Date(),
|
||||||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
calendar: Calendar.current)
|
||||||
}
|
}
|
||||||
while dateSet.contains(fmt.string(from: check)) {
|
|
||||||
streak += 1
|
/// Pure streak math (unit-tested in StreakLogicTests).
|
||||||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
/// - 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
|
return streak
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,56 @@ class TaskViewModel: ObservableObject {
|
|||||||
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
.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) {
|
func toggle(_ task: TaskItem) {
|
||||||
// A recurring row carries its occurrence date in dueDate — toggle just that day.
|
// A recurring row carries its occurrence date in dueDate — toggle just that day.
|
||||||
if recurs(task), let d = task.dueDate {
|
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
|
@AppStorage("streakGoal") private var streakGoal = 5
|
||||||
@ObservedObject private var hk = HealthKitManager.shared
|
@ObservedObject private var hk = HealthKitManager.shared
|
||||||
@ObservedObject private var auth = AuthManager.shared
|
@ObservedObject private var auth = AuthManager.shared
|
||||||
|
@State private var showActivityHistory = false
|
||||||
|
|
||||||
// ── Task metrics ──
|
// ── Task metrics ──
|
||||||
private var totalTasks: Int { taskVM.tasks.count }
|
private var totalTasks: Int { taskVM.tasks.count }
|
||||||
@@ -1063,24 +1064,19 @@ private struct ProfileStatsSheet: View {
|
|||||||
return Int(Double(doneTasks) / Double(totalTasks) * 100)
|
return Int(Double(doneTasks) / Double(totalTasks) * 100)
|
||||||
}
|
}
|
||||||
private var completedThisWeek: Int {
|
private var completedThisWeek: Int {
|
||||||
let start = Calendar.current.date(byAdding: .day, value: -6, to: Calendar.current.startOfDay(for: Date()))!
|
// Sum of per-day counts — includes recurring occurrences.
|
||||||
return taskVM.tasks.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= start }.count
|
last7.reduce(0) { $0 + $1.1 }
|
||||||
}
|
}
|
||||||
private var urgentOpen: Int {
|
private var urgentOpen: Int {
|
||||||
taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count
|
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)] {
|
private var last7: [(Date, Int)] {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
return (0..<7).reversed().map { ago -> (Date, Int) in
|
return (0..<7).reversed().map { ago -> (Date, Int) in
|
||||||
let day = cal.date(byAdding: .day, value: -ago, to: cal.startOfDay(for: Date()))!
|
let day = cal.date(byAdding: .day, value: -ago, to: cal.startOfDay(for: Date()))!
|
||||||
let next = cal.date(byAdding: .day, value: 1, to: day)!
|
return (day, taskVM.completionCount(on: 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private var maxDay: Int { max(1, last7.map { $0.1 }.max() ?? 1) }
|
private var maxDay: Int { max(1, last7.map { $0.1 }.max() ?? 1) }
|
||||||
@@ -1121,9 +1117,19 @@ private struct ProfileStatsSheet: View {
|
|||||||
|
|
||||||
// ── Tasks card ──
|
// ── Tasks card ──
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
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) {
|
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: "\(completedThisWeek)", label: "this week", color: AppColors.green)
|
||||||
PStatCell(value: "\(overdueCount)", label: "overdue", color: overdueCount > 0 ? AppColors.accent : AppColors.text3(cs))
|
PStatCell(value: "\(overdueCount)", label: "overdue", color: overdueCount > 0 ? AppColors.accent : AppColors.text3(cs))
|
||||||
PStatCell(value: "\(completionRate)%", label: "done rate", color: AppColors.blue)
|
PStatCell(value: "\(completionRate)%", label: "done rate", color: AppColors.blue)
|
||||||
@@ -1296,6 +1302,11 @@ private struct ProfileStatsSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(AppColors.background(cs).ignoresSafeArea())
|
.background(AppColors.background(cs).ignoresSafeArea())
|
||||||
|
.sheet(isPresented: $showActivityHistory) {
|
||||||
|
ActivityHistoryView()
|
||||||
|
.environmentObject(taskVM)
|
||||||
|
.environmentObject(workoutVM)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dayLetter(_ date: Date) -> String {
|
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