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>
162 lines
6.7 KiB
Swift
162 lines
6.7 KiB
Swift
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
|
|
}
|
|
}
|