import Foundation import MacToolsPluginKit @MainActor final class ActivityBarStatsStore: ObservableObject { private enum StorageKey { static let days = "activity-bar.input.days.v1" } // Direct observers receive every input mutation and bypass controller-level // UI throttling; keep hot-path UI subscriptions on ActivityBarController. @Published private(set) var days: [String: ActivityBarDailyStats] private let storage: PluginStorage private let calendar: Calendar private let dateProvider: () -> Date private let persistenceDelay: Duration private let encoder = JSONEncoder() private let decoder = JSONDecoder() private var persistenceTask: Task? private var hasPendingChanges = false private enum Persistence { static let debounceDelay: Duration = .seconds(30) static let retainedDayCount = 370 } init( storage: PluginStorage, calendar: Calendar = .current, dateProvider: @escaping () -> Date = Date.init, persistenceDelay: Duration = Persistence.debounceDelay ) { self.storage = storage self.calendar = calendar self.dateProvider = dateProvider self.persistenceDelay = persistenceDelay self.days = Self.prunedDays(Self.loadDays(storage: storage, decoder: decoder)) } deinit { persistenceTask?.cancel() } var today: ActivityBarDailyStats { days[dateKey(for: dateProvider())] ?? ActivityBarDailyStats(date: dateKey(for: dateProvider())) } var sortedDateKeys: [String] { days.keys.sorted() } func stats(for date: String) -> ActivityBarDailyStats { days[date] ?? ActivityBarDailyStats(date: date) } func recentDays(count: Int, endingAt endDate: Date? = nil) -> [ActivityBarDailyStats] { let end = endDate ?? dateProvider() let boundedCount = max(count, 1) return (0.. 0 else { return } mutateToday(app: app) { day, appStats in day.screenTimeSeconds += seconds appStats.screenTimeSeconds += seconds } } func record(_ event: ActivityBarInputEvent) { switch event { case let .keystroke(app): incrementKeystroke(app: app) case let .pointerClick(app): incrementPointerClick(app: app) case let .scroll(app): incrementScroll(app: app) case let .screenTime(app, seconds): addScreenTime(seconds, app: app) } } func resetToday() { days[dateKey(for: dateProvider())] = ActivityBarDailyStats(date: dateKey(for: dateProvider())) hasPendingChanges = true persistPendingChanges(force: true) } func flushPendingChanges() { persistPendingChanges() } private func mutateToday( app rawApp: String, update: (inout ActivityBarDailyStats, inout ActivityBarAppStats) -> Void ) { let app = sanitizedAppName(rawApp) let key = dateKey(for: dateProvider()) var day = days[key] ?? ActivityBarDailyStats(date: key) var appStats = day.perApp[app] ?? ActivityBarAppStats() update(&day, &appStats) day.perApp[app] = appStats days[key] = day hasPendingChanges = true schedulePersistence() } private func sanitizedAppName(_ value: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Unknown" : trimmed } private func dateKey(for date: Date) -> String { let components = calendar.dateComponents([.year, .month, .day], from: date) let year = components.year ?? 0 let month = components.month ?? 0 let day = components.day ?? 0 return String(format: "%04d-%02d-%02d", year, month, day) } private func schedulePersistence() { guard persistenceTask == nil else { return } persistenceTask = Task { @MainActor [weak self] in guard let self else { return } do { try await Task.sleep(for: self.persistenceDelay) } catch { return } guard !Task.isCancelled else { return } self.persistPendingChanges() } } private func persistPendingChanges(force: Bool = false) { persistenceTask?.cancel() persistenceTask = nil guard force || hasPendingChanges else { return } days = Self.prunedDays(days) do { let data = try encoder.encode(days) storage.set(data, forKey: StorageKey.days) hasPendingChanges = false } catch { ActivityBarLog.input.error("Failed to persist input stats: \(error.localizedDescription, privacy: .public)") } } private static func loadDays(storage: PluginStorage, decoder: JSONDecoder) -> [String: ActivityBarDailyStats] { guard let data = storage.data(forKey: StorageKey.days) else { return [:] } do { return try decoder.decode([String: ActivityBarDailyStats].self, from: data) } catch { ActivityBarLog.input.error("Failed to load input stats: \(error.localizedDescription, privacy: .public)") return [:] } } private static func prunedDays(_ days: [String: ActivityBarDailyStats]) -> [String: ActivityBarDailyStats] { guard days.count > Persistence.retainedDayCount else { return days } let retainedKeys = Set(days.keys.sorted().suffix(Persistence.retainedDayCount)) return days.filter { retainedKeys.contains($0.key) } } }