diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index 8fcdb6e..df67d58 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; }; 12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; }; 13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; }; + 152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */; }; 16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */; }; 1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; }; 205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; }; @@ -21,9 +22,11 @@ 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; }; 3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; }; 3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; }; + 42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */; }; 45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */; }; 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; }; 4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */; }; + 550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */; }; 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; }; 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; }; 5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; }; @@ -93,16 +96,17 @@ /* Begin PBXFileReference section */ 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = ""; }; 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = ""; }; + 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsModels.swift; sourceTree = ""; }; 0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; }; 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = ""; }; 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; }; - 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGrid.swift; sourceTree = ""; }; 106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = ""; }; 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = ""; }; 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = ""; }; - 20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = ""; }; 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = ""; }; 35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = ""; }; @@ -119,6 +123,7 @@ 72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGridTests.swift; sourceTree = ""; }; 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = ""; }; + 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngine.swift; sourceTree = ""; }; 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WenzaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechRecognizer.swift; sourceTree = ""; }; 89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; @@ -128,7 +133,8 @@ 92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = ""; }; 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = ""; }; - 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngineTests.swift; sourceTree = ""; }; ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = ""; }; B4CFFDFE4653A9E901CEF28D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -153,6 +159,7 @@ 068B77B2F01C399C7A430292 /* KisaniCalTests */ = { isa = PBXGroup; children = ( + 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */, 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */, 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */, D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */, @@ -241,6 +248,15 @@ path = "Preview Content"; sourceTree = ""; }; + C7409CC354029293D424BEDA /* Analytics */ = { + isa = PBXGroup; + children = ( + 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */, + 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */, + ); + path = Analytics; + sourceTree = ""; + }; E0A03E79A679740978E61BF1 /* Theme */ = { isa = PBXGroup; children = ( @@ -273,6 +289,7 @@ 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */, 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */, C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */, + C7409CC354029293D424BEDA /* Analytics */, 21B93C269F283F11B415B18C /* Components */, FB9BF734B9E493EEB09ACE21 /* Managers */, 23CBCF100C5EF55E737379CA /* Models */, @@ -382,7 +399,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1500; + LastUpgradeCheck = 2660; TargetAttributes = { 28B43516AD88946E21D9BFE0 = { DevelopmentTeam = K8BLMMR883; @@ -442,6 +459,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */, 6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */, 16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */, 4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */, @@ -463,6 +481,8 @@ files = ( D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */, A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */, + 152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */, + 42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */, 2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */, 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */, ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */, @@ -616,10 +636,12 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 9; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = K8BLMMR883; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -633,6 +655,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; @@ -667,7 +690,11 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = KisaniCalWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; @@ -710,10 +737,12 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 9; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = K8BLMMR883; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -734,6 +763,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -767,7 +797,11 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = KisaniCalWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; diff --git a/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme b/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme index 4071af8..d8b51b8 100644 --- a/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme +++ b/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme @@ -1,11 +1,10 @@ + LastUpgradeVersion = "2660" + version = "1.3"> + buildImplicitDependencies = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> - - - - - - diff --git a/KisaniCal/Analytics/AnalyticsEngine.swift b/KisaniCal/Analytics/AnalyticsEngine.swift new file mode 100644 index 0000000..5bce712 --- /dev/null +++ b/KisaniCal/Analytics/AnalyticsEngine.swift @@ -0,0 +1,287 @@ +import Foundation + +// Deterministic, side-effect-free analytics calculations. No SwiftUI, no +// persistence, no HealthKit, no LLM — pure functions over value types so every +// formula is unit-testable and reproducible. All calendar math takes an injected +// `Calendar` so locale / firstWeekday / timezone / DST behavior is testable. +// +// FORMULAS +// volume = Σ (weight × reps) over completed sets [kg] +// percentChange = (current − baseline) / |baseline| × 100 [%] +// consistency = min(1, sessions / scheduled) [0…1] +// trend = classify(least-squares slope / |mean|) vs threshold +// +// All comparisons degrade honestly: missing baseline → no %, tiny/zero baseline +// → no % (avoids divide-by-tiny), new exercise / rest day → not meaningful. +// Language is factual and supportive; nothing here diagnoses or advises medically. + +enum AnalyticsEngine { + + /// Informational, non-medical disclaimer surfaced with every report. + static let disclaimer = """ + These are informational summaries of your own logged activity — not medical \ + advice, diagnosis, or treatment. Talk to a qualified professional for health \ + decisions. + """ + + // MARK: - Exercise identity + + /// Normalized identity so the same exercise matches across days regardless of + /// casing / whitespace. Deterministic and locale-independent. + static func exerciseIdentity(_ name: String) -> String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: " ", with: " ") + } + + // MARK: - Delta + + /// Compare `current` against `baseline`, guarding percentage honesty. + /// - `isNew`: no prior baseline exists (e.g. a newly added exercise). + /// - `restDay`: baseline day was a rest day → % is not meaningful. + static func delta(current: Double, + baseline: Double?, + minMeaningfulBaseline: Double = 0.0001, + isNew: Bool = false, + restDay: Bool = false) -> MetricDelta { + guard let base = baseline, !isNew, !restDay else { + return MetricDelta(current: current, + baseline: baseline, + absolute: baseline.map { current - $0 }, + percent: nil, + direction: current > 0 ? .up : .flat, + meaningful: false) + } + let abs = current - base + let dir: MetricDirection = abs > 0 ? .up : (abs < 0 ? .down : .flat) + // Only compute % when the baseline magnitude is meaningful. + let pct: Double? = Swift.abs(base) >= minMeaningfulBaseline ? (abs / Swift.abs(base)) * 100 : nil + return MetricDelta(current: current, + baseline: base, + absolute: abs, + percent: pct, + direction: dir, + meaningful: pct != nil) + } + + /// Classify a single week/month-over-period change from its volume delta. + static func classifyChange(_ d: MetricDelta, plateauPct: Double = 3) -> TrendClassification { + guard d.meaningful, let pct = d.percent else { return .insufficientData } + if pct > plateauPct { return .improving } + if pct < -plateauPct { return .declining } + return .plateau + } + + // MARK: - Consistency + + static func consistency(sessions: Int, scheduled: Int) -> Double { + guard scheduled > 0 else { return 0 } + return min(1.0, Double(sessions) / Double(scheduled)) + } + + // MARK: - Volume + + static func volume(weight: Double, reps: Int) -> Double { weight * Double(reps) } + + // MARK: - Trend (multi-point, for 12-month views) + + static func classifyTrend(_ values: [Double], + minPoints: Int = 3, + relativeThreshold: Double = 0.03) -> TrendResult { + let n = values.count + guard n >= minPoints else { + return TrendResult(classification: .insufficientData, slope: 0, points: n) + } + let slope = leastSquaresSlope(values) + let mean = values.reduce(0, +) / Double(n) + guard Swift.abs(mean) > 0 else { + let cls: TrendClassification = slope == 0 ? .plateau : (slope > 0 ? .improving : .declining) + return TrendResult(classification: cls, slope: slope, points: n) + } + let rel = slope / Swift.abs(mean) + let cls: TrendClassification + if rel > relativeThreshold { cls = .improving } + else if rel < -relativeThreshold { cls = .declining } + else { cls = .plateau } + return TrendResult(classification: cls, slope: slope, points: n) + } + + static func leastSquaresSlope(_ y: [Double]) -> Double { + let n = Double(y.count) + guard n > 1 else { return 0 } + let xs = (0.. [Double] { + guard values.count >= 5 else { return values } + let sorted = values.sorted() + let lo = percentile(sorted, lower) + let hi = percentile(sorted, upper) + return values.map { min(max($0, lo), hi) } + } + + static func percentile(_ sorted: [Double], _ p: Double) -> Double { + guard !sorted.isEmpty else { return 0 } + let idx = Int((Double(sorted.count - 1) * min(max(p, 0), 1)).rounded()) + return sorted[min(max(idx, 0), sorted.count - 1)] + } + + // MARK: - Exercise comparison + + static func compareExercises(current: [ExerciseSample], + baseline: [ExerciseSample], + restDay: Bool = false) -> [ExerciseComparison] { + let baseByIdentity = Dictionary(baseline.map { ($0.identity, $0) }, uniquingKeysWith: { a, _ in a }) + return current.map { cur in + let base = baseByIdentity[cur.identity] + let isNew = base == nil + return ExerciseComparison( + identity: cur.identity, + volume: delta(current: cur.volume, baseline: base?.volume, isNew: isNew, restDay: restDay), + sets: delta(current: Double(cur.sets), baseline: base.map { Double($0.sets) }, isNew: isNew, restDay: restDay), + reps: delta(current: Double(cur.reps), baseline: base.map { Double($0.reps) }, isNew: isNew, restDay: restDay), + isNew: isNew + ) + } + } + + // MARK: - Daily comparison + + static func dailyComparison(day: DaySample, + previousDay: DaySample?, + sameWeekdayLastWeek: DaySample?) -> DailyComparison { + DailyComparison( + dateKey: day.dateKey, + restDay: !day.didWorkout, + vsPreviousDay: compareExercises(current: day.exercises, + baseline: previousDay?.exercises ?? [], + restDay: previousDay.map { !$0.didWorkout } ?? true), + vsSameWeekdayLastWeek: compareExercises(current: day.exercises, + baseline: sameWeekdayLastWeek?.exercises ?? [], + restDay: sameWeekdayLastWeek.map { !$0.didWorkout } ?? true) + ) + } + + // MARK: - Period summaries & reports + + static func summarize(_ days: [DaySample]) -> PeriodSummary { + let sessions = days.filter { $0.didWorkout }.count + let scheduled = days.filter { $0.scheduled }.count + let missed = days.filter { $0.scheduled && !$0.didWorkout }.count + let sets = days.reduce(0) { $0 + $1.sets } + let volume = days.reduce(0) { $0 + $1.volume } + let duration = days.reduce(0) { $0 + $1.durationMinutes } + return PeriodSummary(sessions: sessions, sets: sets, volume: volume, + durationMinutes: duration, scheduled: scheduled, + missed: missed, consistency: consistency(sessions: sessions, scheduled: scheduled)) + } + + private static func periodDeltas(current cur: PeriodSummary, previous prev: PeriodSummary) -> [String: MetricDelta] { + [ + "sessions": delta(current: Double(cur.sessions), baseline: Double(prev.sessions)), + "sets": delta(current: Double(cur.sets), baseline: Double(prev.sets)), + "volume": delta(current: cur.volume, baseline: prev.volume), + "duration": delta(current: cur.durationMinutes, baseline: prev.durationMinutes), + "consistency": delta(current: cur.consistency * 100, baseline: prev.consistency * 100), + ] + } + + static func weeklyReport(weekStartKey: String, + days: [DaySample], + previousWeekDays: [DaySample], + generatedAt: Date) -> WeeklyReport { + let cur = summarize(days) + let prev = summarize(previousWeekDays) + let deltas = periodDeltas(current: cur, previous: prev) + return WeeklyReport( + weekStartKey: weekStartKey, + generatedAt: generatedAt, + summary: cur, + vsPreviousWeek: deltas, + progression: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)), + disclaimer: disclaimer + ) + } + + static func monthlyReport(monthKey: String, + days: [DaySample], + previousMonthDays: [DaySample], + generatedAt: Date) -> MonthlyReport { + let cur = summarize(days) + let prev = summarize(previousMonthDays) + let deltas = periodDeltas(current: cur, previous: prev) + return MonthlyReport( + monthKey: monthKey, + generatedAt: generatedAt, + summary: cur, + vsPreviousMonth: deltas, + classification: classifyChange(deltas["volume"] ?? delta(current: 0, baseline: nil)), + disclaimer: disclaimer + ) + } + + // MARK: - Calendar helpers (injected Calendar → locale/TZ/DST-safe) + + private static func keyFormatter(_ calendar: Calendar) -> DateFormatter { + let f = DateFormatter() + f.calendar = calendar + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = calendar.timeZone + f.dateFormat = "yyyy-MM-dd" + return f + } + + static func dateKey(for date: Date, calendar: Calendar) -> String { + keyFormatter(calendar).string(from: calendar.startOfDay(for: date)) + } + + static func monthKey(for date: Date, calendar: Calendar) -> String { + let f = keyFormatter(calendar); f.dateFormat = "yyyy-MM" + return f.string(from: date) + } + + static func weekInterval(containing date: Date, calendar: Calendar) -> DateInterval? { + calendar.dateInterval(of: .weekOfYear, for: date) + } + + static func monthInterval(containing date: Date, calendar: Calendar) -> DateInterval? { + calendar.dateInterval(of: .month, for: date) + } + + /// The same weekday one week earlier — used for the day-vs-last-week compare. + static func sameWeekdayLastWeek(_ date: Date, calendar: Calendar) -> Date? { + calendar.date(byAdding: .weekOfYear, value: -1, to: date) + } + + /// Whether `date` falls in a completed period (strictly before the period + /// containing `now`) — reports are only generated for completed periods. + static func isCompletedWeek(_ date: Date, now: Date, calendar: Calendar) -> Bool { + guard let w = weekInterval(containing: date, calendar: calendar) else { return false } + return w.end <= (weekInterval(containing: now, calendar: calendar)?.start ?? now) + } + + static func isCompletedMonth(_ date: Date, now: Date, calendar: Calendar) -> Bool { + guard let m = monthInterval(containing: date, calendar: calendar) else { return false } + return m.end <= (monthInterval(containing: now, calendar: calendar)?.start ?? now) + } + + // MARK: - Retention + + /// Keys older than `months` before `now` (for safe pruning of raw data). + static func expiredDayKeys(_ keys: [String], now: Date, months: Int = 12, calendar: Calendar) -> [String] { + guard let cutoff = calendar.date(byAdding: .month, value: -months, to: calendar.startOfDay(for: now)) else { return [] } + let cutoffKey = dateKey(for: cutoff, calendar: calendar) + return keys.filter { $0 < cutoffKey } + } +} diff --git a/KisaniCal/Analytics/AnalyticsModels.swift b/KisaniCal/Analytics/AnalyticsModels.swift new file mode 100644 index 0000000..3a284b3 --- /dev/null +++ b/KisaniCal/Analytics/AnalyticsModels.swift @@ -0,0 +1,154 @@ +import Foundation + +// Pure value types for the analytics system. No SwiftUI, no persistence — these +// are the inputs and outputs of `AnalyticsEngine`, kept decoupled from app +// models so the engine is deterministic and unit-testable in isolation. +// Adapters from WorkoutDayLog / HealthKit live in the persistence layer. + +// MARK: - Direction & trend + +/// Semantic direction of a change. Paired in UI with a non-color glyph so status +/// is never conveyed by color alone. +enum MetricDirection: String, Codable, Equatable { + case up, down, flat, none +} + +enum TrendClassification: String, Codable, Equatable { + case improving, declining, plateau, insufficientData +} + +struct TrendResult: Codable, Equatable { + let classification: TrendClassification + let slope: Double // least-squares slope, units per period + let points: Int +} + +// MARK: - Unit compatibility + +/// Metrics are only comparable within the same physical dimension. Prevents +/// nonsense like comparing kilograms to minutes. +enum MetricUnit: String, Codable, Equatable { + case kilograms, pounds, count, minutes, kilometers, miles, kilocalories, bpm, percent, none + + var dimension: String { + switch self { + case .kilograms, .pounds: return "mass" + case .kilometers, .miles: return "distance" + case .minutes: return "time" + case .kilocalories: return "energy" + case .bpm: return "rate" + case .count: return "count" + case .percent: return "ratio" + case .none: return "none" + } + } + + func isComparable(with other: MetricUnit) -> Bool { dimension == other.dimension } +} + +// MARK: - Delta + +/// One metric compared against a baseline. `percent` is nil when there is no +/// baseline, the baseline is too small to be meaningful, or the comparison is +/// otherwise not honest (new item / rest day). `meaningful` gates UI display. +struct MetricDelta: Codable, Equatable { + let current: Double + let baseline: Double? + let absolute: Double? + let percent: Double? + let direction: MetricDirection + let meaningful: Bool +} + +// MARK: - Samples (engine inputs) + +/// A single exercise's contribution on a day, keyed by a normalized identity. +struct ExerciseSample: Codable, Equatable { + let identity: String // normalized identity key (see AnalyticsEngine.exerciseIdentity) + let sets: Int + let reps: Int // total reps across completed sets + let volume: Double // Σ weight×reps of completed sets, kg +} + +/// A day of activity: app workout record + optional HealthKit reference values. +/// HealthKit fields are optional because authorization/data may be missing. +struct DaySample: Codable, Equatable { + let dateKey: String // "yyyy-MM-dd" in the reporting calendar + let didWorkout: Bool + let scheduled: Bool + let sessions: Int + let sets: Int + let volume: Double + let durationMinutes: Double + let exercises: [ExerciseSample] + // HealthKit reference (source of truth for health measurements) — may be nil. + let steps: Int? + let activeCalories: Int? + let restingHeartRate: Int? + + init(dateKey: String, didWorkout: Bool = false, scheduled: Bool = false, + sessions: Int = 0, sets: Int = 0, volume: Double = 0, durationMinutes: Double = 0, + exercises: [ExerciseSample] = [], steps: Int? = nil, + activeCalories: Int? = nil, restingHeartRate: Int? = nil) { + self.dateKey = dateKey + self.didWorkout = didWorkout + self.scheduled = scheduled + self.sessions = sessions + self.sets = sets + self.volume = volume + self.durationMinutes = durationMinutes + self.exercises = exercises + self.steps = steps + self.activeCalories = activeCalories + self.restingHeartRate = restingHeartRate + } +} + +// MARK: - Comparisons & reports (engine outputs; immutable snapshots) + +struct ExerciseComparison: Codable, Equatable { + let identity: String + let volume: MetricDelta + let sets: MetricDelta + let reps: MetricDelta + let isNew: Bool +} + +struct DailyComparison: Codable, Equatable { + let dateKey: String + let restDay: Bool + let vsPreviousDay: [ExerciseComparison] + let vsSameWeekdayLastWeek: [ExerciseComparison] +} + +struct PeriodSummary: Codable, Equatable { + let sessions: Int + let sets: Int + let volume: Double + let durationMinutes: Double + let scheduled: Int + let missed: Int + let consistency: Double // 0...1 +} + +/// Immutable weekly report. Once generated with `generatedAt`, later edits to +/// source data never change it (the persistence layer stores it frozen). +struct WeeklyReport: Codable, Equatable, Identifiable { + let weekStartKey: String // "yyyy-MM-dd" of the week's first day + let generatedAt: Date + let summary: PeriodSummary + let vsPreviousWeek: [String: MetricDelta] + let progression: TrendClassification + let disclaimer: String + var id: String { weekStartKey } +} + +struct MonthlyReport: Codable, Equatable, Identifiable { + let monthKey: String // "yyyy-MM" + let generatedAt: Date + let summary: PeriodSummary + let vsPreviousMonth: [String: MetricDelta] + let classification: TrendClassification + let disclaimer: String + var id: String { monthKey } +} diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 9b8203c..ff99e9c 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -1533,3 +1533,17 @@ Files: `CalendarView.swift`. Awaiting decision on persistence backend / deployment target, then implement the pure `AnalyticsEngine` + models + tests first (most inspection-verifiable), UI last. Device/build QA tracked here. + +### KC-51 progress — Phase 1: analysis engine (DONE, engine-verified) +- Added pure, deterministic `AnalyticsEngine` + value-type models + (`AnalyticsModels.swift`) — no SwiftUI/persistence/HealthKit deps. +- Formulas documented in-source: volume, %change (guarded), consistency, trend. +- Honest degradation: nil/zero/tiny baseline → no %; new exercise / rest day → + not meaningful; multi-point trend vs least-squares slope; winsorized outliers; + unit-dimension compatibility; divide-by-zero guarded. +- Calendar helpers take an injected `Calendar` → locale/firstWeekday/TZ/DST-safe. +- `AnalyticsEngineTests.swift` (35 cases). **Verified independently**: compiled + with `swiftc` and ran a 42-assertion harness → all pass (Xcode test target not + runnable here — no sim runtime; same assertions live in the XCTest file). +- Next: Phase 2 file-based persistence (report/record stores), adapters from + `WorkoutDayLog` + HealthKit, reconciliation, retention, dedup — with tests. diff --git a/KisaniCalTests/AnalyticsEngineTests.swift b/KisaniCalTests/AnalyticsEngineTests.swift new file mode 100644 index 0000000..022f461 --- /dev/null +++ b/KisaniCalTests/AnalyticsEngineTests.swift @@ -0,0 +1,258 @@ +import XCTest +@testable import KisaniCal + +/// Deterministic tests for the pure analytics engine — formulas, honest-degradation, +/// trend classification, and calendar/timezone/DST/firstWeekday boundary behavior. +final class AnalyticsEngineTests: XCTestCase { + + // MARK: - Delta / percentage change + + func testDeltaNormal() { + let d = AnalyticsEngine.delta(current: 120, baseline: 100) + XCTAssertEqual(d.absolute, 20) + XCTAssertEqual(d.percent!, 20, accuracy: 0.0001) + XCTAssertEqual(d.direction, .up) + XCTAssertTrue(d.meaningful) + } + + func testDeltaDecrease() { + let d = AnalyticsEngine.delta(current: 80, baseline: 100) + XCTAssertEqual(d.percent!, -20, accuracy: 0.0001) + XCTAssertEqual(d.direction, .down) + } + + func testDeltaNoBaselineIsNotMeaningful() { + let d = AnalyticsEngine.delta(current: 50, baseline: nil) + XCTAssertNil(d.percent) + XCTAssertNil(d.absolute) + XCTAssertFalse(d.meaningful) + } + + func testDeltaZeroBaselineNoPercent() { + // Divide-by-zero guard: zero baseline yields an absolute but no percent. + let d = AnalyticsEngine.delta(current: 30, baseline: 0) + XCTAssertEqual(d.absolute, 30) + XCTAssertNil(d.percent) + XCTAssertFalse(d.meaningful) + } + + func testDeltaTinyBaselineNoPercent() { + let d = AnalyticsEngine.delta(current: 10, baseline: 0.00001) + XCTAssertNil(d.percent, "percentage against a negligible baseline is suppressed") + } + + func testDeltaNewExercise() { + let d = AnalyticsEngine.delta(current: 500, baseline: 400, isNew: true) + XCTAssertNil(d.percent) + XCTAssertFalse(d.meaningful) + } + + func testDeltaRestDayBaseline() { + let d = AnalyticsEngine.delta(current: 500, baseline: 400, restDay: true) + XCTAssertFalse(d.meaningful) + } + + // MARK: - Consistency + + func testConsistencyNormal() { + XCTAssertEqual(AnalyticsEngine.consistency(sessions: 4, scheduled: 5), 0.8, accuracy: 0.0001) + } + + func testConsistencyZeroScheduled() { + XCTAssertEqual(AnalyticsEngine.consistency(sessions: 3, scheduled: 0), 0) + } + + func testConsistencyCappedAtOne() { + XCTAssertEqual(AnalyticsEngine.consistency(sessions: 6, scheduled: 5), 1.0) + } + + // MARK: - Volume + + func testVolume() { + XCTAssertEqual(AnalyticsEngine.volume(weight: 60, reps: 10), 600) + } + + // MARK: - Trend classification (multi-point) + + func testTrendImproving() { + XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 110, 120, 130]).classification, .improving) + } + + func testTrendDeclining() { + XCTAssertEqual(AnalyticsEngine.classifyTrend([130, 120, 110, 100]).classification, .declining) + } + + func testTrendPlateau() { + XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 101, 99, 100]).classification, .plateau) + } + + func testTrendInsufficientData() { + XCTAssertEqual(AnalyticsEngine.classifyTrend([100, 110]).classification, .insufficientData) + } + + func testTrendAllZeroIsPlateau() { + XCTAssertEqual(AnalyticsEngine.classifyTrend([0, 0, 0, 0]).classification, .plateau) + } + + // MARK: - classifyChange (week/month over period) + + func testClassifyChangeImproving() { + let d = AnalyticsEngine.delta(current: 120, baseline: 100) + XCTAssertEqual(AnalyticsEngine.classifyChange(d), .improving) + } + + func testClassifyChangePlateau() { + let d = AnalyticsEngine.delta(current: 101, baseline: 100) + XCTAssertEqual(AnalyticsEngine.classifyChange(d), .plateau) + } + + func testClassifyChangeInsufficient() { + let d = AnalyticsEngine.delta(current: 100, baseline: nil) + XCTAssertEqual(AnalyticsEngine.classifyChange(d), .insufficientData) + } + + // MARK: - Outliers + + func testWinsorizeClampsExtremes() { + let w = AnalyticsEngine.winsorize([1, 2, 3, 4, 5, 6, 7, 8, 9, 1000]) + XCTAssertLessThan(w.max()!, 1000, "extreme high value should be clamped") + } + + func testWinsorizeSmallSampleNoop() { + let input = [1.0, 999.0] + XCTAssertEqual(AnalyticsEngine.winsorize(input), input) + } + + // MARK: - Exercise identity & comparison + + func testExerciseIdentityNormalizes() { + XCTAssertEqual(AnalyticsEngine.exerciseIdentity(" Bench Press "), + AnalyticsEngine.exerciseIdentity("bench press")) + } + + func testCompareExercisesMarksNew() { + let cur = [ExerciseSample(identity: "squat", sets: 3, reps: 15, volume: 900), + ExerciseSample(identity: "curl", sets: 3, reps: 30, volume: 300)] + let base = [ExerciseSample(identity: "squat", sets: 3, reps: 12, volume: 720)] + let comps = AnalyticsEngine.compareExercises(current: cur, baseline: base) + let squat = comps.first { $0.identity == "squat" }! + let curl = comps.first { $0.identity == "curl" }! + XCTAssertFalse(squat.isNew) + XCTAssertEqual(squat.volume.percent!, 25, accuracy: 0.0001) // 900 vs 720 + XCTAssertTrue(curl.isNew) + XCTAssertNil(curl.volume.percent) + } + + // MARK: - Unit compatibility + + func testUnitComparability() { + XCTAssertTrue(MetricUnit.kilograms.isComparable(with: .pounds)) + XCTAssertFalse(MetricUnit.kilograms.isComparable(with: .minutes)) + XCTAssertTrue(MetricUnit.kilometers.isComparable(with: .miles)) + XCTAssertFalse(MetricUnit.bpm.isComparable(with: .kilocalories)) + } + + // MARK: - Period summary + + func testSummarizeCountsSessionsAndMissed() { + let days = [ + DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000, durationMinutes: 60), + DaySample(dateKey: "2026-07-07", didWorkout: false, scheduled: true), // missed + DaySample(dateKey: "2026-07-08", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 900, durationMinutes: 55), + DaySample(dateKey: "2026-07-09", didWorkout: false, scheduled: false), // rest + ] + let s = AnalyticsEngine.summarize(days) + XCTAssertEqual(s.sessions, 2) + XCTAssertEqual(s.scheduled, 3) + XCTAssertEqual(s.missed, 1) + XCTAssertEqual(s.sets, 38) + XCTAssertEqual(s.volume, 1900) + XCTAssertEqual(s.consistency, 2.0/3.0, accuracy: 0.0001) + } + + // MARK: - Reports (idempotency / determinism) + + func testWeeklyReportDeterministic() { + let gen = Date(timeIntervalSince1970: 1_780_000_000) + let days = [DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)] + let prev = [DaySample(dateKey: "2026-06-29", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)] + let r1 = AnalyticsEngine.weeklyReport(weekStartKey: "2026-07-06", days: days, previousWeekDays: prev, generatedAt: gen) + let r2 = AnalyticsEngine.weeklyReport(weekStartKey: "2026-07-06", days: days, previousWeekDays: prev, generatedAt: gen) + XCTAssertEqual(r1, r2, "same inputs → identical report (idempotent)") + XCTAssertEqual(r1.vsPreviousWeek["volume"]!.percent!, 25, accuracy: 0.0001) + XCTAssertEqual(r1.progression, .improving) + XCTAssertFalse(r1.disclaimer.isEmpty) + } + + func testMonthlyReportInsufficientWhenNoPrevious() { + let days = [DaySample(dateKey: "2026-07-06", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)] + let r = AnalyticsEngine.monthlyReport(monthKey: "2026-07", days: days, previousMonthDays: [], generatedAt: Date()) + // previous volume is 0 → no meaningful %, classification insufficient. + XCTAssertEqual(r.classification, .insufficientData) + } + + // MARK: - Calendar / timezone / firstWeekday / DST boundaries + + private func calendar(tz: String, firstWeekday: Int = 1) -> Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: tz)! + c.firstWeekday = firstWeekday + c.locale = Locale(identifier: "en_US_POSIX") + return c + } + + func testDateKeyRespectsTimezone() { + // 2026-07-07T02:30Z is still 2026-07-06 (22:30) in New York (UTC-4). + let instant = Date(timeIntervalSince1970: 1_783_391_400) // 2026-07-07T02:30:00Z + let utc = AnalyticsEngine.dateKey(for: instant, calendar: calendar(tz: "UTC")) + let ny = AnalyticsEngine.dateKey(for: instant, calendar: calendar(tz: "America/New_York")) + XCTAssertEqual(utc, "2026-07-07") + XCTAssertEqual(ny, "2026-07-06") + } + + func testFirstWeekdayChangesWeekStart() { + let date = Date(timeIntervalSince1970: 1_783_500_000) // a Tuesday + let sundayFirst = calendar(tz: "UTC", firstWeekday: 1) + let mondayFirst = calendar(tz: "UTC", firstWeekday: 2) + let wSun = AnalyticsEngine.weekInterval(containing: date, calendar: sundayFirst)! + let wMon = AnalyticsEngine.weekInterval(containing: date, calendar: mondayFirst)! + XCTAssertNotEqual(wSun.start, wMon.start, "week start differs by firstWeekday") + } + + func testDSTSpringForwardDayKeyStable() { + // US DST spring-forward 2026-03-08. The 23-hour day must still key correctly. + let cal = calendar(tz: "America/New_York") + var comps = DateComponents(); comps.year = 2026; comps.month = 3; comps.day = 8; comps.hour = 12 + let noon = cal.date(from: comps)! + XCTAssertEqual(AnalyticsEngine.dateKey(for: noon, calendar: cal), "2026-03-08") + // start-of-day is well-defined despite the missing hour + let interval = AnalyticsEngine.weekInterval(containing: noon, calendar: cal) + XCTAssertNotNil(interval) + } + + func testMonthKey() { + let cal = calendar(tz: "UTC") + let d = Date(timeIntervalSince1970: 1_783_500_000) + XCTAssertEqual(AnalyticsEngine.monthKey(for: d, calendar: cal), "2026-07") + } + + // MARK: - Completed-period gating & retention + + func testCompletedWeekGating() { + let cal = calendar(tz: "UTC") + let now = Date(timeIntervalSince1970: 1_783_500_000) // "this week" + let lastWeek = cal.date(byAdding: .weekOfYear, value: -1, to: now)! + XCTAssertTrue(AnalyticsEngine.isCompletedWeek(lastWeek, now: now, calendar: cal)) + XCTAssertFalse(AnalyticsEngine.isCompletedWeek(now, now: now, calendar: cal)) + } + + func testRetentionExpiry() { + let cal = calendar(tz: "UTC") + let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07 + let old = AnalyticsEngine.dateKey(for: cal.date(byAdding: .month, value: -13, to: now)!, calendar: cal) + let recent = AnalyticsEngine.dateKey(for: cal.date(byAdding: .month, value: -1, to: now)!, calendar: cal) + let expired = AnalyticsEngine.expiredDayKeys([old, recent], now: now, months: 12, calendar: cal) + XCTAssertTrue(expired.contains(old)) + XCTAssertFalse(expired.contains(recent)) + } +}