analytics(persistence): file-based store + reconciliation selection (KC-51 ph2)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
Phase 2: AnalyticsStore (JSON files under Application Support) with immutable frozen daily snapshots + immutable, idempotent, deduped weekly/monthly reports, and 12-month retention pruning. Engine gains completedWeekStartKeys/ completedMonthKeys/missingKeys for idempotent reconciliation selection. AnalyticsStoreTests: 6 XCTest cases. Verified standalone via swiftc — 23- assertion temp-dir harness all pass. Registered files via xcodegen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@
|
|||||||
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
|
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
|
||||||
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */; };
|
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */; };
|
||||||
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */; };
|
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */; };
|
||||||
|
87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */; };
|
||||||
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
|
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
|
||||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
|
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
|
||||||
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
|
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
|
||||||
@@ -57,6 +58,7 @@
|
|||||||
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */; };
|
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */; };
|
||||||
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; };
|
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; };
|
||||||
E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */; };
|
E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */; };
|
||||||
|
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */; };
|
||||||
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; };
|
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; };
|
||||||
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
|
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
|
||||||
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; };
|
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; };
|
||||||
@@ -100,16 +102,18 @@
|
|||||||
0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
||||||
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; };
|
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; };
|
||||||
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
|
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
|
||||||
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGrid.swift; sourceTree = "<group>"; };
|
0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarGrid.swift; sourceTree = "<group>"; };
|
||||||
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
|
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStore.swift; sourceTree = "<group>"; };
|
||||||
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
|
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
|
||||||
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
|
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
|
||||||
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; };
|
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; };
|
||||||
20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KisaniCalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
|
23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
|
||||||
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; };
|
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; };
|
||||||
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; };
|
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; };
|
||||||
|
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStoreTests.swift; sourceTree = "<group>"; };
|
||||||
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = "<group>"; };
|
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = "<group>"; };
|
||||||
429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = "<group>"; };
|
429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = "<group>"; };
|
||||||
449C34805DC6B2CB66886544 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = "<group>"; };
|
449C34805DC6B2CB66886544 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = "<group>"; };
|
||||||
@@ -133,7 +137,7 @@
|
|||||||
92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = "<group>"; };
|
92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = "<group>"; };
|
||||||
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
|
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
|
||||||
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; explicitFileType = wrapper.application; includeInIndex = 0; 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>"; };
|
||||||
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>"; };
|
||||||
@@ -160,6 +164,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */,
|
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */,
|
||||||
|
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */,
|
||||||
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
|
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
|
||||||
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
|
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
|
||||||
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
|
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
|
||||||
@@ -253,6 +258,7 @@
|
|||||||
children = (
|
children = (
|
||||||
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */,
|
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */,
|
||||||
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */,
|
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */,
|
||||||
|
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */,
|
||||||
);
|
);
|
||||||
path = Analytics;
|
path = Analytics;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -399,7 +405,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 2660;
|
LastUpgradeCheck = 1500;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
28B43516AD88946E21D9BFE0 = {
|
28B43516AD88946E21D9BFE0 = {
|
||||||
DevelopmentTeam = K8BLMMR883;
|
DevelopmentTeam = K8BLMMR883;
|
||||||
@@ -460,6 +466,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */,
|
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */,
|
||||||
|
87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */,
|
||||||
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
|
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
|
||||||
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
|
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
|
||||||
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
|
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
|
||||||
@@ -483,6 +490,7 @@
|
|||||||
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
|
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.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 */,
|
||||||
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
|
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
|
||||||
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
|
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
|
||||||
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
|
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
|
||||||
@@ -636,12 +644,10 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 9;
|
CURRENT_PROJECT_VERSION = 9;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
@@ -655,7 +661,6 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -690,11 +695,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
"@executable_path/../../Frameworks",
|
|
||||||
);
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.9;
|
SWIFT_VERSION = 5.9;
|
||||||
@@ -737,12 +738,10 @@
|
|||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 9;
|
CURRENT_PROJECT_VERSION = 9;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
@@ -763,7 +762,6 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -797,11 +795,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
"@executable_path/../../Frameworks",
|
|
||||||
);
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_VERSION = 5.9;
|
SWIFT_VERSION = 5.9;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "2660"
|
LastUpgradeVersion = "1500"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -26,7 +27,8 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
@@ -49,6 +51,8 @@
|
|||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@@ -70,6 +74,8 @@
|
|||||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
@@ -87,6 +93,8 @@
|
|||||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction
|
<AnalyzeAction
|
||||||
buildConfiguration = "Debug">
|
buildConfiguration = "Debug">
|
||||||
|
|||||||
@@ -284,4 +284,38 @@ enum AnalyticsEngine {
|
|||||||
let cutoffKey = dateKey(for: cutoff, calendar: calendar)
|
let cutoffKey = dateKey(for: cutoff, calendar: calendar)
|
||||||
return keys.filter { $0 < cutoffKey }
|
return keys.filter { $0 < cutoffKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Reconciliation selection (which completed periods need a report)
|
||||||
|
|
||||||
|
/// Week-start keys of every completed week within the last `months`, oldest→newest.
|
||||||
|
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 [] }
|
||||||
|
var keys: [String] = []
|
||||||
|
var guardCount = 0
|
||||||
|
while cursor < thisWeek.start, guardCount < 70 {
|
||||||
|
keys.append(dateKey(for: cursor, calendar: cal))
|
||||||
|
guard let next = cal.date(byAdding: .weekOfYear, value: 1, to: cursor) else { break }
|
||||||
|
cursor = next; guardCount += 1
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Month keys of every completed month within the last `months`, oldest→newest.
|
||||||
|
static func completedMonthKeys(now: Date, months: Int = 12, calendar cal: Calendar) -> [String] {
|
||||||
|
var keys: [String] = []
|
||||||
|
for m in stride(from: months, through: 1, by: -1) {
|
||||||
|
guard let d = cal.date(byAdding: .month, value: -m, to: now) else { continue }
|
||||||
|
let key = monthKey(for: d, calendar: cal)
|
||||||
|
if !keys.contains(key) { keys.append(key) }
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keys present in `all` but not in `existing` — the periods still needing a
|
||||||
|
/// report. Idempotent by construction: already-generated periods are skipped.
|
||||||
|
static func missingKeys(_ all: [String], existing: Set<String>) -> [String] {
|
||||||
|
all.filter { !existing.contains($0) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
161
KisaniCal/Analytics/AnalyticsStore.swift
Normal file
161
KisaniCal/Analytics/AnalyticsStore.swift
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// File-based JSON persistence for the analytics system (per KC-51 decision:
|
||||||
|
// file store over SwiftData/Core Data — iOS 16-safe, unbounded local disk,
|
||||||
|
// lowest migration risk, easily reversible).
|
||||||
|
//
|
||||||
|
// Layout (under Application Support/Analytics):
|
||||||
|
// snapshots/<yyyy-MM-dd>.json immutable frozen DaySample per past day
|
||||||
|
// reports/weekly/<yyyy-MM-dd>.json
|
||||||
|
// reports/monthly/<yyyy-MM>.json
|
||||||
|
//
|
||||||
|
// Guarantees:
|
||||||
|
// - Immutability: a past day's snapshot and any report, once written, are never
|
||||||
|
// overwritten (`writeIfAbsent`). Later edits to source data cannot change a
|
||||||
|
// generated report.
|
||||||
|
// - Idempotency + dedup: files are keyed by period; re-saving an existing key is
|
||||||
|
// a no-op, so generation never duplicates.
|
||||||
|
// - Retention: `prune` removes snapshots/reports older than the window.
|
||||||
|
// - Nothing sensitive beyond what's needed is written (no raw HealthKit records,
|
||||||
|
// only the derived reference values already in DaySample).
|
||||||
|
final class AnalyticsStore {
|
||||||
|
|
||||||
|
private let root: URL
|
||||||
|
private let fm: FileManager
|
||||||
|
private let encoder: JSONEncoder
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
private var snapshotsDir: URL { root.appendingPathComponent("snapshots", isDirectory: true) }
|
||||||
|
private var weeklyDir: URL { root.appendingPathComponent("reports/weekly", isDirectory: true) }
|
||||||
|
private var monthlyDir: URL { root.appendingPathComponent("reports/monthly", isDirectory: true) }
|
||||||
|
|
||||||
|
init(root: URL, fileManager: FileManager = .default) {
|
||||||
|
self.root = root
|
||||||
|
self.fm = fileManager
|
||||||
|
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601; enc.outputFormatting = [.sortedKeys]
|
||||||
|
self.encoder = enc
|
||||||
|
let dec = JSONDecoder(); dec.dateDecodingStrategy = .iso8601
|
||||||
|
self.decoder = dec
|
||||||
|
ensureDirectories()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default location under Application Support.
|
||||||
|
convenience init() {
|
||||||
|
let base = (try? FileManager.default.url(for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask, appropriateFor: nil, create: true))
|
||||||
|
?? URL(fileURLWithPath: NSTemporaryDirectory())
|
||||||
|
self.init(root: base.appendingPathComponent("Analytics", isDirectory: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureDirectories() {
|
||||||
|
for dir in [snapshotsDir, weeklyDir, monthlyDir] {
|
||||||
|
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Snapshots (immutable once the day is in the past)
|
||||||
|
|
||||||
|
/// Persist a day snapshot. A past day (`isPast == true`) is frozen: if a file
|
||||||
|
/// already exists it is kept unchanged. Today/future may be updated in place.
|
||||||
|
@discardableResult
|
||||||
|
func saveSnapshot(_ day: DaySample, isPast: Bool) -> Bool {
|
||||||
|
let url = snapshotsDir.appendingPathComponent("\(day.dateKey).json")
|
||||||
|
if isPast && fm.fileExists(atPath: url.path) { return false } // frozen
|
||||||
|
return write(day, to: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(dateKey: String) -> DaySample? {
|
||||||
|
read(DaySample.self, from: snapshotsDir.appendingPathComponent("\(dateKey).json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func allSnapshotKeys() -> [String] {
|
||||||
|
listKeys(in: snapshotsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Weekly / monthly reports (immutable, idempotent)
|
||||||
|
|
||||||
|
/// Save a weekly report only if absent (idempotent, never overwritten).
|
||||||
|
/// Returns true if newly written, false if it already existed.
|
||||||
|
@discardableResult
|
||||||
|
func saveWeeklyReport(_ report: WeeklyReport) -> Bool {
|
||||||
|
writeIfAbsent(report, to: weeklyDir.appendingPathComponent("\(report.weekStartKey).json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func saveMonthlyReport(_ report: MonthlyReport) -> Bool {
|
||||||
|
writeIfAbsent(report, to: monthlyDir.appendingPathComponent("\(report.monthKey).json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func weeklyReport(weekStartKey: String) -> WeeklyReport? {
|
||||||
|
read(WeeklyReport.self, from: weeklyDir.appendingPathComponent("\(weekStartKey).json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func monthlyReport(monthKey: String) -> MonthlyReport? {
|
||||||
|
read(MonthlyReport.self, from: monthlyDir.appendingPathComponent("\(monthKey).json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func allWeeklyReports() -> [WeeklyReport] {
|
||||||
|
listKeys(in: weeklyDir).compactMap { weeklyReport(weekStartKey: $0) }
|
||||||
|
.sorted { $0.weekStartKey < $1.weekStartKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
func allMonthlyReports() -> [MonthlyReport] {
|
||||||
|
listKeys(in: monthlyDir).compactMap { monthlyReport(monthKey: $0) }
|
||||||
|
.sorted { $0.monthKey < $1.monthKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
func existingWeeklyKeys() -> Set<String> { Set(listKeys(in: weeklyDir)) }
|
||||||
|
func existingMonthlyKeys() -> Set<String> { Set(listKeys(in: monthlyDir)) }
|
||||||
|
|
||||||
|
// MARK: - Retention
|
||||||
|
|
||||||
|
/// Remove day snapshots (and weekly reports) whose key is older than the
|
||||||
|
/// retention cutoff. Monthly reports use the "yyyy-MM" cutoff. Returns the
|
||||||
|
/// number of files removed.
|
||||||
|
@discardableResult
|
||||||
|
func prune(now: Date, months: Int = 12, calendar: Calendar) -> Int {
|
||||||
|
var removed = 0
|
||||||
|
guard let cutoffDate = calendar.date(byAdding: .month, value: -months, to: calendar.startOfDay(for: now)) else { return 0 }
|
||||||
|
let dayCutoff = AnalyticsEngine.dateKey(for: cutoffDate, calendar: calendar)
|
||||||
|
let monthCutoff = AnalyticsEngine.monthKey(for: cutoffDate, calendar: calendar)
|
||||||
|
|
||||||
|
for key in listKeys(in: snapshotsDir) where key < dayCutoff {
|
||||||
|
if remove(snapshotsDir.appendingPathComponent("\(key).json")) { removed += 1 }
|
||||||
|
}
|
||||||
|
for key in listKeys(in: weeklyDir) where key < dayCutoff {
|
||||||
|
if remove(weeklyDir.appendingPathComponent("\(key).json")) { removed += 1 }
|
||||||
|
}
|
||||||
|
for key in listKeys(in: monthlyDir) where key < monthCutoff {
|
||||||
|
if remove(monthlyDir.appendingPathComponent("\(key).json")) { removed += 1 }
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Low-level IO
|
||||||
|
|
||||||
|
private func listKeys(in dir: URL) -> [String] {
|
||||||
|
guard let items = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return [] }
|
||||||
|
return items.filter { $0.pathExtension == "json" }
|
||||||
|
.map { $0.deletingPathExtension().lastPathComponent }
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func write<T: Encodable>(_ value: T, to url: URL) -> Bool {
|
||||||
|
guard let data = try? encoder.encode(value) else { return false }
|
||||||
|
do { try data.write(to: url, options: .atomic); return true } catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeIfAbsent<T: Encodable>(_ value: T, to url: URL) -> Bool {
|
||||||
|
if fm.fileExists(atPath: url.path) { return false }
|
||||||
|
return write(value, to: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func read<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return try? decoder.decode(type, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remove(_ url: URL) -> Bool {
|
||||||
|
(try? fm.removeItem(at: url)) != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1547,3 +1547,17 @@ last. Device/build QA tracked here.
|
|||||||
runnable here — no sim runtime; same assertions live in the XCTest file).
|
runnable here — no sim runtime; same assertions live in the XCTest file).
|
||||||
- Next: Phase 2 file-based persistence (report/record stores), adapters from
|
- Next: Phase 2 file-based persistence (report/record stores), adapters from
|
||||||
`WorkoutDayLog` + HealthKit, reconciliation, retention, dedup — with tests.
|
`WorkoutDayLog` + HealthKit, reconciliation, retention, dedup — with tests.
|
||||||
|
|
||||||
|
### KC-51 progress — Phase 2: persistence + reconciliation (DONE, store-verified)
|
||||||
|
- `AnalyticsStore` (file-based JSON under Application Support): immutable frozen
|
||||||
|
daily snapshots + immutable weekly/monthly reports (writeIfAbsent →
|
||||||
|
idempotent, deduped by period key), retention prune (>12mo), no raw HealthKit
|
||||||
|
persisted (only derived reference values in DaySample).
|
||||||
|
- Engine reconciliation selectors: `completedWeekStartKeys`, `completedMonthKeys`,
|
||||||
|
`missingKeys` (idempotent — already-generated periods skipped).
|
||||||
|
- `AnalyticsStoreTests` (6 cases). **Verified independently**: compiled store +
|
||||||
|
engine with swiftc and ran a 23-assertion temp-dir harness → all pass.
|
||||||
|
- Next: Phase 3 app integration — adapter (`WorkoutDayLog` + HealthKit →
|
||||||
|
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).
|
||||||
|
|||||||
94
KisaniCalTests/AnalyticsStoreTests.swift
Normal file
94
KisaniCalTests/AnalyticsStoreTests.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import KisaniCal
|
||||||
|
|
||||||
|
/// Persistence tests: snapshot immutability, report idempotency/dedup,
|
||||||
|
/// reconciliation selection, and retention pruning. Each test runs against a
|
||||||
|
/// fresh temp directory. (Verified standalone via swiftc; mirrored here for the
|
||||||
|
/// Xcode test target.)
|
||||||
|
final class AnalyticsStoreTests: XCTestCase {
|
||||||
|
|
||||||
|
private var tmp: URL!
|
||||||
|
private var store: AnalyticsStore!
|
||||||
|
private let now = Date(timeIntervalSince1970: 1_783_500_000) // 2026-07-08
|
||||||
|
|
||||||
|
private func cal(_ tz: String = "UTC") -> Calendar {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.timeZone = TimeZone(identifier: tz)!
|
||||||
|
c.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
tmp = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("analytics_test_\(UUID().uuidString)")
|
||||||
|
store = AnalyticsStore(root: tmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: tmp)
|
||||||
|
super.tearDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPastSnapshotIsFrozen() {
|
||||||
|
let past = DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 1, sets: 10, volume: 500)
|
||||||
|
XCTAssertTrue(store.saveSnapshot(past, isPast: true))
|
||||||
|
let edited = DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 9, sets: 99, volume: 9999)
|
||||||
|
XCTAssertFalse(store.saveSnapshot(edited, isPast: true), "past snapshot must not be overwritten")
|
||||||
|
XCTAssertEqual(store.snapshot(dateKey: "2026-06-01")?.sets, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTodaySnapshotUpdatable() {
|
||||||
|
_ = store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: false, scheduled: true), isPast: false)
|
||||||
|
XCTAssertTrue(store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000), isPast: false))
|
||||||
|
XCTAssertEqual(store.snapshot(dateKey: "2026-07-08")?.sets, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWeeklyReportIdempotentAndImmutable() {
|
||||||
|
let wr = AnalyticsEngine.weeklyReport(
|
||||||
|
weekStartKey: "2026-06-29",
|
||||||
|
days: [DaySample(dateKey: "2026-06-29", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)],
|
||||||
|
previousWeekDays: [DaySample(dateKey: "2026-06-22", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)],
|
||||||
|
generatedAt: now)
|
||||||
|
XCTAssertTrue(store.saveWeeklyReport(wr))
|
||||||
|
XCTAssertFalse(store.saveWeeklyReport(wr), "re-saving must be a no-op (dedup)")
|
||||||
|
XCTAssertEqual(store.weeklyReport(weekStartKey: "2026-06-29")?.progression, .improving)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMonthlyReportDedup() {
|
||||||
|
let mr = AnalyticsEngine.monthlyReport(
|
||||||
|
monthKey: "2026-06",
|
||||||
|
days: [DaySample(dateKey: "2026-06-01", didWorkout: true, scheduled: true, sessions: 1, sets: 20, volume: 1000)],
|
||||||
|
previousMonthDays: [DaySample(dateKey: "2026-05-01", didWorkout: true, scheduled: true, sessions: 1, sets: 18, volume: 800)],
|
||||||
|
generatedAt: now)
|
||||||
|
XCTAssertTrue(store.saveMonthlyReport(mr))
|
||||||
|
XCTAssertFalse(store.saveMonthlyReport(mr))
|
||||||
|
XCTAssertNotNil(store.monthlyReport(monthKey: "2026-06"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReconciliationSelection() {
|
||||||
|
let c = cal()
|
||||||
|
let weekKeys = AnalyticsEngine.completedWeekStartKeys(now: now, months: 12, calendar: c)
|
||||||
|
XCTAssertTrue((50...53).contains(weekKeys.count))
|
||||||
|
let currentWeekKey = AnalyticsEngine.dateKey(for: AnalyticsEngine.weekInterval(containing: now, calendar: c)!.start, calendar: c)
|
||||||
|
XCTAssertFalse(weekKeys.contains(currentWeekKey), "current (incomplete) week excluded")
|
||||||
|
|
||||||
|
let monthKeys = AnalyticsEngine.completedMonthKeys(now: now, months: 12, calendar: c)
|
||||||
|
XCTAssertEqual(monthKeys.count, 12)
|
||||||
|
XCTAssertFalse(monthKeys.contains("2026-07"), "current month excluded")
|
||||||
|
|
||||||
|
// A saved report is not re-selected (idempotent reconciliation).
|
||||||
|
let wr = AnalyticsEngine.weeklyReport(weekStartKey: weekKeys.last!, days: [], previousWeekDays: [], generatedAt: now)
|
||||||
|
_ = store.saveWeeklyReport(wr)
|
||||||
|
XCTAssertFalse(AnalyticsEngine.missingKeys(weekKeys, existing: store.existingWeeklyKeys()).contains(weekKeys.last!))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRetentionPrune() {
|
||||||
|
let c = cal()
|
||||||
|
_ = store.saveSnapshot(DaySample(dateKey: "2025-01-01", didWorkout: true), isPast: true) // >12mo old
|
||||||
|
_ = store.saveSnapshot(DaySample(dateKey: "2026-07-08", didWorkout: true), isPast: false)
|
||||||
|
let removed = store.prune(now: now, months: 12, calendar: c)
|
||||||
|
XCTAssertGreaterThanOrEqual(removed, 1)
|
||||||
|
XCTAssertNil(store.snapshot(dateKey: "2025-01-01"), "expired snapshot pruned")
|
||||||
|
XCTAssertNotNil(store.snapshot(dateKey: "2026-07-08"), "recent snapshot survives")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user