analytics(persistence): file-based store + reconciliation selection (KC-51 ph2)
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:
kutesir
2026-07-07 22:34:32 +03:00
parent dd4d196129
commit 80591c31a6
6 changed files with 329 additions and 24 deletions

View File

@@ -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;

View File

@@ -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">

View File

@@ -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`, oldestnewest.
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`, oldestnewest.
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) }
}
} }

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

View File

@@ -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).

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