Merge develop -> main: full session (KC-51 through KC-71) #28

Merged
kutesir merged 64 commits from develop into main 2026-07-12 22:57:14 +00:00
8 changed files with 395 additions and 1 deletions
Showing only changes of commit 065066c8f7 - Show all commits

View File

@@ -7,6 +7,7 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */; };
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; }; 096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; };
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.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 */; }; A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; };
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; }; A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; };
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.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 */; }; BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */; };
BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.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 */; }; 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 */; };
@@ -62,6 +66,7 @@
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 */; };
FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -123,11 +128,13 @@
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>"; }; 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>"; }; 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>"; };
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; };
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>"; }; 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>"; };
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>"; }; 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>"; }; 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>"; }; FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; };
@@ -163,6 +173,8 @@
068B77B2F01C399C7A430292 /* KisaniCalTests */ = { 068B77B2F01C399C7A430292 /* KisaniCalTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */,
A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */,
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */, 9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */,
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */, 3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */,
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */, 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
@@ -256,9 +268,12 @@
C7409CC354029293D424BEDA /* Analytics */ = { C7409CC354029293D424BEDA /* Analytics */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D4FB8B5BFF3FF62728E9498A /* AnalyticsCoordinator.swift */,
7A32192C8620C6AB8499869F /* AnalyticsDeepLink.swift */,
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */, 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */,
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */, 0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */,
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */, 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */,
E839E4F5E6272515C3EA14D7 /* WorkoutAnalyticsAdapter.swift */,
); );
path = Analytics; path = Analytics;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -465,6 +480,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
B0B83389E72D6DF4563D3DD4 /* AnalyticsAdapterTests.swift in Sources */,
FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */,
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */, 550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */,
87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */, 87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */,
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */, 6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
@@ -488,6 +505,8 @@
files = ( files = (
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */, D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */,
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */, A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
023A636FF533304DA5578D1C /* AnalyticsCoordinator.swift in Sources */,
B39F302F10FA4899AA0A5BAC /* AnalyticsDeepLink.swift in Sources */,
152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */, 152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */,
42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */, 42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */,
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */, E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */,
@@ -520,6 +539,7 @@
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */, C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */, 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */, 8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */,
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */, 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View 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() }
}

View 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)
}
}

View File

@@ -288,14 +288,19 @@ enum AnalyticsEngine {
// MARK: - Reconciliation selection (which completed periods need a report) // MARK: - Reconciliation selection (which completed periods need a report)
/// Week-start keys of every completed week within the last `months`, oldestnewest. /// Week-start keys of every completed week within the last `months`, oldestnewest.
/// 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] { static func completedWeekStartKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
guard let windowStart = cal.date(byAdding: .month, value: -months, to: now), guard let windowStart = cal.date(byAdding: .month, value: -months, to: now),
let thisWeek = weekInterval(containing: now, calendar: cal), let thisWeek = weekInterval(containing: now, calendar: cal),
var cursor = weekInterval(containing: windowStart, calendar: cal)?.start else { return [] } var cursor = weekInterval(containing: windowStart, calendar: cal)?.start else { return [] }
let cutoffKey = dateKey(for: cal.startOfDay(for: windowStart), calendar: cal)
var keys: [String] = [] var keys: [String] = []
var guardCount = 0 var guardCount = 0
while cursor < thisWeek.start, guardCount < 70 { 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 } guard let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor) else { break }
cursor = next; guardCount += 1 cursor = next; guardCount += 1
} }

View 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)
}
}

View File

@@ -1561,3 +1561,33 @@ last. Device/build QA tracked here.
DaySample), reconcile coordinator on launch/active, notifications + deep links. DaySample), reconcile coordinator on launch/active, notifications + deep links.
Phase 4: Analytics UI (6 areas + states + accessibility). Both require an Xcode Phase 4: Analytics UI (6 areas + states + accessibility). Both require an Xcode
build / device to verify (no sim runtime here). 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.

View 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
}
}

View 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
}
}