Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions Sources/VocaMac/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ final class AppState: ObservableObject {
let modelManager: ModelManaging
let soundManager: SoundPlaying
let cursorOverlay: CursorOverlayManaging
let statsManager: StatsManaging
let updateChecker = UpdateChecker()
let permissionManager: any PermissionManaging

Expand Down Expand Up @@ -147,6 +148,7 @@ final class AppState: ObservableObject {
modelManager: ModelManaging = ModelManager(),
soundManager: SoundPlaying = SoundManager(),
cursorOverlay: CursorOverlayManaging,
statsManager: StatsManaging,
permissionManager: (any PermissionManaging)? = nil,
skipSystemIntegration: Bool = false
) {
Expand All @@ -157,6 +159,7 @@ final class AppState: ObservableObject {
self.modelManager = modelManager
self.soundManager = soundManager
self.cursorOverlay = cursorOverlay
self.statsManager = statsManager
self.permissionManager = permissionManager ?? PermissionManager(audioEngine: audioEngine, hotKeyManager: hotKeyManager)
self.skipSystemIntegration = skipSystemIntegration

Expand All @@ -172,6 +175,14 @@ final class AppState: ObservableObject {
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)

// Forward statsManager changes
statsManager.objectWillChangePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}

/// Single production AppState instance for the process.
Expand All @@ -181,7 +192,10 @@ final class AppState: ObservableObject {
/// stored-property initialization prevents duplicate service graphs, event
/// taps, audio observers, and stale SwiftUI environment objects.
@MainActor
private static let sharedProductionInstance = AppState(cursorOverlay: CursorOverlayManager())
private static let sharedProductionInstance = AppState(
cursorOverlay: CursorOverlayManager(),
statsManager: StatsManager()
)

/// Convenience factory for creating AppState with all real services.
/// Needed because CursorOverlayManager is @MainActor and can't be a default parameter.
Expand Down Expand Up @@ -513,7 +527,10 @@ final class AppState: ObservableObject {

lastTranscription = result

// Inject text at cursor position (text is already filtered
// Update stats
statsManager.recordTranscription(result)

// Inject text at cursor position
// by WhisperService to remove hallucination tokens like [BLANK_AUDIO])
let trimmedText = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedText.isEmpty {
Expand Down
40 changes: 40 additions & 0 deletions Sources/VocaMac/Models/UserStats.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// UserStats.swift
// VocaMac
//
// Data model for tracking user usage statistics.

import Foundation

struct UserStats: Codable {
/// Total number of words transcribed across all sessions
var totalWords: Int = 0

/// Total number of successful transcriptions performed
var totalTranscriptions: Int = 0

/// Total duration of audio recorded in seconds
var totalAudioDurationSeconds: Double = 0

/// Date of the most recent transcription
var lastUsageDate: Date?

/// Current consecutive days of usage
var currentStreak: Int = 0

/// Highest consecutive days of usage recorded
var bestStreak: Int = 0

/// Daily word counts to calculate trends and streaks
/// Key is date string in "yyyy-MM-dd" format
var dailyWordCounts: [String: Int] = [:]

/// Daily duration to calculate WPM history
var dailyDurationSeconds: [String: Double] = [:]

/// Calculated average Words Per Minute (WPM)
var averageWPM: Double {
Comment thread
Mr-Sunglasses marked this conversation as resolved.
guard totalAudioDurationSeconds > 0 else { return 0 }
let minutes = totalAudioDurationSeconds / 60.0
return Double(totalWords) / minutes
}
}
10 changes: 10 additions & 0 deletions Sources/VocaMac/Services/ServiceProtocols.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,13 @@ extension SpeechTranscribing {
protocol TextInjecting: AnyObject {
func inject(text: String, preserveClipboard: Bool)
}

// MARK: - StatsManaging

@MainActor
protocol StatsManaging: AnyObject {
var stats: UserStats { get }
var objectWillChangePublisher: AnyPublisher<Void, Never> { get }
func recordTranscription(_ transcription: VocaTranscription)
func resetStats()
}
126 changes: 126 additions & 0 deletions Sources/VocaMac/Services/StatsManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// StatsManager.swift
// VocaMac
//
// Manages the persistence and updating of user statistics.

import Foundation
import Combine

@MainActor
class StatsManager: StatsManaging, ObservableObject {
@Published private(set) var stats: UserStats = UserStats()

var objectWillChangePublisher: AnyPublisher<Void, Never> {
objectWillChange.eraseToAnyPublisher()
}

private let fileManager = FileManager.default
private let statsFileName = "stats.json"

init() {
loadStats()
}

private var statsFileURL: URL {
let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let vMacDir = appSupport.appendingPathComponent("VocaMac", isDirectory: true)

// Ensure directory exists
if !fileManager.fileExists(atPath: vMacDir.path) {
try? fileManager.createDirectory(at: vMacDir, withIntermediateDirectories: true)
}

return vMacDir.appendingPathComponent(statsFileName)
}

private func loadStats() {
do {
if fileManager.fileExists(atPath: statsFileURL.path) {
let data = try Data(contentsOf: statsFileURL)
stats = try JSONDecoder().decode(UserStats.self, from: data)
VocaLogger.debug(.general, "User stats loaded from disk")
} else {
VocaLogger.info(.general, "No stats file found, starting fresh")
}
} catch {
VocaLogger.error(.general, "Failed to load stats: \(error.localizedDescription)")
}
}

private func saveStats() {
do {
let data = try JSONEncoder().encode(stats)
try data.write(to: statsFileURL, options: .atomic)
VocaLogger.debug(.general, "User stats saved to disk")
} catch {
VocaLogger.error(.general, "Failed to save stats: \(error.localizedDescription)")
}
}

func recordTranscription(_ transcription: VocaTranscription) {
let text = transcription.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }

// Estimate word count
let words = text.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.count

let dateKey = formatDate(transcription.timestamp)
Comment thread
Mr-Sunglasses marked this conversation as resolved.
Outdated

// Update basic counts
stats.totalWords += words
stats.totalTranscriptions += 1
stats.totalAudioDurationSeconds += transcription.audioLengthSeconds

// Update daily stats
stats.dailyWordCounts[dateKey, default: 0] += words
stats.dailyDurationSeconds[dateKey, default: 0] += transcription.audioLengthSeconds

// Update streaks
updateStreaks(currentDate: transcription.timestamp)

stats.lastUsageDate = transcription.timestamp

saveStats()
}

func resetStats() {
stats = UserStats()
saveStats()
}

private func updateStreaks(currentDate: Date) {
guard let lastDate = stats.lastUsageDate else {
// First time usage
stats.currentStreak = 1
stats.bestStreak = 1
return
}

let calendar = Calendar.current

// Check if last usage was yesterday
if calendar.isDateInYesterday(lastDate) {
Comment thread
Mr-Sunglasses marked this conversation as resolved.
// Continuation of streak
if !calendar.isDate(lastDate, inSameDayAs: currentDate) {
stats.currentStreak += 1
}
} else if calendar.isDate(lastDate, inSameDayAs: currentDate) {
// Already used today, streak remains same
} else {
// Streak broken
stats.currentStreak = 1
}

if stats.currentStreak > stats.bestStreak {
stats.bestStreak = stats.currentStreak
}
}

private func formatDate(_ date: Date) -> String {
Comment thread
Mr-Sunglasses marked this conversation as resolved.
Outdated
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
}
5 changes: 5 additions & 0 deletions Sources/VocaMac/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ struct SettingsView: View {
Label("Models", systemImage: "brain")
}

StatsSettingsTab()
.tabItem {
Label("Stats", systemImage: "chart.xyaxis.line")
}

AudioSettingsTab()
.tabItem {
Label("Audio", systemImage: "waveform")
Expand Down
Loading
Loading