mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
- Added LiveMonitor class to scan text areas in the frontmost window for grammar and spelling issues. - Introduced DetectedField struct to represent text areas with issues, including suggestions for corrections. - Implemented functionality to apply corrections and dismiss suggestions. - Added Permissions class to manage Accessibility permissions required for monitoring. - Created SnippetStore for managing text-expansion snippets, including persistence to UserDefaults. - Developed TextCapture class to read and write text from the frontmost application using Accessibility API and clipboard fallback. - Implemented TextFieldScanner to traverse the Accessibility tree and collect editable text elements. - Added TextReplacementCoordinator to orchestrate the text correction process. - Integrated UpdaterManager for handling application updates using Sparkle. - Created comprehensive unit tests for various components, including grammar corrections and entitlement checks.
155 lines
5.8 KiB
Swift
155 lines
5.8 KiB
Swift
import Foundation
|
|
|
|
/// Privacy-friendly, fully-local usage insights. Counts *how often* features are
|
|
/// used — never *what* was written. Stored only on this Mac.
|
|
@MainActor
|
|
final class UsageStats: ObservableObject {
|
|
struct Snapshot: Codable, Equatable {
|
|
var totalCorrections = 0
|
|
var totalAIActions = 0
|
|
var totalWords = 0
|
|
var activeDays = 0 // distinct days with ≥1 action (lifetime)
|
|
var firstUseDay: String? // "yyyy-MM-dd" of first ever action
|
|
var perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day
|
|
}
|
|
|
|
/// What a Grammarly-style subscription runs per year (USD) — used only for the
|
|
/// honest, local "subscription you didn't pay" figure. GrammaGem is one-time.
|
|
static let comparableAnnualSubscriptionUSD = 144.0
|
|
|
|
@Published private(set) var data: Snapshot
|
|
|
|
private let key = "GrammaGem.usageStats"
|
|
private let d = UserDefaults.standard
|
|
|
|
private static let fmt: DateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
f.locale = Locale(identifier: "en_US_POSIX")
|
|
f.timeZone = .current
|
|
return f
|
|
}()
|
|
|
|
init() {
|
|
if let raw = d.data(forKey: key),
|
|
let decoded = try? JSONDecoder().decode(Snapshot.self, from: raw) {
|
|
data = decoded
|
|
} else {
|
|
data = Snapshot()
|
|
}
|
|
}
|
|
|
|
func recordCorrection(words: Int) {
|
|
data.totalCorrections += 1
|
|
data.totalWords += max(0, words)
|
|
bumpToday()
|
|
persist()
|
|
}
|
|
|
|
func recordAIAction(words: Int) {
|
|
data.totalAIActions += 1
|
|
data.totalWords += max(0, words)
|
|
bumpToday()
|
|
persist()
|
|
}
|
|
|
|
/// A playful, clearly-estimated "time saved" figure (local only).
|
|
var estimatedMinutesSaved: Int {
|
|
(data.totalCorrections * 8 + data.totalAIActions * 45) / 60
|
|
}
|
|
|
|
// MARK: - Value loop (streak / milestones / subscription-you-didn't-pay)
|
|
|
|
/// Consecutive days, ending today or yesterday, with at least one action.
|
|
var currentStreak: Int { Self.streak(perDay: data.perDay, asOf: Date()) }
|
|
|
|
/// The Grammarly subscription you DIDN'T pay: their annual price prorated over
|
|
/// how long you've owned GrammaGem. Honest, local, illustrative — and it only
|
|
/// grows, which is the point of a one-time purchase (see the value plan §5).
|
|
var subscriptionAvoidedUSD: Int {
|
|
Self.subscriptionAvoidedUSD(
|
|
firstUseDay: data.firstUseDay, asOf: Date(),
|
|
annualPrice: Self.comparableAnnualSubscriptionUSD)
|
|
}
|
|
|
|
/// The next correction milestone and progress toward it (for a nudge/badge).
|
|
var nextMilestone: (target: Int, label: String, progress: Double) {
|
|
Self.nextMilestone(corrections: data.totalCorrections)
|
|
}
|
|
|
|
// MARK: - Pure helpers (unit-testable; no UserDefaults / wall clock)
|
|
|
|
static func streak(perDay: [String: Int], asOf now: Date) -> Int {
|
|
let cal = Calendar.current
|
|
func used(_ day: Date) -> Bool { (perDay[fmt.string(from: day)] ?? 0) > 0 }
|
|
var day = now
|
|
if !used(day) {
|
|
// Streak is still alive if used yesterday but not yet today.
|
|
day = cal.date(byAdding: .day, value: -1, to: day) ?? day
|
|
if !used(day) { return 0 }
|
|
}
|
|
var streak = 0
|
|
while used(day) {
|
|
streak += 1
|
|
day = cal.date(byAdding: .day, value: -1, to: day) ?? day
|
|
}
|
|
return streak
|
|
}
|
|
|
|
static func subscriptionAvoidedUSD(firstUseDay: String?, asOf now: Date, annualPrice: Double) -> Int {
|
|
guard let first = firstUseDay, let firstDate = fmt.date(from: first) else { return 0 }
|
|
let days = max(1, Calendar.current.dateComponents([.day], from: firstDate, to: now).day ?? 0)
|
|
return Int((Double(days) / 365.0 * annualPrice).rounded())
|
|
}
|
|
|
|
static func nextMilestone(corrections: Int) -> (target: Int, label: String, progress: Double) {
|
|
let targets = [100, 500, 1_000, 5_000, 10_000, 50_000, 100_000]
|
|
let target = targets.first { $0 > corrections } ?? (((corrections / 100_000) + 1) * 100_000)
|
|
let previous = targets.last { $0 <= corrections } ?? 0
|
|
let span = max(1, target - previous)
|
|
let progress = min(1.0, Double(corrections - previous) / Double(span))
|
|
let label = target >= 1_000 ? "\(target / 1_000)k corrections" : "\(target) corrections"
|
|
return (target, label, progress)
|
|
}
|
|
|
|
/// Counts for the last 7 calendar days, oldest → newest, for a small chart.
|
|
func last7Days() -> [(label: String, count: Int)] {
|
|
let cal = Calendar.current
|
|
let today = Date()
|
|
return (0..<7).reversed().map { offset in
|
|
let day = cal.date(byAdding: .day, value: -offset, to: today) ?? today
|
|
let key = Self.fmt.string(from: day)
|
|
let short = day.formatted(.dateTime.weekday(.narrow))
|
|
return (label: short, count: data.perDay[key] ?? 0)
|
|
}
|
|
}
|
|
|
|
func reset() {
|
|
data = Snapshot()
|
|
persist()
|
|
}
|
|
|
|
private func bumpToday() {
|
|
let k = Self.fmt.string(from: Date())
|
|
if data.perDay[k] == nil { // first action on a brand-new day
|
|
data.activeDays += 1
|
|
if data.firstUseDay == nil { data.firstUseDay = k }
|
|
}
|
|
data.perDay[k, default: 0] += 1
|
|
// Keep only ~60 days of history.
|
|
if data.perDay.count > 70 {
|
|
let keep = Set((0..<60).compactMap { off -> String? in
|
|
guard let day = Calendar.current.date(byAdding: .day, value: -off, to: Date()) else { return nil }
|
|
return Self.fmt.string(from: day)
|
|
})
|
|
data.perDay = data.perDay.filter { keep.contains($0.key) }
|
|
}
|
|
}
|
|
|
|
private func persist() {
|
|
if let raw = try? JSONEncoder().encode(data) {
|
|
d.set(raw, forKey: key)
|
|
}
|
|
}
|
|
}
|