214 lines
7.1 KiB
Swift
214 lines
7.1 KiB
Swift
import Foundation
|
|
import MacToolsPluginKit
|
|
|
|
@MainActor
|
|
final class ActivityBarCodingSessionStore: ObservableObject {
|
|
private enum StorageKey {
|
|
static let days = "activity-bar.coding.days.v1"
|
|
}
|
|
|
|
private struct ActiveSession: Equatable {
|
|
var project: String
|
|
var tool: String
|
|
var status: ActivityBarHookStatus
|
|
var startedAt: Date
|
|
var lastUpdatedAt: Date
|
|
}
|
|
|
|
@Published private(set) var days: [String: ActivityBarCodingDailyStats]
|
|
@Published private(set) var activeSessionCount = 0
|
|
|
|
private var activeSessions: [String: ActiveSession] = [:]
|
|
private let storage: PluginStorage
|
|
private let calendar: Calendar
|
|
private let dateProvider: () -> Date
|
|
private let encoder = JSONEncoder()
|
|
private let decoder = JSONDecoder()
|
|
|
|
init(
|
|
storage: PluginStorage,
|
|
calendar: Calendar = .current,
|
|
dateProvider: @escaping () -> Date = Date.init
|
|
) {
|
|
self.storage = storage
|
|
self.calendar = calendar
|
|
self.dateProvider = dateProvider
|
|
self.days = Self.loadDays(storage: storage, decoder: decoder)
|
|
}
|
|
|
|
var today: ActivityBarCodingDailyStats {
|
|
days[dateKey(for: dateProvider())] ?? ActivityBarCodingDailyStats(date: dateKey(for: dateProvider()))
|
|
}
|
|
|
|
var sortedDateKeys: [String] {
|
|
days.keys.sorted()
|
|
}
|
|
|
|
func stats(for date: String) -> ActivityBarCodingDailyStats {
|
|
days[date] ?? ActivityBarCodingDailyStats(date: date)
|
|
}
|
|
|
|
func recentDays(count: Int, endingAt endDate: Date? = nil) -> [ActivityBarCodingDailyStats] {
|
|
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 handleEvent(_ event: ActivityBarHookEvent) {
|
|
let now = dateProvider()
|
|
let project = projectName(from: event.cwd)
|
|
let sessionID = event.sessionID.isEmpty ? "unknown" : event.sessionID
|
|
let tool = ActivityBarCodingTool.displayName(forSessionID: sessionID)
|
|
|
|
closeElapsedTime(for: sessionID, now: now)
|
|
|
|
if let prompt = event.userPrompt, event.event == .userPromptSubmit {
|
|
addWords(countWords(prompt), project: project, tool: tool)
|
|
}
|
|
|
|
if event.event == .preToolUse {
|
|
addToolCall(project: project, tool: tool)
|
|
}
|
|
|
|
switch event.event {
|
|
case .sessionEnd:
|
|
activeSessions.removeValue(forKey: sessionID)
|
|
default:
|
|
activeSessions[sessionID] = ActiveSession(
|
|
project: project,
|
|
tool: tool,
|
|
status: event.status,
|
|
startedAt: activeSessions[sessionID]?.startedAt ?? now,
|
|
lastUpdatedAt: now
|
|
)
|
|
}
|
|
|
|
activeSessionCount = activeSessions.count
|
|
persist()
|
|
}
|
|
|
|
func flushActiveDurations() {
|
|
let now = dateProvider()
|
|
for sessionID in activeSessions.keys {
|
|
closeElapsedTime(for: sessionID, now: now)
|
|
activeSessions[sessionID]?.lastUpdatedAt = now
|
|
}
|
|
persist()
|
|
}
|
|
|
|
func resetToday() {
|
|
days[dateKey(for: dateProvider())] = ActivityBarCodingDailyStats(date: dateKey(for: dateProvider()))
|
|
persist()
|
|
}
|
|
|
|
private func closeElapsedTime(for sessionID: String, now: Date) {
|
|
guard var session = activeSessions[sessionID] else {
|
|
return
|
|
}
|
|
|
|
let elapsed = now.timeIntervalSince(session.lastUpdatedAt)
|
|
if elapsed > 0.5, session.status != .waitingForInput, session.status != .ended {
|
|
addDuration(elapsed, project: session.project, tool: session.tool)
|
|
}
|
|
|
|
session.lastUpdatedAt = now
|
|
activeSessions[sessionID] = session
|
|
}
|
|
|
|
private func addWords(_ count: Int, project: String, tool: String) {
|
|
guard count > 0 else {
|
|
return
|
|
}
|
|
|
|
mutateToday(project: project, tool: tool) { day, projectStats, toolStats in
|
|
day.wordCount += count
|
|
projectStats.wordCount += count
|
|
toolStats.wordCount += count
|
|
}
|
|
}
|
|
|
|
private func addToolCall(project: String, tool: String) {
|
|
mutateToday(project: project, tool: tool) { day, projectStats, toolStats in
|
|
day.toolCallCount += 1
|
|
projectStats.toolCallCount += 1
|
|
toolStats.toolCallCount += 1
|
|
}
|
|
}
|
|
|
|
private func addDuration(_ seconds: TimeInterval, project: String, tool: String) {
|
|
mutateToday(project: project, tool: tool) { day, projectStats, toolStats in
|
|
day.durationSeconds += seconds
|
|
projectStats.durationSeconds += seconds
|
|
toolStats.durationSeconds += seconds
|
|
}
|
|
}
|
|
|
|
private func mutateToday(
|
|
project rawProject: String,
|
|
tool rawTool: String,
|
|
update: (inout ActivityBarCodingDailyStats, inout ActivityBarProjectStats, inout ActivityBarProjectStats) -> Void
|
|
) {
|
|
let project = rawProject.isEmpty ? "Unknown" : rawProject
|
|
let tool = rawTool.isEmpty ? ActivityBarCodingTool.claudeCode.rawValue : rawTool
|
|
let key = dateKey(for: dateProvider())
|
|
var day = days[key] ?? ActivityBarCodingDailyStats(date: key)
|
|
var projectStats = day.perProject[project] ?? ActivityBarProjectStats()
|
|
var toolStats = day.perTool[tool] ?? ActivityBarProjectStats()
|
|
|
|
update(&day, &projectStats, &toolStats)
|
|
|
|
day.perProject[project] = projectStats
|
|
day.perTool[tool] = toolStats
|
|
days[key] = day
|
|
}
|
|
|
|
private func countWords(_ prompt: String) -> Int {
|
|
prompt
|
|
.split { $0.isWhitespace || $0.isNewline }
|
|
.count
|
|
}
|
|
|
|
private func projectName(from cwd: String?) -> String {
|
|
guard let cwd, !cwd.isEmpty else {
|
|
return "Unknown"
|
|
}
|
|
|
|
return URL(fileURLWithPath: cwd).lastPathComponent
|
|
}
|
|
|
|
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 persist() {
|
|
do {
|
|
let data = try encoder.encode(days)
|
|
storage.set(data, forKey: StorageKey.days)
|
|
} catch {
|
|
ActivityBarLog.hooks.error("Failed to persist coding stats: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
private static func loadDays(storage: PluginStorage, decoder: JSONDecoder) -> [String: ActivityBarCodingDailyStats] {
|
|
guard let data = storage.data(forKey: StorageKey.days) else {
|
|
return [:]
|
|
}
|
|
|
|
do {
|
|
return try decoder.decode([String: ActivityBarCodingDailyStats].self, from: data)
|
|
} catch {
|
|
ActivityBarLog.hooks.error("Failed to load coding stats: \(error.localizedDescription, privacy: .public)")
|
|
return [:]
|
|
}
|
|
}
|
|
}
|