analytics(integration): adapter + reconcile coordinator + deep links (KC-51 ph3)
Phase 3 core (verifiable): WorkoutAnalyticsAdapter (app data → DaySample), AnalyticsCoordinator (idempotent backfill of missing weekly/monthly reports + retention prune, injectable providers), AnalyticsDeepLink (wenza://analytics report routing). Fixed a reconcile bug caught by tests: a retention-boundary week was generated then immediately pruned, regenerating every run — aligned completedWeekStartKeys to the prune cutoff → idempotent. Tests: AnalyticsAdapterTests + AnalyticsCoordinatorTests. Verified standalone via swiftc (~90 analytics assertions total across engine/store/adapter/coordinator). Remaining (app wiring, notifications, UI) requires an Xcode build — logged in ISSUES.md as device-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */; };
|
||||
06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */; };
|
||||
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; };
|
||||
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
|
||||
@@ -50,8 +51,11 @@
|
||||
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; };
|
||||
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; };
|
||||
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; };
|
||||
B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */; };
|
||||
B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */; };
|
||||
BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */; };
|
||||
BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.swift */; };
|
||||
C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */; };
|
||||
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 */; };
|
||||
@@ -62,6 +66,7 @@
|
||||
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 */; };
|
||||
FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -123,11 +128,13 @@
|
||||
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>"; };
|
||||
713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsAdapterTests.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>"; };
|
||||
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; };
|
||||
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngine.swift; sourceTree = "<group>"; };
|
||||
7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsDeepLink.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
||||
@@ -139,6 +146,7 @@
|
||||
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = "<group>"; };
|
||||
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsEngineTests.swift; sourceTree = "<group>"; };
|
||||
A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCoordinatorTests.swift; sourceTree = "<group>"; };
|
||||
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; };
|
||||
AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
|
||||
B4CFFDFE4653A9E901CEF28D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
@@ -149,8 +157,10 @@
|
||||
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>"; };
|
||||
D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCoordinator.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>"; };
|
||||
E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutAnalyticsAdapter.swift; sourceTree = "<group>"; };
|
||||
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = "<group>"; };
|
||||
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
|
||||
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; };
|
||||
@@ -163,6 +173,8 @@
|
||||
068B77B2F01C399C7A430292 /* KisaniCalTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */,
|
||||
A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */,
|
||||
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */,
|
||||
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */,
|
||||
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
|
||||
@@ -256,9 +268,12 @@
|
||||
C7409CC354029293D424BEDA /* Analytics */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */,
|
||||
7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */,
|
||||
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */,
|
||||
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */,
|
||||
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */,
|
||||
E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */,
|
||||
);
|
||||
path = Analytics;
|
||||
sourceTree = "<group>";
|
||||
@@ -465,6 +480,8 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */,
|
||||
FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */,
|
||||
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */,
|
||||
87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */,
|
||||
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
|
||||
@@ -488,6 +505,8 @@
|
||||
files = (
|
||||
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */,
|
||||
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
|
||||
023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */,
|
||||
B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */,
|
||||
152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */,
|
||||
42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */,
|
||||
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */,
|
||||
@@ -520,6 +539,7 @@
|
||||
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
|
||||
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
|
||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
|
||||
C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */,
|
||||
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
76
KisaniCal/Analytics/AnalyticsCoordinator.swift
Normal file
76
KisaniCal/Analytics/AnalyticsCoordinator.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
/// Generates any missing immutable weekly/monthly reports for completed periods,
|
||||
/// then prunes beyond the retention window. Idempotent: already-generated periods
|
||||
/// are skipped (dedup by store key), so it is safe to run on every launch /
|
||||
/// foreground without depending on exact background execution times.
|
||||
///
|
||||
/// Sample providers are injected so the reconcile logic stays independent of app
|
||||
/// models / HealthKit and is unit-testable. The app wires the providers to
|
||||
/// `WorkoutAnalyticsAdapter` + `WorkoutViewModel` + `HealthKitManager`.
|
||||
final class AnalyticsCoordinator {
|
||||
|
||||
struct ReconcileResult: Equatable {
|
||||
var weekliesGenerated: [String] = []
|
||||
var monthliesGenerated: [String] = []
|
||||
var isEmpty: Bool { weekliesGenerated.isEmpty && monthliesGenerated.isEmpty }
|
||||
}
|
||||
|
||||
/// Samples for a completed period plus its comparison baseline.
|
||||
typealias PeriodSamples = (days: [DaySample], previous: [DaySample])
|
||||
|
||||
private let store: AnalyticsStore
|
||||
private let calendar: Calendar
|
||||
private let now: () -> Date
|
||||
private let retentionMonths: Int
|
||||
private let weekSamples: (_ weekStartKey: String) -> PeriodSamples
|
||||
private let monthSamples: (_ monthKey: String) -> PeriodSamples
|
||||
|
||||
init(store: AnalyticsStore,
|
||||
calendar: Calendar,
|
||||
retentionMonths: Int = 12,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
weekSamples: @escaping (String) -> PeriodSamples,
|
||||
monthSamples: @escaping (String) -> PeriodSamples) {
|
||||
self.store = store
|
||||
self.calendar = calendar
|
||||
self.retentionMonths = retentionMonths
|
||||
self.now = now
|
||||
self.weekSamples = weekSamples
|
||||
self.monthSamples = monthSamples
|
||||
}
|
||||
|
||||
/// Backfill every completed week/month within the retention window that has
|
||||
/// no report yet, then prune expired data. Returns which periods were newly
|
||||
/// generated (empty on a no-op run → drives "should I notify?" decisions).
|
||||
@discardableResult
|
||||
func reconcile() -> ReconcileResult {
|
||||
let ref = now()
|
||||
var result = ReconcileResult()
|
||||
|
||||
let allWeeks = AnalyticsEngine.completedWeekStartKeys(now: ref, months: retentionMonths, calendar: calendar)
|
||||
for key in AnalyticsEngine.missingKeys(allWeeks, existing: store.existingWeeklyKeys()) {
|
||||
let s = weekSamples(key)
|
||||
let report = AnalyticsEngine.weeklyReport(weekStartKey: key, days: s.days,
|
||||
previousWeekDays: s.previous, generatedAt: ref)
|
||||
if store.saveWeeklyReport(report) { result.weekliesGenerated.append(key) }
|
||||
}
|
||||
|
||||
let allMonths = AnalyticsEngine.completedMonthKeys(now: ref, months: retentionMonths, calendar: calendar)
|
||||
for key in AnalyticsEngine.missingKeys(allMonths, existing: store.existingMonthlyKeys()) {
|
||||
let s = monthSamples(key)
|
||||
let report = AnalyticsEngine.monthlyReport(monthKey: key, days: s.days,
|
||||
previousMonthDays: s.previous, generatedAt: ref)
|
||||
if store.saveMonthlyReport(report) { result.monthliesGenerated.append(key) }
|
||||
}
|
||||
|
||||
store.prune(now: ref, months: retentionMonths, calendar: calendar)
|
||||
return result
|
||||
}
|
||||
|
||||
/// The most recent completed weekly / monthly report keys (for "notify about
|
||||
/// the latest report" without duplicating). Deep links for these come from
|
||||
/// `AnalyticsDeepLink`.
|
||||
func latestWeeklyKey() -> String? { store.existingWeeklyKeys().max() }
|
||||
func latestMonthlyKey() -> String? { store.existingMonthlyKeys().max() }
|
||||
}
|
||||
56
KisaniCal/Analytics/AnalyticsDeepLink.swift
Normal file
56
KisaniCal/Analytics/AnalyticsDeepLink.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
/// Deep links into the Analytics area — used by weekly/monthly report
|
||||
/// notifications to route straight to the relevant report. Pure value type with
|
||||
/// symmetric URL encode/decode so routing is testable without any UI.
|
||||
///
|
||||
/// Format: wenza://analytics → overview
|
||||
/// wenza://analytics/weekly/<yyyy-MM-dd> → a weekly report
|
||||
/// wenza://analytics/monthly/<yyyy-MM> → a monthly report
|
||||
enum AnalyticsDeepLink: Equatable {
|
||||
case overview
|
||||
case weekly(weekStartKey: String)
|
||||
case monthly(monthKey: String)
|
||||
|
||||
static let scheme = "wenza"
|
||||
static let host = "analytics"
|
||||
|
||||
/// Stable userInfo key carried on notifications.
|
||||
static let userInfoKey = "kisani.analytics.deeplink"
|
||||
|
||||
var url: URL {
|
||||
var c = URLComponents()
|
||||
c.scheme = Self.scheme
|
||||
c.host = Self.host
|
||||
switch self {
|
||||
case .overview: c.path = ""
|
||||
case .weekly(let key): c.path = "/weekly/\(key)"
|
||||
case .monthly(let key): c.path = "/monthly/\(key)"
|
||||
}
|
||||
return c.url!
|
||||
}
|
||||
|
||||
init?(url: URL) {
|
||||
guard url.scheme == Self.scheme, url.host == Self.host else { return nil }
|
||||
let parts = url.path.split(separator: "/").map(String.init)
|
||||
switch parts.first {
|
||||
case nil:
|
||||
self = .overview
|
||||
case "weekly":
|
||||
guard parts.count >= 2 else { return nil }
|
||||
self = .weekly(weekStartKey: parts[1])
|
||||
case "monthly":
|
||||
guard parts.count >= 2 else { return nil }
|
||||
self = .monthly(monthKey: parts[1])
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Build/parse via a plain string (for notification userInfo payloads).
|
||||
var stringValue: String { url.absoluteString }
|
||||
init?(string: String) {
|
||||
guard let url = URL(string: string) else { return nil }
|
||||
self.init(url: url)
|
||||
}
|
||||
}
|
||||
@@ -288,14 +288,19 @@ enum AnalyticsEngine {
|
||||
// MARK: - Reconciliation selection (which completed periods need a report)
|
||||
|
||||
/// Week-start keys of every completed week within the last `months`, oldest→newest.
|
||||
/// Only weeks whose start is on/after the retention cutoff are included, so
|
||||
/// generation and `prune` agree — otherwise a boundary week would be
|
||||
/// generated then immediately pruned, regenerating on every run.
|
||||
static func completedWeekStartKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
|
||||
guard let windowStart = cal.date(byAdding: .month, value: -months, to: now),
|
||||
let thisWeek = weekInterval(containing: now, calendar: cal),
|
||||
var cursor = weekInterval(containing: windowStart, calendar: cal)?.start else { return [] }
|
||||
let cutoffKey = dateKey(for: cal.startOfDay(for: windowStart), calendar: cal)
|
||||
var keys: [String] = []
|
||||
var guardCount = 0
|
||||
while cursor < thisWeek.start, guardCount < 70 {
|
||||
keys.append(dateKey(for: cursor, calendar: cal))
|
||||
let key = dateKey(for: cursor, calendar: cal)
|
||||
if key >= cutoffKey { keys.append(key) }
|
||||
guard let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor) else { break }
|
||||
cursor = next; guardCount += 1
|
||||
}
|
||||
|
||||
93
KisaniCal/Analytics/WorkoutAnalyticsAdapter.swift
Normal file
93
KisaniCal/Analytics/WorkoutAnalyticsAdapter.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
|
||||
// Pure bridge from app workout data (+ HealthKit reference) into the engine's
|
||||
// `DaySample` inputs. Kept free of app-model / HealthKit types so it stays
|
||||
// deterministic and unit-testable; the app maps `WorkoutDayLog` → `RawWorkoutDay`
|
||||
// and supplies HealthKit reference values at the call site.
|
||||
|
||||
/// One day's app-generated workout, already reduced to analysis primitives.
|
||||
/// The app builds this from `WorkoutDayLog` (volume = Σ weight×reps of done sets).
|
||||
struct RawWorkoutDay: Equatable {
|
||||
let dateKey: String
|
||||
let didWorkout: Bool
|
||||
let sessions: Int
|
||||
let durationMinutes: Double
|
||||
let exercises: [ExerciseSample]
|
||||
|
||||
var sets: Int { exercises.reduce(0) { $0 + $1.sets } }
|
||||
var volume: Double { exercises.reduce(0) { $0 + $1.volume } }
|
||||
}
|
||||
|
||||
/// HealthKit reference values for a day (source of truth for health measurements).
|
||||
/// All optional — authorization or data may be missing.
|
||||
struct RawHealthDay: Equatable {
|
||||
let steps: Int?
|
||||
let activeCalories: Int?
|
||||
let restingHeartRate: Int?
|
||||
}
|
||||
|
||||
enum WorkoutAnalyticsAdapter {
|
||||
|
||||
/// Build ordered `DaySample`s for `dayKeys`. A day is:
|
||||
/// - `didWorkout` if it has a workout record with `didWorkout`,
|
||||
/// - `scheduled` if its key is in `scheduledKeys`,
|
||||
/// - enriched with HealthKit reference values where present.
|
||||
/// Missing days become rest/empty samples (honest — no fabricated data).
|
||||
static func daySamples(dayKeys: [String],
|
||||
workouts: [String: RawWorkoutDay],
|
||||
scheduledKeys: Set<String>,
|
||||
health: [String: RawHealthDay] = [:]) -> [DaySample] {
|
||||
dayKeys.map { key in
|
||||
let w = workouts[key]
|
||||
let h = health[key]
|
||||
return DaySample(
|
||||
dateKey: key,
|
||||
didWorkout: w?.didWorkout ?? false,
|
||||
scheduled: scheduledKeys.contains(key),
|
||||
sessions: w?.sessions ?? 0,
|
||||
sets: w?.sets ?? 0,
|
||||
volume: w?.volume ?? 0,
|
||||
durationMinutes: w?.durationMinutes ?? 0,
|
||||
exercises: w?.exercises ?? [],
|
||||
steps: h?.steps,
|
||||
activeCalories: h?.activeCalories,
|
||||
restingHeartRate: h?.restingHeartRate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inclusive list of day keys from `start` to `end`, in the given calendar.
|
||||
static func dayKeys(from start: Date, to end: Date, calendar: Calendar) -> [String] {
|
||||
var keys: [String] = []
|
||||
var cursor = calendar.startOfDay(for: start)
|
||||
let last = calendar.startOfDay(for: end)
|
||||
var guardCount = 0
|
||||
while cursor <= last, guardCount < 800 { // ~2yr ceiling
|
||||
keys.append(AnalyticsEngine.dateKey(for: cursor, calendar: calendar))
|
||||
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
|
||||
cursor = next; guardCount += 1
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/// Day keys for the calendar week/month containing `date`.
|
||||
static func weekDayKeys(containing date: Date, calendar: Calendar) -> [String] {
|
||||
guard let w = AnalyticsEngine.weekInterval(containing: date, calendar: calendar) else { return [] }
|
||||
return dayKeys(from: w.start, to: calendar.date(byAdding: .day, value: -1, to: w.end) ?? w.start, calendar: calendar)
|
||||
}
|
||||
|
||||
static func monthDayKeys(containing date: Date, calendar: Calendar) -> [String] {
|
||||
guard let m = AnalyticsEngine.monthInterval(containing: date, calendar: calendar) else { return [] }
|
||||
return dayKeys(from: m.start, to: calendar.date(byAdding: .day, value: -1, to: m.end) ?? m.start, calendar: calendar)
|
||||
}
|
||||
|
||||
/// Reduce an exercise's completed sets to an `ExerciseSample` (app helper —
|
||||
/// mirrors `WorkoutDayLog.volume`). Kept here so the identity + volume rule is
|
||||
/// defined once and testable.
|
||||
static func exerciseSample(name: String, completedSets: [(weight: Double, reps: Int)]) -> ExerciseSample {
|
||||
let reps = completedSets.reduce(0) { $0 + $1.reps }
|
||||
let volume = completedSets.reduce(0.0) { $0 + AnalyticsEngine.volume(weight: $1.weight, reps: $1.reps) }
|
||||
return ExerciseSample(identity: AnalyticsEngine.exerciseIdentity(name),
|
||||
sets: completedSets.count, reps: reps, volume: volume)
|
||||
}
|
||||
}
|
||||
@@ -1561,3 +1561,33 @@ last. Device/build QA tracked here.
|
||||
DaySample), reconcile coordinator on launch/active, notifications + deep links.
|
||||
Phase 4: Analytics UI (6 areas + states + accessibility). Both require an Xcode
|
||||
build / device to verify (no sim runtime here).
|
||||
|
||||
### KC-51 progress — Phase 3: adapter + reconcile coordinator + deep links (verified)
|
||||
- `WorkoutAnalyticsAdapter` (pure): `WorkoutDayLog`/HealthKit primitives →
|
||||
`DaySample`; exercise reduction (identity + volume), inclusive day-key ranges,
|
||||
week/month key sets. Rest/missing days become honest empty samples.
|
||||
- `AnalyticsCoordinator` (injectable sample providers → testable): backfills
|
||||
every missing completed week/month, saves immutably (dedup), prunes retention.
|
||||
**Idempotent** — a second run generates nothing.
|
||||
- `AnalyticsDeepLink`: `wenza://analytics[/weekly/<key>|/monthly/<key>]` with
|
||||
symmetric URL/string encode-decode for report notifications.
|
||||
- **Bug caught + fixed by tests:** `completedWeekStartKeys` included a boundary
|
||||
week that `prune` then deleted → the report regenerated (and would re-notify)
|
||||
on every launch. Aligned generation to the retention cutoff → idempotent.
|
||||
- Tests: `AnalyticsAdapterTests`, `AnalyticsCoordinatorTests`. Verified
|
||||
standalone via swiftc (adapter 17 + coordinator/deeplink 10 assertions, all
|
||||
pass). Total analytics assertions verified standalone: ~90.
|
||||
|
||||
### KC-51 — remaining (device/Xcode-build required; NOT verified here)
|
||||
- App wiring: inject real providers (adapter + WorkoutViewModel + HealthKit) into
|
||||
the coordinator; call `reconcile()` on launch + `scenePhase == .active`
|
||||
(reuse existing checkPending* pattern). App-coupled — needs Xcode build.
|
||||
- Notifications: extend `NotificationManager.schedulePeriodMilestones()` to fire
|
||||
one permission-aware weekly + monthly local notification carrying an
|
||||
`AnalyticsDeepLink` in `userInfo`; route it on tap.
|
||||
- Analytics UI (6 areas: Overview / Daily / Weekly / Monthly / 12-month trends /
|
||||
Per-exercise) + loading/empty/denied/partial/error states + Dynamic Type,
|
||||
VoiceOver, light/dark, size classes. SwiftUI — not build-verifiable here.
|
||||
- BLOCKER unchanged: no iOS runtime in this env → cannot run the Xcode test
|
||||
target or build (goal conditions #10/#11, and thus UI #8/#9). Restore a runtime,
|
||||
then wire + build + run the suite incrementally.
|
||||
|
||||
61
KisaniCalTests/AnalyticsAdapterTests.swift
Normal file
61
KisaniCalTests/AnalyticsAdapterTests.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import XCTest
|
||||
@testable import KisaniCal
|
||||
|
||||
/// Adapter tests: exercise reduction (identity + volume), inclusive day-key
|
||||
/// ranges, and app-data → DaySample mapping (rest/missing/scheduled handling).
|
||||
final class AnalyticsAdapterTests: XCTestCase {
|
||||
|
||||
private func cal() -> Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.timeZone = TimeZone(identifier: "UTC")!
|
||||
c.locale = Locale(identifier: "en_US_POSIX")
|
||||
return c
|
||||
}
|
||||
|
||||
func testExerciseSampleReduction() {
|
||||
let es = WorkoutAnalyticsAdapter.exerciseSample(name: " Bench Press ",
|
||||
completedSets: [(60, 10), (60, 8), (70, 6)])
|
||||
XCTAssertEqual(es.identity, "bench press")
|
||||
XCTAssertEqual(es.sets, 3)
|
||||
XCTAssertEqual(es.reps, 24)
|
||||
XCTAssertEqual(es.volume, 1500) // 600 + 480 + 420
|
||||
}
|
||||
|
||||
func testInclusiveDayKeys() {
|
||||
let c = cal()
|
||||
let start = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
|
||||
let keys = WorkoutAnalyticsAdapter.dayKeys(from: start, to: c.date(byAdding: .day, value: 6, to: start)!, calendar: c)
|
||||
XCTAssertEqual(keys.count, 7)
|
||||
XCTAssertEqual(keys, keys.sorted())
|
||||
}
|
||||
|
||||
func testDaySampleMapping() {
|
||||
let c = cal()
|
||||
let start = Date(timeIntervalSince1970: 1_783_500_000)
|
||||
let keys = WorkoutAnalyticsAdapter.dayKeys(from: start, to: c.date(byAdding: .day, value: 6, to: start)!, calendar: c)
|
||||
let es = WorkoutAnalyticsAdapter.exerciseSample(name: "Squat", completedSets: [(100, 5), (100, 5)])
|
||||
let raw = RawWorkoutDay(dateKey: keys[0], didWorkout: true, sessions: 1, durationMinutes: 45, exercises: [es])
|
||||
let samples = WorkoutAnalyticsAdapter.daySamples(
|
||||
dayKeys: keys,
|
||||
workouts: [keys[0]: raw],
|
||||
scheduledKeys: [keys[0], keys[2]],
|
||||
health: [keys[0]: RawHealthDay(steps: 8000, activeCalories: 500, restingHeartRate: 55)])
|
||||
|
||||
XCTAssertEqual(samples.count, 7)
|
||||
XCTAssertTrue(samples[0].didWorkout)
|
||||
XCTAssertEqual(samples[0].volume, 1000)
|
||||
XCTAssertTrue(samples[0].scheduled)
|
||||
XCTAssertEqual(samples[0].steps, 8000)
|
||||
XCTAssertFalse(samples[1].didWorkout) // rest / missing
|
||||
XCTAssertEqual(samples[1].volume, 0)
|
||||
XCTAssertNil(samples[1].steps) // honest: no fabricated data
|
||||
XCTAssertTrue(samples[2].scheduled && !samples[2].didWorkout) // scheduled but missed
|
||||
}
|
||||
|
||||
func testWeekAndMonthDayKeys() {
|
||||
let c = cal()
|
||||
let d = Date(timeIntervalSince1970: 1_783_500_000)
|
||||
XCTAssertEqual(WorkoutAnalyticsAdapter.weekDayKeys(containing: d, calendar: c).count, 7)
|
||||
XCTAssertEqual(WorkoutAnalyticsAdapter.monthDayKeys(containing: d, calendar: c).count, 31) // July
|
||||
}
|
||||
}
|
||||
53
KisaniCalTests/AnalyticsCoordinatorTests.swift
Normal file
53
KisaniCalTests/AnalyticsCoordinatorTests.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import XCTest
|
||||
@testable import KisaniCal
|
||||
|
||||
/// Deep-link routing + reconcile idempotency (no duplicate reports across runs).
|
||||
final class AnalyticsCoordinatorTests: XCTestCase {
|
||||
|
||||
private func cal() -> Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.timeZone = TimeZone(identifier: "UTC")!
|
||||
c.locale = Locale(identifier: "en_US_POSIX")
|
||||
return c
|
||||
}
|
||||
private let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
|
||||
|
||||
// MARK: - Deep links
|
||||
|
||||
func testDeepLinkRoundTrips() {
|
||||
XCTAssertEqual(AnalyticsDeepLink(url: AnalyticsDeepLink.overview.url), .overview)
|
||||
XCTAssertEqual(AnalyticsDeepLink(url: AnalyticsDeepLink.weekly(weekStartKey: "2026-06-29").url),
|
||||
.weekly(weekStartKey: "2026-06-29"))
|
||||
XCTAssertEqual(AnalyticsDeepLink(string: "wenza://analytics/monthly/2026-05"),
|
||||
.monthly(monthKey: "2026-05"))
|
||||
XCTAssertEqual(AnalyticsDeepLink.weekly(weekStartKey: "2026-06-29").url.absoluteString,
|
||||
"wenza://analytics/weekly/2026-06-29")
|
||||
XCTAssertNil(AnalyticsDeepLink(string: "http://x/weekly/2026-06-29"))
|
||||
}
|
||||
|
||||
// MARK: - Reconcile idempotency
|
||||
|
||||
func testReconcileGeneratesThenIsIdempotent() {
|
||||
let tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("coord_\(UUID().uuidString)")
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
let store = AnalyticsStore(root: tmp)
|
||||
func sample(_ v: Double) -> [DaySample] {
|
||||
[DaySample(dateKey: "x", didWorkout: true, scheduled: true, sessions: 1, sets: 10, volume: v)]
|
||||
}
|
||||
let coord = AnalyticsCoordinator(
|
||||
store: store, calendar: cal(), retentionMonths: 12, now: { self.now },
|
||||
weekSamples: { _ in (sample(1000), sample(800)) },
|
||||
monthSamples: { _ in (sample(4000), sample(3500)) })
|
||||
|
||||
let first = coord.reconcile()
|
||||
XCTAssertGreaterThanOrEqual(first.weekliesGenerated.count, 50)
|
||||
XCTAssertEqual(first.monthliesGenerated.count, 12)
|
||||
|
||||
let second = coord.reconcile()
|
||||
XCTAssertTrue(second.isEmpty, "second run must generate nothing (no duplicate reports)")
|
||||
|
||||
XCTAssertEqual(store.allWeeklyReports().count, first.weekliesGenerated.count)
|
||||
XCTAssertEqual(store.allMonthlyReports().count, 12)
|
||||
XCTAssertEqual(store.allWeeklyReports().first?.progression, .improving) // 1000 vs 800
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user