grammargem/Sources/GrammaGem/Stats/UsageStats.swift
BigMarc a7bf45b466 Add management window; sales-focused README; FounderGem LLC
- Management app: sidebar window with Dashboard (usage insights), Devices
  (slot usage, deactivate this Mac), Writing Modes (custom + app-aware map),
  Dictionary, Snippets, Shortcuts, AI Model, Privacy, General, License, About.
- New local stores: Preferences (launch-at-login, appearance), UsageStats
  (privacy-friendly local insights), CustomModesStore, SnippetStore.
- License model now tracks device name + activation usage/limit for the
  Devices screen.
- README rewritten as a conversion-focused product page (no internal cost/setup).
- Legal entity set to FounderGem LLC across README, LICENSE, Info.plist, About.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:28:23 +02:00

91 lines
2.7 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 perDay: [String: Int] = [:] // "yyyy-MM-dd" -> actions that day
}
@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
}
/// 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())
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)
}
}
}