Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
32 changes: 32 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cotabby/App/Coordinators/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
private let huggingFaceSearchService: HuggingFaceSearchService
private let performanceMetricsStore: PerformanceMetricsStore
private let systemMetricsStore: SystemMetricsStore
private let usageAnalyticsStore: UsageAnalyticsStore
private let onShowWelcome: () -> Void
private let clearEmojiHistory: () -> Void

Expand All @@ -37,6 +38,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
huggingFaceSearchService: HuggingFaceSearchService,
performanceMetricsStore: PerformanceMetricsStore,
systemMetricsStore: SystemMetricsStore,
usageAnalyticsStore: UsageAnalyticsStore,
onShowWelcome: @escaping () -> Void,
clearEmojiHistory: @escaping () -> Void
) {
Expand All @@ -50,6 +52,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
self.huggingFaceSearchService = huggingFaceSearchService
self.performanceMetricsStore = performanceMetricsStore
self.systemMetricsStore = systemMetricsStore
self.usageAnalyticsStore = usageAnalyticsStore
self.onShowWelcome = onShowWelcome
self.clearEmojiHistory = clearEmojiHistory
}
Expand Down Expand Up @@ -77,6 +80,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
huggingFaceSearchService: huggingFaceSearchService,
performanceMetricsStore: performanceMetricsStore,
systemMetricsStore: systemMetricsStore,
usageAnalyticsStore: usageAnalyticsStore,
onShowWelcome: onShowWelcome,
clearEmojiHistory: clearEmojiHistory
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,11 @@ extension SuggestionCoordinator {

totalTabAcceptedWordCount += acceptedWordCount
userDefaults.set(totalTabAcceptedWordCount, forKey: Self.totalTabAcceptedWordCountDefaultsKey)

// The same word-bearing accept also feeds the per-day Usage pane (issue #489). Characters use
// the accepted chunk's length, so a CJK accept (which counts as a single word here, mirroring
// the menu-bar total) still reflects its true size. Only counts are stored, never the text.
usageAnalyticsStore.recordAcceptance(words: acceptedWordCount, characters: acceptedChunk.count)
}

// MARK: - Caret Prediction
Expand Down
5 changes: 5 additions & 0 deletions Cotabby/App/Coordinators/SuggestionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ final class SuggestionCoordinator: ObservableObject {
let workController: SuggestionWorkController
let configuration: SuggestionConfiguration
let userDefaults: UserDefaults
/// Local, privacy-preserving usage stats (issue #489). Written at accept time alongside the
/// `totalTabAcceptedWordCount` total and read by the Usage settings pane.
let usageAnalyticsStore: UsageAnalyticsStore
let overlayPresenter: SuggestionOverlayPresenter
let logger: SuggestionDebugLogger
/// Drives the typo gate before each prediction. Owned at app scope (constructed once in
Expand Down Expand Up @@ -133,6 +136,7 @@ final class SuggestionCoordinator: ObservableObject {
configuration: SuggestionConfiguration,
spellChecker: CurrentWordSpellChecker,
symSpellCorrector: SymSpellCorrector,
usageAnalyticsStore: UsageAnalyticsStore,
spellingLanguageResolver: SpellingLanguageResolver = SpellingLanguageResolver(),
userDefaults: UserDefaults = .standard
) {
Expand All @@ -156,6 +160,7 @@ final class SuggestionCoordinator: ObservableObject {
self.symSpellCorrector = symSpellCorrector
self.spellingLanguageResolver = spellingLanguageResolver
self.userDefaults = userDefaults
self.usageAnalyticsStore = usageAnalyticsStore
settingsSnapshot = suggestionSettings.snapshot
// These collaborators isolate "how overlay/logging works" from "when the coordinator
// wants to show state," which keeps the coordinator closer to orchestration code.
Expand Down
8 changes: 8 additions & 0 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ final class CotabbyAppEnvironment {
let welcomeCoordinator: WelcomeCoordinator
let huggingFaceSearchService: HuggingFaceSearchService
let performanceMetricsStore: PerformanceMetricsStore
let usageAnalyticsStore: UsageAnalyticsStore
let settingsCoordinator: SettingsCoordinator
let activationIndicatorController: ActivationIndicatorController
let focusDebugOverlayController: FocusDebugOverlayController?
Expand Down Expand Up @@ -108,6 +109,10 @@ final class CotabbyAppEnvironment {
)
let huggingFaceSearchService = HuggingFaceSearchService()
let performanceMetricsStore = PerformanceMetricsStore()
// Local accepted-suggestion stats for the Usage pane (issue #489). Constructed before both
// the suggestion coordinator (which records into it on accept) and the settings coordinator
// (whose pane reads it) so the single instance is shared by writer and reader.
let usageAnalyticsStore = UsageAnalyticsStore()
// Live CPU/RAM graph backing for the Performance pane. Holds no state until the pane asks it
// to start sampling, so constructing it eagerly here costs nothing.
let systemMetricsStore = SystemMetricsStore()
Expand Down Expand Up @@ -166,6 +171,7 @@ final class CotabbyAppEnvironment {
huggingFaceSearchService: huggingFaceSearchService,
performanceMetricsStore: performanceMetricsStore,
systemMetricsStore: systemMetricsStore,
usageAnalyticsStore: usageAnalyticsStore,
onShowWelcome: { [weak welcomeCoordinator] in
welcomeCoordinator?.showWelcome()
},
Expand Down Expand Up @@ -204,6 +210,7 @@ final class CotabbyAppEnvironment {
configuration: configuration,
spellChecker: spellChecker,
symSpellCorrector: symSpellCorrector,
usageAnalyticsStore: usageAnalyticsStore,
spellingLanguageResolver: SpellingLanguageResolver()
)

Expand Down Expand Up @@ -264,6 +271,7 @@ final class CotabbyAppEnvironment {
self.welcomeCoordinator = welcomeCoordinator
self.huggingFaceSearchService = huggingFaceSearchService
self.performanceMetricsStore = performanceMetricsStore
self.usageAnalyticsStore = usageAnalyticsStore
self.settingsCoordinator = settingsCoordinator
self.activationIndicatorController = activationIndicatorController
self.focusDebugOverlayController = FocusDebugOverlayController.isEnabled
Expand Down
63 changes: 63 additions & 0 deletions Cotabby/Models/UsageAnalyticsModels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// File overview:
/// Value types for the local usage-analytics feature (issue #489). These are the entire data model
/// the Usage settings pane and `UsageAnalyticsStore` share: aggregated per-day tallies, a totals
/// roll-up, and the time ranges the pane can show. They are pure value types so the bucketing math in
/// `UsageAnalyticsAggregator` stays trivially testable.

/// One calendar day's accepted-suggestion tallies. `day` is the start of that day (the bucket key) in
/// the calendar that recorded it; the counters are cumulative for the day.
///
/// This struct is the *complete* persisted analytics surface. No accepted text, prompt, OCR,
/// screenshot, app identity, or timestamp finer than the day is ever stored, which is what keeps the
/// feature local usage stats rather than telemetry.
struct UsageAnalyticsDailyBucket: Codable, Equatable, Identifiable, Sendable {
/// Start of the calendar day this bucket aggregates. Doubles as the stable identity, since the
/// aggregator guarantees at most one bucket per day.
var day: Date
/// Number of accepted suggestion chunks committed on this day (one per accept gesture).
var acceptances: Int
/// Word-like tokens across those accepted chunks, counted the same way as the menu-bar total.
var words: Int
/// Characters across those accepted chunks (grapheme count of the accepted text).
var characters: Int

var id: Date { day }
}

/// A roll-up of bucket counters across some time range: the three numbers the pane renders.
struct UsageAnalyticsTotals: Equatable, Sendable {
var acceptances: Int
var words: Int
var characters: Int

static let zero = UsageAnalyticsTotals(acceptances: 0, words: 0, characters: 0)
}

/// The windows the Usage pane can summarize. `dayWindow` is the inclusive number of calendar days
/// back from today (so `.last7Days` is today plus the previous six), or `nil` for all recorded
/// history.
enum UsageAnalyticsRange: String, CaseIterable, Identifiable, Sendable {
case last7Days
case last30Days
case allTime

var id: String { rawValue }

var label: String {
switch self {
case .last7Days: return "Last 7 Days"
case .last30Days: return "Last 30 Days"
case .allTime: return "All Time"
}
}

var dayWindow: Int? {
switch self {
case .last7Days: return 7
case .last30Days: return 30
case .allTime: return nil
}
}
}
105 changes: 105 additions & 0 deletions Cotabby/Models/UsageAnalyticsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import Combine
import Foundation

/// Narrow persistence surface so the store can be unit-tested against an in-memory stand-in instead
/// of process-global `UserDefaults` (shared across tests and unreliable to mutate from a sandboxed
/// test host). `UserDefaults` already satisfies it, so production wiring is unchanged. Mirrors
/// `EmojiUsageDefaults`.
protocol UsageAnalyticsDefaults: AnyObject {
func data(forKey defaultName: String) -> Data?
func set(_ value: Any?, forKey defaultName: String)
func removeObject(forKey defaultName: String)
}

extension UserDefaults: UsageAnalyticsDefaults {}

/// File overview:
/// Persists local, privacy-preserving usage analytics for issue #489: per-day tallies of how many
/// suggestion chunks the user accepted and how many words and characters those chunks contained.
/// Backs the Usage settings pane and is written from `SuggestionCoordinator` at accept time.
///
/// What it deliberately never stores: any accepted text, prompt, OCR, screenshot, app identity, or
/// timestamp finer than the calendar day. The whole on-disk surface is `[day, acceptances, words,
/// characters]` rows, so it can only answer "how much did autocomplete help" and never "what did you
/// write".
///
/// `@MainActor` because the sole writer is the main-actor `SuggestionCoordinator` at commit time and
/// the sole reader is the main-actor settings pane. State is a single JSON blob so the read/write is
/// atomic.
///
/// The `deinit` is `nonisolated` to dodge the macOS 14 isolated-deinit back-deploy crash that
/// over-releases a `@MainActor` class with non-trivial stored properties and aborts the app-hosted
/// unit tests (see `EmojiUsageStore` for the full rationale).
@MainActor
final class UsageAnalyticsStore: ObservableObject {
/// Day-sorted (oldest first) tallies. `private(set)` so only `recordAcceptance`/`clear` mutate it.
@Published private(set) var buckets: [UsageAnalyticsDailyBucket]

private let defaults: UsageAnalyticsDefaults
private let calendar: Calendar
private static let storageKey = "cotabbyUsageAnalytics"

/// Versioned envelope so a future schema change can migrate the blob instead of silently
/// discarding it.
private struct Persisted: Codable {
var version: Int
var buckets: [UsageAnalyticsDailyBucket]
}
private static let currentVersion = 1

init(defaults: UsageAnalyticsDefaults = UserDefaults.standard, calendar: Calendar = .current) {
self.defaults = defaults
self.calendar = calendar
if let data = defaults.data(forKey: Self.storageKey),
let decoded = try? JSONDecoder().decode(Persisted.self, from: data) {
buckets = decoded.buckets.sorted { $0.day < $1.day }
} else {
buckets = []
}
}

// See the type doc comment: avoids the macOS 14 isolated-deinit back-deploy crash.
nonisolated deinit {}

/// Records one accepted suggestion chunk. `words` and `characters` come from the accepted text;
/// the coordinator reuses `SuggestionSessionReconciler.acceptedWordCount` for `words` so this
/// agrees with the menu-bar total. A fully empty accept is a no-op so it cannot inflate the
/// acceptance count.
func recordAcceptance(words: Int, characters: Int, date: Date = Date()) {
guard words > 0 || characters > 0 else { return }
buckets = UsageAnalyticsAggregator.recording(
words: words,
characters: characters,
on: date,
into: buckets,
calendar: calendar
)
persist()
}

/// Totals for `range`, relative to `now` (injectable so tests can pin "today").
func totals(in range: UsageAnalyticsRange, now: Date = Date()) -> UsageAnalyticsTotals {
UsageAnalyticsAggregator.totals(in: buckets, range: range, now: now, calendar: calendar)
}

/// Dense, zero-filled per-day buckets for the last `days` days, oldest first. Drives the chart.
func recentDailyBuckets(days: Int, now: Date = Date()) -> [UsageAnalyticsDailyBucket] {
UsageAnalyticsAggregator.dailyBuckets(from: buckets, days: days, now: now, calendar: calendar)
}

/// Forgets all recorded analytics. Backs the pane's "Reset Stats" control.
func clear() {
guard !buckets.isEmpty else { return }
buckets = []
defaults.removeObject(forKey: Self.storageKey)
}

private func persist() {
guard let data = try? JSONEncoder().encode(
Persisted(version: Self.currentVersion, buckets: buckets)
) else {
return
}
defaults.set(data, forKey: Self.storageKey)
}
}
3 changes: 2 additions & 1 deletion Cotabby/Support/SettingsAttentionEvaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ enum SettingsAttentionEvaluator {
return reason
}

case .home, .general, .appearance, .emoji, .writing, .context, .shortcuts, .apps, .performance, .about:
case .home, .general, .appearance, .emoji, .writing, .context,
.shortcuts, .apps, .performance, .usage, .about:
return nil
}
}
Expand Down
101 changes: 101 additions & 0 deletions Cotabby/Support/UsageAnalyticsAggregator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Foundation

/// File overview:
/// Pure date-bucketing and aggregation rules for usage analytics (issue #489), split out from
/// `UsageAnalyticsStore` so the counting math is unit-testable without `UserDefaults` or the main
/// actor. Every entry point takes its `calendar` and reference `now` explicitly: tests pin them to a
/// fixed gregorian/UTC calendar and fixed dates, while production passes `.current` and `Date()`.
enum UsageAnalyticsAggregator {
/// Start of the calendar day containing `date`. This is the bucket key, so two accepts on the
/// same local day fold into one bucket regardless of wall-clock time.
static func dayStart(for date: Date, calendar: Calendar) -> Date {
calendar.startOfDay(for: date)
}

/// Folds one acceptance into `buckets`, returning the updated, day-sorted array. Adds to the
/// existing bucket for `date`'s day when present, otherwise inserts a new one. Word/character
/// counts are clamped non-negative so a malformed persisted blob can never drag a total below
/// zero.
static func recording(
words: Int,
characters: Int,
on date: Date,
into buckets: [UsageAnalyticsDailyBucket],
calendar: Calendar
) -> [UsageAnalyticsDailyBucket] {
let key = dayStart(for: date, calendar: calendar)
let addedWords = max(0, words)
let addedCharacters = max(0, characters)

var updated = buckets
if let index = updated.firstIndex(where: { $0.day == key }) {
updated[index].acceptances += 1
updated[index].words += addedWords
updated[index].characters += addedCharacters
return updated
}

updated.append(
UsageAnalyticsDailyBucket(
day: key,
acceptances: 1,
words: addedWords,
characters: addedCharacters
)
)
updated.sort { $0.day < $1.day }
return updated
}

/// Sums the buckets that fall within `range` relative to `now`. `.allTime` includes everything;
/// a windowed range includes the bucket for today plus the previous `dayWindow - 1` days.
static func totals(
in buckets: [UsageAnalyticsDailyBucket],
range: UsageAnalyticsRange,
now: Date,
calendar: Calendar
) -> UsageAnalyticsTotals {
let included: [UsageAnalyticsDailyBucket]
if let window = range.dayWindow, let cutoff = cutoffDay(window: window, now: now, calendar: calendar) {
included = buckets.filter { $0.day >= cutoff }
} else {
included = buckets
}

return included.reduce(into: .zero) { totals, bucket in
totals.acceptances += bucket.acceptances
totals.words += bucket.words
totals.characters += bucket.characters
}
}

/// A dense, day-sorted series for the last `days` calendar days ending today (oldest first), with
/// any day that has no recorded activity filled in as a zero bucket. Drives the pane's bar chart
/// so gaps render as empty bars instead of collapsing the axis.
static func dailyBuckets(
from buckets: [UsageAnalyticsDailyBucket],
days: Int,
now: Date,
calendar: Calendar
) -> [UsageAnalyticsDailyBucket] {
guard days > 0 else { return [] }
let today = dayStart(for: now, calendar: calendar)
let byDay = Dictionary(buckets.map { ($0.day, $0) }, uniquingKeysWith: { existing, _ in existing })

var dense: [UsageAnalyticsDailyBucket] = []
for offset in stride(from: days - 1, through: 0, by: -1) {
guard let day = calendar.date(byAdding: .day, value: -offset, to: today) else { continue }
dense.append(
byDay[day] ?? UsageAnalyticsDailyBucket(day: day, acceptances: 0, words: 0, characters: 0)
)
}
return dense
}

/// First day included by a windowed range: today minus `window - 1` days, so the window counts
/// today inclusively.
private static func cutoffDay(window: Int, now: Date, calendar: Calendar) -> Date? {
let today = dayStart(for: now, calendar: calendar)
return calendar.date(byAdding: .day, value: -(max(1, window) - 1), to: today)
}
}
Loading