From 80591c31a631923c32bf3e88890fecbfc8789d8f Mon Sep 17 00:00:00 2001 From: kutesir Date: Tue, 7 Jul 2026 22:34:32 +0300 Subject: [PATCH] analytics(persistence): file-based store + reconciliation selection (KC-51 ph2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- KisaniCal.xcodeproj/project.pbxproj | 34 ++-- .../xcshareddata/xcschemes/KisaniCal.xcscheme | 16 +- KisaniCal/Analytics/AnalyticsEngine.swift | 34 ++++ KisaniCal/Analytics/AnalyticsStore.swift | 161 ++++++++++++++++++ KisaniCal/ISSUES.md | 14 ++ KisaniCalTests/AnalyticsStoreTests.swift | 94 ++++++++++ 6 files changed, 329 insertions(+), 24 deletions(-) create mode 100644 KisaniCal/Analytics/AnalyticsStore.swift create mode 100644 KisaniCalTests/AnalyticsStoreTests.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index df67d58..d9e89ef 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -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 = ""; }; 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = ""; }; 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; }; - 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 = ""; }; 106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + 10BD058FC3875B6BD6B10C90 /* AnalyticsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStore.swift; sourceTree = ""; }; 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = ""; }; 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = ""; }; 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = ""; }; - 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 = ""; }; 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = ""; }; 35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = ""; }; + 3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsStoreTests.swift; sourceTree = ""; }; 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = ""; }; 429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = ""; }; 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = ""; }; @@ -133,7 +137,7 @@ 92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = ""; }; 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLRecurrenceParseTests.swift; sourceTree = ""; }; - 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 = ""; }; ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = ""; }; @@ -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 = ""; @@ -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; diff --git a/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme b/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme index d8b51b8..4071af8 100644 --- a/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme +++ b/KisaniCal.xcodeproj/xcshareddata/xcschemes/KisaniCal.xcscheme @@ -1,10 +1,11 @@ + LastUpgradeVersion = "1500" + version = "1.7"> + buildImplicitDependencies = "YES" + runPostActionsOnFailure = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES" + onlyGenerateCoverageForSpecifiedTargets = "NO"> + + + + + + diff --git a/KisaniCal/Analytics/AnalyticsEngine.swift b/KisaniCal/Analytics/AnalyticsEngine.swift index 5bce712..372b29a 100644 --- a/KisaniCal/Analytics/AnalyticsEngine.swift +++ b/KisaniCal/Analytics/AnalyticsEngine.swift @@ -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] { + all.filter { !existing.contains($0) } + } } diff --git a/KisaniCal/Analytics/AnalyticsStore.swift b/KisaniCal/Analytics/AnalyticsStore.swift new file mode 100644 index 0000000..f856df3 --- /dev/null +++ b/KisaniCal/Analytics/AnalyticsStore.swift @@ -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/.json immutable frozen DaySample per past day +// reports/weekly/.json +// reports/monthly/.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 { Set(listKeys(in: weeklyDir)) } + func existingMonthlyKeys() -> Set { 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(_ 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(_ value: T, to url: URL) -> Bool { + if fm.fileExists(atPath: url.path) { return false } + return write(value, to: url) + } + + private func read(_ 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 + } +} diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index ff99e9c..3856e41 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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). diff --git a/KisaniCalTests/AnalyticsStoreTests.swift b/KisaniCalTests/AnalyticsStoreTests.swift new file mode 100644 index 0000000..408cf8a --- /dev/null +++ b/KisaniCalTests/AnalyticsStoreTests.swift @@ -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") + } +}