diff --git a/Leader Key.xcodeproj/project.pbxproj b/Leader Key.xcodeproj/project.pbxproj index 141cebf6..902b9674 100644 --- a/Leader Key.xcodeproj/project.pbxproj +++ b/Leader Key.xcodeproj/project.pbxproj @@ -55,6 +55,8 @@ 6D9B9C012DBA000000000001 /* ConfigOutlineEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */; }; 6D9B9C042DBA000000000002 /* KeyCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9B9C032DBA000000000002 /* KeyCapture.swift */; }; 6D9B9C062DBA000000000003 /* ConfigEditorShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9B9C052DBA000000000003 /* ConfigEditorShared.swift */; }; + CC0000012E9A000000000008 /* StatsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000012E9A000000000007 /* StatsManager.swift */; }; + CC0000012E9A00000000000A /* StatsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000012E9A000000000009 /* StatsPane.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -114,6 +116,8 @@ 6D9B9C002DBA000000000001 /* ConfigOutlineEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigOutlineEditorView.swift; sourceTree = ""; }; 6D9B9C032DBA000000000002 /* KeyCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCapture.swift; sourceTree = ""; }; 6D9B9C052DBA000000000003 /* ConfigEditorShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigEditorShared.swift; sourceTree = ""; }; + CC0000012E9A000000000007 /* StatsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsManager.swift; sourceTree = ""; }; + CC0000012E9A000000000009 /* StatsPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsPane.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -158,10 +162,19 @@ children = ( 427C18242BD31E2E00955B98 /* GeneralPane.swift */, 42FDC3192D51687B004F5C5C /* AdvancedPane.swift */, + CC0000012E9A000000000009 /* StatsPane.swift */, ); path = Settings; sourceTree = ""; }; + CC0000012E9A000000000004 /* Stats */ = { + isa = PBXGroup; + children = ( + CC0000012E9A000000000007 /* StatsManager.swift */, + ); + path = Stats; + sourceTree = ""; + }; 427C17DE2BD311B400955B98 = { isa = PBXGroup; children = ( @@ -206,6 +219,7 @@ 427C184F2BD6652500955B98 /* Util.swift */, 427C17EE2BD311B500955B98 /* Assets.xcassets */, 423632242D68CC5D00878D92 /* Settings */, + CC0000012E9A000000000004 /* Stats */, 427C18362BD3243C00955B98 /* Support */, 423632232D68CB0F00878D92 /* Themes */, 427C18392BD3268000955B98 /* Views */, @@ -443,6 +457,8 @@ 42F4CDCD2D45B13600D0DD76 /* KeyButton.swift in Sources */, 606C56EF2DAB875A00198B9F /* Cheater.swift in Sources */, 427C181C2BD314B500955B98 /* Constants.swift in Sources */, + CC0000012E9A000000000008 /* StatsManager.swift in Sources */, + CC0000012E9A00000000000A /* StatsPane.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Leader Key.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Leader Key.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b9760019..19f91d27 100644 --- a/Leader Key.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Leader Key.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "da47d6e5ab6b8a19e07061424069e09f3c36abd019782617be344b153679c7b4", + "originHash" : "27cd159b3de48393390ccc7adbcd2855f25c1f1b5d8088fa165248f7c52f23e1", "pins" : [ { "identity" : "defaults", diff --git a/Leader Key/AppDelegate.swift b/Leader Key/AppDelegate.swift index 36411839..43ae1159 100644 --- a/Leader Key/AppDelegate.swift +++ b/Leader Key/AppDelegate.swift @@ -35,6 +35,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, contentView: { AdvancedPane().environmentObject(self.config) }), + Settings.Pane( + identifier: .stats, title: "Stats", + toolbarIcon: NSImage( + systemSymbolName: "chart.bar.fill", accessibilityDescription: "Stats")!, + contentView: { + StatsPane().environmentObject(self.config) + }), ], style: .segmentedControl, ) diff --git a/Leader Key/Controller.swift b/Leader Key/Controller.swift index 37c0eecd..854ba505 100644 --- a/Leader Key/Controller.swift +++ b/Leader Key/Controller.swift @@ -160,24 +160,34 @@ class Controller { switch hit { case .action(let action): if execute { + // Capture keyPath BEFORE hiding (which clears navigation state) + let keyPath = statsKeyPath(leafKey: action.key) + if let mods = modifiers, isInStickyMode(mods) { - runAction(action) + runAction(action, keyPath: keyPath) } else { hide { - self.runAction(action) + self.runAction(action, keyPath: keyPath) } } } // If execute is false, just stay visible showing the matched action - case .group(let group): - if execute, let mods = modifiers, shouldRunGroupSequenceWithModifiers(mods) { - hide { - self.runGroup(group) - } - } else { - userState.display = group.key - userState.navigateToGroup(group) - } + case .group(let group): + if execute, let mods = modifiers, shouldRunGroupSequenceWithModifiers(mods) { + // Capture keyPath BEFORE hiding + let parentPath = statsKeyPath(leafKey: nil) + hide { + self.runGroup(group, parentPath: parentPath) + } + } else { + // Record group navigation for stats + if let keyPath = statsKeyPath(leafKey: group.key) { + StatsManager.shared.recordGroupNavigation(group: group, keyPath: keyPath) + } + + userState.display = group.key + userState.navigateToGroup(group) + } case .none: window.notFound() } @@ -288,20 +298,47 @@ class Controller { } } - private func runGroup(_ group: Group) { - for groupOrAction in group.actions { - switch groupOrAction { - case .group(let group): - runGroup(group) + private func runGroup(_ group: Group, parentPath: String? = nil) { + let currentPath: String + if let parent = parentPath, let groupKey = group.key { + currentPath = parent + "/" + groupKey + } else if let groupKey = group.key { + currentPath = groupKey + } else { + currentPath = "" + } + + for groupOrAction in group.actions { + switch groupOrAction { + case .group(let subGroup): + runGroup(subGroup, parentPath: currentPath) case .action(let action): - runAction(action) + let actionPath: String? + if !currentPath.isEmpty, let actionKey = action.key { + actionPath = currentPath + "/" + actionKey + } else if let actionKey = action.key { + actionPath = actionKey + } else { + actionPath = nil + } + runAction(action, keyPath: actionPath) } - } - } - - private func runAction(_ action: Action) { - switch action.type { - case .application: + } + } + + private func statsKeyPath(leafKey: String?) -> String? { + guard let leafKey, !leafKey.isEmpty else { return nil } + let components = userState.navigationPath.compactMap(\.key).filter { !$0.isEmpty } + [leafKey] + return components.joined(separator: "/") + } + + private func runAction(_ action: Action, keyPath: String?) { + if let keyPath = keyPath { + StatsManager.shared.recordExecution(action: action, keyPath: keyPath) + } + + switch action.type { + case .application: NSWorkspace.shared.openApplication( at: URL(fileURLWithPath: action.value), configuration: NSWorkspace.OpenConfiguration()) diff --git a/Leader Key/Settings.swift b/Leader Key/Settings.swift index 1eb1af27..d63194d8 100644 --- a/Leader Key/Settings.swift +++ b/Leader Key/Settings.swift @@ -3,4 +3,5 @@ import Settings extension Settings.PaneIdentifier { static let general = Self("general") static let advanced = Self("advanced") + static let stats = Self("stats") } diff --git a/Leader Key/Settings/StatsPane.swift b/Leader Key/Settings/StatsPane.swift new file mode 100644 index 00000000..1e8a9c8b --- /dev/null +++ b/Leader Key/Settings/StatsPane.swift @@ -0,0 +1,497 @@ +import AppKit +import Settings +import SwiftUI +import Kingfisher + +struct StatsPane: View { + private let contentWidth = 550.0 + + @EnvironmentObject private var userConfig: UserConfig + + @State private var groupedStats: [GroupStats] = [] + @State private var expandedGroups: Set = [] + @State private var totalExecutions: Int = 0 + + var body: some View { + Settings.Container(contentWidth: contentWidth) { + Settings.Section(title: "") { + VStack(alignment: .leading, spacing: 16) { + // Header + HStack(alignment: .center) { + Text("Usage Stats") + .font(.headline) + Spacer() + Text("\(totalExecutions) total") + .foregroundColor(.primary) + .font(.system(.body, design: .rounded)) + .fontWeight(.medium) + .monospacedDigit() + } + .padding(.bottom, 8) + + // Stats list + if groupedStats.isEmpty { + Text("No actions recorded yet") + .foregroundColor(.secondary) + .padding(.vertical, 20) + } else { + ScrollView { + VStack(spacing: 2) { + ForEach(groupedStats) { group in + GroupStatsRow( + group: group, + isExpanded: expandedGroups.contains(group.id), + maxCount: groupedStats.first?.totalCount ?? 1, + depth: 0, + expandedGroups: $expandedGroups + ) + } + } + } + .scrollIndicators(.hidden) + .frame(height: 400) + } + } + } + + Settings.Section(title: "") { + VStack { + Spacer() + .frame(height: 24) + + HStack { + Spacer() + Button("Clear Stats") { + showClearConfirmation() + } + Spacer() + } + } + } + } + .onAppear { + loadStats() + } + } + + private func loadStats() { + let rawActions = StatsManager.shared.getMostUsedActions(limit: 50) + + // Build tree structure + let allDisplays = rawActions.map { stat in + ActionStatsDisplay( + action: stat, + pathComponents: stat.keyPath.split(separator: "/").map(String.init) + ) + } + + groupedStats = buildGroupTree(displays: allDisplays, pathPrefix: []) + .sorted { $0.totalCount > $1.totalCount } + + totalExecutions = StatsManager.shared.getTotalExecutions() + } + + private func buildGroupTree(displays: [ActionStatsDisplay], pathPrefix: [String]) -> [GroupStats] { + var result: [GroupStats] = [] + + // Group by next path component + var grouped: [String: [ActionStatsDisplay]] = [:] + var leafActions: [ActionStatsDisplay] = [] + + for display in displays { + let remainingPath = Array(display.pathComponents.dropFirst(pathPrefix.count)) + + if remainingPath.count == 1 { + // This is a leaf action at this level + leafActions.append(display) + } else if remainingPath.count > 1 { + // This belongs to a subgroup + let nextKey = remainingPath[0] + grouped[nextKey, default: []].append(display) + } + } + + // Create GroupStats for each subgroup + for (key, items) in grouped { + let fullPath = (pathPrefix + [key]).joined(separator: "/") + let totalCount = items.reduce(0) { $0 + $1.action.executionCount } + let groupLabel = findGroupLabel(for: key, at: pathPrefix) ?? key.capitalized + + // Recursively build children + let nestedGroups = buildGroupTree(displays: items, pathPrefix: pathPrefix + [key]) + let children: [GroupStatsChild] = nestedGroups.map { .group($0) } + + result.append(GroupStats( + fullPath: fullPath, + groupKey: key, + groupLabel: groupLabel, + totalCount: totalCount, + children: children, + isGroup: true + )) + } + + // Add leaf actions + for action in leafActions { + result.append(GroupStats( + fullPath: action.action.keyPath, + groupKey: action.pathComponents.last ?? "", + groupLabel: action.action.actionLabel ?? action.action.actionValue, + totalCount: action.action.executionCount, + children: [.action(action)], + isGroup: false + )) + } + + return result.sorted { $0.totalCount > $1.totalCount } + } + + private func findGroupLabel(for key: String, at pathPrefix: [String]) -> String? { + // Navigate to the right level in the config + var current: Group = userConfig.root + + for component in pathPrefix { + guard let nextGroup = current.actions.first(where: { item in + if case .group(let g) = item, g.key == component { + return true + } + return false + }) else { + return nil + } + + if case .group(let g) = nextGroup { + current = g + } + } + + // Find the group at this level + for item in current.actions { + if case .group(let group) = item, group.key == key { + return group.label ?? group.key + } + } + + return nil + } + + + private func showClearConfirmation() { + let alert = NSAlert() + alert.messageText = "Clear All Stats?" + alert.informativeText = + "This will permanently delete all recorded action history." + alert.alertStyle = .warning + alert.addButton(withTitle: "Clear") + alert.addButton(withTitle: "Cancel") + + if alert.runModal() == .alertFirstButtonReturn { + StatsManager.shared.clearAllStats() + loadStats() + } + } +} + +private struct GroupStats: Identifiable { + var id: String { fullPath } + let fullPath: String // Full path for unique ID + let groupKey: String // Just this level's key + let groupLabel: String + let totalCount: Int + let children: [GroupStatsChild] + let isGroup: Bool +} + +private enum GroupStatsChild: Identifiable { + case action(ActionStatsDisplay) + case group(GroupStats) + + var id: String { + switch self { + case .action(let display): + return display.id.uuidString + case .group(let group): + return group.id + } + } + + var executionCount: Int { + switch self { + case .action(let display): + return display.action.executionCount + case .group(let group): + return group.totalCount + } + } +} + +private struct ActionStatsDisplay: Identifiable { + let id = UUID() + let action: ActionStats + let pathComponents: [String] +} + +private struct GroupStatsRow: View { + let group: GroupStats + let isExpanded: Bool + let maxCount: Int + let depth: Int + @Binding var expandedGroups: Set + + private let indentPerLevel: CGFloat = 16 + + var body: some View { + if group.isGroup { + // Collapsible group with children + VStack(spacing: 2) { + // Group header + HStack(spacing: 0) { + // Indent spacer outside the button + if depth > 0 { + Spacer() + .frame(width: indentPerLevel * CGFloat(depth)) + } + + Button(action: { + if expandedGroups.contains(group.id) { + expandedGroups.remove(group.id) + } else { + expandedGroups.insert(group.id) + } + }) { + HStack(alignment: .center, spacing: 0) { + // Chevron + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.secondary) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.2), value: isExpanded) + .frame(width: 14) + + // Key badge + KeyBadgeView(key: group.groupKey) + .padding(.leading, 4) + + // Group name + Text(group.groupLabel) + .font(.system(.body)) + .foregroundColor(.primary) + .padding(.leading, 8) + + Spacer(minLength: 16) + + // Right-aligned section (always same position) + HStack(spacing: 0) { + // Progress bar + ProgressBarView(value: group.totalCount, max: maxCount) + .padding(.trailing, 20) + + // Folder icon + Image(systemName: "folder") + .resizable() + .scaledToFit() + .foregroundColor(.secondary) + .frame(width: 24, height: 24) + + // Count + Text("\(group.totalCount)") + .foregroundColor(.primary) + .font(.system(.body, design: .rounded)) + .fontWeight(.medium) + .monospacedDigit() + .frame(width: 36, alignment: .trailing) + } + } + .padding(.trailing, 18) + .padding(.vertical, 8) + .padding(.horizontal, 8) + .background( RoundedRectangle(cornerRadius: 8) + .fill(Color.secondary.opacity(0.1)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } + + // Expanded children + if isExpanded { + VStack(spacing: 2) { + ForEach(group.children) { child in + switch child { + case .action(let stat): + ActionStatsRow( + stat: stat, + maxCount: group.children.map(\.executionCount).max() ?? 1, + depth: depth + 1, + expandedGroups: $expandedGroups + ) + case .group(let subGroup): + GroupStatsRow( + group: subGroup, + isExpanded: expandedGroups.contains(subGroup.id), + maxCount: group.children.map(\.executionCount).max() ?? 1, + depth: depth + 1, + expandedGroups: $expandedGroups + ) + } + } + } + } + } + } else { + // Top-level individual action + if case .action(let stat) = group.children.first { + ActionStatsRow( + stat: stat, + maxCount: maxCount, + depth: depth, + expandedGroups: $expandedGroups + ) + } + } + } +} + +private struct ActionStatsRow: View { + let stat: ActionStatsDisplay + let maxCount: Int + let depth: Int + @Binding var expandedGroups: Set + + private let indentPerLevel: CGFloat = 16 + + var body: some View { + HStack(alignment: .center, spacing: 0) { + // Indent spacer (add extra indent since no chevron) + Spacer() + .frame(width: indentPerLevel * CGFloat(depth) + 14) + + // Key badge + if let lastKey = stat.pathComponents.last { + KeyBadgeView(key: lastKey) + .padding(.leading, 4) + } + + // Action name + Text(displayName) + .font(.system(.body)) + .lineLimit(1) + .truncationMode(.tail) + .padding(.leading, 8) + + Spacer(minLength: 16) + + // Right-aligned section (always same position) + HStack(spacing: 0) { + // Progress bar + ProgressBarView(value: stat.action.executionCount, max: maxCount) + .padding(.trailing, 20) + + // Icon + ActionIconView(actionType: stat.action.actionType, actionValue: stat.action.actionValue) + + // Count + Text("\(stat.action.executionCount)") + .foregroundColor(.primary) + .font(.system(.body, design: .rounded)) + .fontWeight(.medium) + .monospacedDigit() + .frame(width: 36, alignment: .trailing) + } + } + .padding(.trailing, 18) + .padding(.vertical, 8) + .padding(.horizontal, 8) + .contentShape(Rectangle()) + } + + private var displayName: String { + if let label = stat.action.actionLabel, !label.isEmpty { + return label + } + switch stat.action.actionType { + case "application": + return + (stat.action.actionValue as NSString).lastPathComponent + .replacingOccurrences(of: ".app", with: "") + case "folder": + return (stat.action.actionValue as NSString).lastPathComponent + default: + return stat.action.actionValue + } + } +} + +private struct ProgressBarView: View { + let value: Int + let max: Int + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.secondary.opacity(0.15)) + .frame(height: 6) + + RoundedRectangle(cornerRadius: 2) + .fill(Color.accentColor) + .frame( + width: geometry.size.width * CGFloat(value) / CGFloat(max), + height: 6 + ) + } + } + .frame(width: 100, height: 6) + } +} + +private struct KeyBadgeView: View { + let key: String + + var body: some View { + Text(KeyMaps.glyph(for: key) ?? key) + .font(.system(.callout, design: .monospaced)) + .fontWeight(.medium) + .foregroundColor(.primary) + .frame(width: 28, height: 28) + .background(Color.secondary.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } +} + +private struct ActionIconView: View { + let actionType: String + let actionValue: String + + private let iconSize: CGFloat = 24 + + var body: some View { + iconContent + .frame(width: iconSize, height: iconSize) + } + + @ViewBuilder + private var iconContent: some View { + switch actionType { + case "application": + AppIconImage(appPath: actionValue, size: NSSize(width: iconSize, height: iconSize)) + case "url": + FavIconImage(url: actionValue, icon: "link", size: NSSize(width: iconSize, height: iconSize)) + case "folder", "group": + Image(systemName: "folder") + .resizable() + .scaledToFit() + .foregroundColor(.secondary) + case "command": + Image(systemName: "terminal") + .resizable() + .scaledToFit() + .foregroundColor(.secondary) + default: + Image(systemName: "questionmark.circle") + .resizable() + .scaledToFit() + .foregroundColor(.secondary) + } + } +} diff --git a/Leader Key/Stats/StatsManager.swift b/Leader Key/Stats/StatsManager.swift new file mode 100644 index 00000000..beb6d71c --- /dev/null +++ b/Leader Key/Stats/StatsManager.swift @@ -0,0 +1,349 @@ +import Foundation + +class StatsManager { + static let shared = StatsManager() + + // Optimization: Only keep recent executions in memory + private var recentExecutions: [ActionExecution] = [] + private let maxRecentExecutions = 50 + + // Optimization: Pre-aggregate daily counts + private var dailyExecutions: [Date: Int] = [:] + + private var actionStatsCache: [String: ActionStats] = [:] + private var groupStatsCache: [String: GroupNavigationStats] = [:] + + private var totalExecutions: Int = 0 + private var totalNavigations: Int = 0 + + private let statsFilePath: String + private var fileHandle: FileHandle? + private let ioQueue = DispatchQueue(label: "com.leaderkey.StatsIO", qos: .utility) + private let lock = NSLock() + private let encoder = JSONEncoder() + + private init() { + let supportDir = UserConfig.defaultDirectory() + statsFilePath = (supportDir as NSString).appendingPathComponent("stats.jsonl") + setupStatsFile() + loadFromDisk() + } + + private func setupStatsFile() { + let fileManager = FileManager.default + + do { + try fileHandle?.close() + } catch { + print("Failed to close stats file: \(error)") + } + fileHandle = nil + + if !fileManager.fileExists(atPath: statsFilePath) { + fileManager.createFile(atPath: statsFilePath, contents: nil) + } + + do { + fileHandle = try FileHandle(forUpdating: URL(fileURLWithPath: statsFilePath)) + try fileHandle?.seekToEnd() + } catch { + print("Failed to open stats file: \(error)") + } + } + + private func actionStatsKey(for record: ActionExecution) -> String { + if !record.keyPath.isEmpty { + return record.keyPath + } + return "\(record.actionType)|\(record.actionValue)" + } + + private func groupStatsKey(for record: ActionExecution) -> String { + if !record.keyPath.isEmpty { + return record.keyPath + } + return record.actionValue + } + + private func loadFromDisk() { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: statsFilePath)) else { + return + } + + // TODO: For very large files, stream line-by-line instead of loading all at once + let lines = String(data: data, encoding: .utf8)?.components(separatedBy: .newlines) ?? [] + let decoder = JSONDecoder() + let calendar = Calendar.current + + for line in lines { + guard !line.isEmpty else { continue } + + do { + let record = try decoder.decode(ActionExecution.self, from: Data(line.utf8)) + updateAggregates(record) + + // Track daily stats + let day = calendar.startOfDay(for: record.timestamp) + dailyExecutions[day, default: 0] += 1 + + // Maintain fixed-size recent history + recentExecutions.append(record) + if recentExecutions.count > maxRecentExecutions { + recentExecutions.removeFirst() + } + } catch { + print("Failed to parse stats line: \(error)") + } + } + } + + private func updateAggregates(_ record: ActionExecution) { + if record.eventType == "action" { + totalExecutions += 1 + + let key = actionStatsKey(for: record) + if var existing = actionStatsCache[key] { + existing.executionCount += 1 + if record.timestamp > existing.lastExecuted { + existing.lastExecuted = record.timestamp + } + if (existing.actionLabel?.isEmpty ?? true), !(record.actionLabel?.isEmpty ?? true) { + existing.actionLabel = record.actionLabel + } + actionStatsCache[key] = existing + } else { + actionStatsCache[key] = ActionStats( + actionValue: record.actionValue, + actionType: record.actionType, + actionLabel: record.actionLabel, + keyPath: record.keyPath, + executionCount: 1, + lastExecuted: record.timestamp + ) + } + } else if record.eventType == "group" { + totalNavigations += 1 + + let key = groupStatsKey(for: record) + if var existing = groupStatsCache[key] { + existing.navigationCount += 1 + if record.timestamp > existing.lastNavigated { + existing.lastNavigated = record.timestamp + } + if (existing.groupLabel?.isEmpty ?? true), !(record.actionLabel?.isEmpty ?? true) { + existing.groupLabel = record.actionLabel + } + groupStatsCache[key] = existing + } else { + groupStatsCache[key] = GroupNavigationStats( + groupKey: record.actionValue, + groupLabel: record.actionLabel, + keyPath: record.keyPath, + navigationCount: 1, + lastNavigated: record.timestamp + ) + } + } + } + + private func appendToFile(_ record: ActionExecution) { + ioQueue.async { [weak self] in + guard let self = self else { return } + + guard let jsonData = try? self.encoder.encode(record) else { + print("Failed to encode stats record") + return + } + + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + return + } + + let line = jsonString + "\n" + guard let lineData = line.data(using: .utf8) else { return } + + do { + try self.fileHandle?.write(contentsOf: lineData) + } catch { + print("Failed to write stats record: \(error)") + } + } + } + + // MARK: - Recording + + func recordExecution(action: Action, keyPath: String) { + let record = ActionExecution( + actionType: action.type.rawValue, + actionValue: action.value, + actionLabel: action.label, + keyPath: keyPath, + eventType: "action", + timestamp: Date() + ) + + lock.lock() + updateAggregates(record) + + let calendar = Calendar.current + let day = calendar.startOfDay(for: record.timestamp) + dailyExecutions[day, default: 0] += 1 + + recentExecutions.append(record) + if recentExecutions.count > maxRecentExecutions { + recentExecutions.removeFirst() + } + lock.unlock() + + appendToFile(record) + } + + func recordGroupNavigation(group: Group, keyPath: String) { + let record = ActionExecution( + actionType: "group", + actionValue: group.key ?? "", + actionLabel: group.label, + keyPath: keyPath, + eventType: "group", + timestamp: Date() + ) + + lock.lock() + updateAggregates(record) + + // Also track navigations in daily stats? + // The previous implementation included everything in 'executions' array but getExecutionsPerDay iterated all. + // Let's include them to maintain behavior. + let calendar = Calendar.current + let day = calendar.startOfDay(for: record.timestamp) + dailyExecutions[day, default: 0] += 1 + + recentExecutions.append(record) + if recentExecutions.count > maxRecentExecutions { + recentExecutions.removeFirst() + } + lock.unlock() + + appendToFile(record) + } + + // MARK: - Queries + + func getMostUsedActions(limit: Int = 20) -> [ActionStats] { + lock.lock() + defer { lock.unlock() } + + return Array(actionStatsCache.values) + .sorted { $0.executionCount > $1.executionCount } + .prefix(limit) + .map { $0 } + } + + func getMostNavigatedGroups(limit: Int = 20) -> [GroupNavigationStats] { + lock.lock() + defer { lock.unlock() } + + return Array(groupStatsCache.values) + .sorted { $0.navigationCount > $1.navigationCount } + .prefix(limit) + .map { $0 } + } + + func getRecentActivity(limit: Int = 50) -> [ActionExecution] { + lock.lock() + defer { lock.unlock() } + + // Return reversed so newest is first + return Array(recentExecutions.suffix(limit).reversed()) + } + + func getTotalExecutions() -> Int { + lock.lock() + defer { lock.unlock() } + + return totalExecutions + } + + func getTotalNavigations() -> Int { + lock.lock() + defer { lock.unlock() } + + return totalNavigations + } + + func getExecutionsPerDay(days: Int = 30) -> [(date: Date, count: Int)] { + lock.lock() + defer { lock.unlock() } + + let calendar = Calendar.current + guard let cutoffDate = calendar.date(byAdding: .day, value: -days, to: Date()) else { + return [] + } + + // Filter from pre-aggregated dictionary + return dailyExecutions + .filter { $0.key >= cutoffDate } + .sorted { $0.key < $1.key } + .map { (date: $0.key, count: $0.value) } + } + + func getTodayCount() -> Int { + lock.lock() + defer { lock.unlock() } + + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + return dailyExecutions[today] ?? 0 + } + + func clearAllStats() { + lock.lock() + recentExecutions.removeAll() + dailyExecutions.removeAll() + actionStatsCache.removeAll() + groupStatsCache.removeAll() + totalExecutions = 0 + totalNavigations = 0 + lock.unlock() + + ioQueue.async { [weak self] in + guard let self = self else { return } + + do { + try self.fileHandle?.close() + } catch { + print("Failed to close stats file: \(error)") + } + self.fileHandle = nil + + try? FileManager.default.removeItem(atPath: self.statsFilePath) + self.setupStatsFile() + } + } +} + +struct ActionExecution: Codable { + var actionType: String + var actionValue: String + var actionLabel: String? + var keyPath: String + var eventType: String + var timestamp: Date +} + +struct ActionStats { + var actionValue: String + var actionType: String + var actionLabel: String? + var keyPath: String + var executionCount: Int + var lastExecuted: Date +} + +struct GroupNavigationStats { + var groupKey: String + var groupLabel: String? + var keyPath: String + var navigationCount: Int + var lastNavigated: Date +}