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 */; };
|
||||
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.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 */; };
|
||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.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 */; };
|
||||
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.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 */; };
|
||||
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -133,7 +137,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -160,6 +164,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */,
|
||||
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */,
|
||||
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
|
||||
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
|
||||
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
|
||||
@@ -253,6 +258,7 @@
|
||||
children = (
|
||||
78EEE7568265196447E54D6B /* AnalyticsEngine.swift */,
|
||||
0379128BF8B6C1CA7DF026D0 /* AnalyticsModels.swift */,
|
||||
10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */,
|
||||
);
|
||||
path = Analytics;
|
||||
sourceTree = "<group>";
|
||||
@@ -399,7 +405,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 2660;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
28B43516AD88946E21D9BFE0 = {
|
||||
DevelopmentTeam = K8BLMMR883;
|
||||
@@ -460,6 +466,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
550E9A86BE36228C705E91C1 /* AnalyticsEngineTests.swift in Sources */,
|
||||
87564C27F644ED9CFF1E517B /* AnalyticsStoreTests.swift in Sources */,
|
||||
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */,
|
||||
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
|
||||
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
|
||||
@@ -483,6 +490,7 @@
|
||||
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
|
||||
152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */,
|
||||
42E3B90A981E75FFC10C221F /* AnalyticsModels.swift in Sources */,
|
||||
E6FCFC0D429627201A52C616 /* AnalyticsStore.swift in Sources */,
|
||||
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
|
||||
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
|
||||
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
|
||||
@@ -636,12 +644,10 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
@@ -655,7 +661,6 @@
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -690,11 +695,7 @@
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -737,12 +738,10 @@
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
@@ -763,7 +762,6 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -797,11 +795,7 @@
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2660"
|
||||
version = "1.3">
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -26,7 +27,8 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
@@ -49,6 +51,8 @@
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@@ -70,6 +74,8 @@
|
||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
@@ -87,6 +93,8 @@
|
||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
|
||||
@@ -284,4 +284,38 @@ enum AnalyticsEngine {
|
||||
let cutoffKey = dateKey(for: cutoff, calendar: calendar)
|
||||
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).
|
||||
- Next: Phase 2 file-based persistence (report/record stores), adapters from
|
||||
`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