Merge develop -> main: full session (KC-51 through KC-71) #28
@@ -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;
|
||||||
|
|||||||
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)
|
// MARK: - Reconciliation selection (which completed periods need a report)
|
||||||
|
|
||||||
/// Week-start keys of every completed week within the last `months`, oldest→newest.
|
/// 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] {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
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.
|
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.
|
||||||
|
|||||||
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