Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

218 lines
6.5 KiB
Swift

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<Void, Never>?
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..<boundedCount).reversed().map { offset in
let date = calendar.date(byAdding: .day, value: -offset, to: end) ?? end
let key = dateKey(for: date)
return stats(for: key)
}
}
func incrementKeystroke(app: String) {
mutateToday(app: app) { day, appStats in
day.keystrokes += 1
appStats.keystrokes += 1
}
}
func incrementPointerClick(app: String) {
mutateToday(app: app) { day, appStats in
day.pointerClicks += 1
appStats.pointerClicks += 1
}
}
func incrementScroll(app: String) {
mutateToday(app: app) { day, appStats in
day.scrollEvents += 1
appStats.scrollEvents += 1
}
}
func addScreenTime(_ seconds: TimeInterval, app: String) {
guard seconds > 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) }
}
}