Skip to content

feat(analytics): add a local usage-analytics Settings pane#673

Open
FuJacob wants to merge 1 commit into
mainfrom
feat/local-usage-analytics
Open

feat(analytics): add a local usage-analytics Settings pane#673
FuJacob wants to merge 1 commit into
mainfrom
feat/local-usage-analytics

Conversation

@FuJacob

@FuJacob FuJacob commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a local, privacy-preserving Usage pane to Settings (issue #489) so users can see how much value they're getting from Cotabby. It shows accepted suggestions, accepted words, and accepted characters across Last 7 Days / Last 30 Days / All Time, a per-day bar chart of words accepted, and a confirmed Reset Stats action. Everything stays on device: the only thing stored is per-day counts.

Validation

# Build for testing (this worktree's cotabbyinference pin resolved to main @ 6e1a9ba)
xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' \
  build-for-testing -derivedDataPath build/DerivedData CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO
# ** TEST BUILD SUCCEEDED **

# New + touched suites
xcodebuild ... test-without-building \
  -only-testing:CotabbyTests/UsageAnalyticsAggregatorTests \
  -only-testing:CotabbyTests/UsageAnalyticsStoreTests \
  -only-testing:CotabbyTests/SuggestionCoordinatorAcceptanceTests
# ** TEST EXECUTE SUCCEEDED **  Executed 23 tests, with 0 failures

swiftlint lint --quiet <all new + changed files>
# exit 0, no findings

xcodegen generate
# project regenerated; project.pbxproj diff is 32 additions, all UsageAnalytics file refs (no churn)

New coverage: counter accumulation + same-day merging, the 7/30/all-time range windows (boundary-exact against a fixed gregorian/UTC calendar), dense zero-filled chart series, persistence across store instances, reset, and the coordinator's accept hook recording exactly one acceptance.

UI note: the pane is standard SwiftUI built on the shared SettingsPaneScaffold + grouped Form + Charts (the same stack as the Performance pane), so it was validated by compilation and the unit tests rather than an on-device screenshot.

Linked issues

Fixes #489

Risk / rollout notes

  • Recording hook: counts are recorded in SuggestionCoordinator.recordAcceptedWords, the existing chokepoint that already feeds the menu-bar totalTabAcceptedWordCount. It reuses the same acceptedWordCount, so the new totals agree with the menu-bar number and there is no double counting. One accept gesture (word, phrase, full, or correction) is one "acceptance"; a phrase accept contributes its full word count.
  • New persisted key: cotabbyUsageAnalytics (a small versioned JSON blob of day buckets). Additive; nothing else reads it. All-time totals begin accumulating from upgrade since there is no prior per-day history to backfill.
  • Privacy: the store deliberately never persists accepted text, prompts, OCR, screenshots, app identity, or any timestamp finer than the calendar day. The whole surface is [day, acceptances, words, characters].
  • pbxproj regenerated: xcodegen generate re-ran for the six new files; the diff is limited to those file references.
  • CJK: word counts follow the existing acceptedWordCount behavior (a space-less run counts as one word), so CJK word totals match the menu bar; the character count still reflects true size.
  • Deferred (privacy-safe, not in scope here): per-backend or per-app breakdowns the issue floats as "consider" — left out to keep the first cut focused and the stored surface minimal.

Greptile Summary

Adds a local, privacy-preserving Usage pane to Settings (#489) that shows accepted-suggestion counts, words, and characters across three time windows (last 7/30 days, all time), a per-day bar chart, and a confirmed Reset action. All data stays on device as a small versioned JSON blob of [day, acceptances, words, characters] buckets; no accepted text is ever persisted.

  • New files: UsageAnalyticsModels, UsageAnalyticsStore, UsageAnalyticsAggregator, and UsageAnalyticsPaneView — model, persistence, pure bucketing math, and SwiftUI pane respectively. The aggregator is cleanly separated for testability with 23 new unit tests.
  • Recording hook: a single line added at the existing accept chokepoint in SuggestionCoordinator+Acceptance.swift, reusing the already-computed acceptedWordCount and acceptedChunk.count so menu-bar and Usage pane totals agree with no double-counting.
  • Wiring: UsageAnalyticsStore is constructed once in CotabbyAppEnvironment before both the suggestion coordinator (writer) and settings coordinator (reader), and flows in via dependency injection throughout.

Confidence Score: 5/5

Safe to merge — the change is purely additive, isolated behind a new settings pane, and the only production-path mutation is a single line at an already-exercised accept chokepoint.

The recording hook reuses already-computed values and cannot corrupt existing state. The store is append-only (per-day integer counters) with a versioned envelope, so a future schema change can migrate gracefully. All new logic is covered by 23 unit tests against a fixed UTC calendar, and the aggregator is a pure function with no shared mutable state.

No files require special attention for merging.

Important Files Changed

Filename Overview
Cotabby/Models/UsageAnalyticsModels.swift New value types for analytics data model — DailyBucket, Totals, and Range enum. day is var (previously flagged), and version in the Persisted envelope is stored but never read on decode.
Cotabby/Models/UsageAnalyticsStore.swift Persists daily bucket data via UserDefaults; @MainActor isolation is correct, nonisolated deinit workaround is well-documented. Silent persist() failure already flagged as P2.
Cotabby/Support/UsageAnalyticsAggregator.swift Pure bucketing/aggregation logic with no side effects; date-window math is correct, cutoffDay clamp guards window=0 edge case, uniquingKeysWith is defensive. Well-tested.
Cotabby/UI/Settings/Panes/UsageAnalyticsPaneView.swift Settings pane with segmented range picker, stat tiles, bar chart, and reset. chartDays returns 30 for both Last30Days and AllTime (previously flagged); isEmpty and totals are two separate in-memory calls per render — acceptable for this scale.
Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift Single-line hook added after existing totalTabAcceptedWordCount update; reuses acceptedWordCount and acceptedChunk.count — no double-counting risk.
Cotabby/App/Core/CotabbyAppEnvironment.swift Store constructed once before both the suggestion coordinator (writer) and settings coordinator (reader), shared correctly via dependency injection.
CotabbyTests/UsageAnalyticsAggregatorTests.swift Comprehensive pure-function tests against a fixed UTC calendar; boundary conditions for 7/30/all-time windows, zero-filling, and multi-day merging all covered.
CotabbyTests/UsageAnalyticsStoreTests.swift Store-level tests using InMemoryDefaults; covers accumulation, empty-accept no-op, cross-instance persistence, reset, and dense chart series. Clean isolation.
CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift New integration test verifies acceptance records into the analytics store correctly. UUID-named UserDefaults suite for the store is created but not torn down (previously flagged P2).
Cotabby/UI/Settings/SettingsCategory.swift Adds .usage case with label and chart.bar.fill icon; consistent with existing pane conventions.
Cotabby/Support/SettingsAttentionEvaluator.swift .usage correctly added to the exhaustive nil-attention case branch.

Sequence Diagram

sequenceDiagram
    participant User
    participant SuggestionCoordinator
    participant UsageAnalyticsStore
    participant UsageAnalyticsAggregator
    participant UserDefaults

    User->>SuggestionCoordinator: Accept suggestion (Tab key)
    SuggestionCoordinator->>SuggestionCoordinator: recordAcceptedWords() updates totalTabAcceptedWordCount
    SuggestionCoordinator->>UsageAnalyticsStore: recordAcceptance(words, characters)
    UsageAnalyticsStore->>UsageAnalyticsAggregator: recording(words, chars, date, buckets, calendar)
    UsageAnalyticsAggregator-->>UsageAnalyticsStore: updated [UsageAnalyticsDailyBucket]
    UsageAnalyticsStore->>UserDefaults: persist() set(JSON, forKey: cotabbyUsageAnalytics)

    Note over User,UserDefaults: User opens Settings → Usage pane

    User->>UsageAnalyticsPaneView: view loads / range picker changes
    UsageAnalyticsPaneView->>UsageAnalyticsStore: totals(in: range)
    UsageAnalyticsStore->>UsageAnalyticsAggregator: totals(in: buckets, range, now, calendar)
    UsageAnalyticsAggregator-->>UsageAnalyticsPaneView: UsageAnalyticsTotals
    UsageAnalyticsPaneView->>UsageAnalyticsStore: recentDailyBuckets(days: chartDays)
    UsageAnalyticsStore->>UsageAnalyticsAggregator: dailyBuckets(from:, days:, now:, calendar:)
    UsageAnalyticsAggregator-->>UsageAnalyticsPaneView: dense [UsageAnalyticsDailyBucket] zero-filled

    Note over User,UserDefaults: User taps Reset Stats and confirms

    User->>UsageAnalyticsPaneView: confirm reset
    UsageAnalyticsPaneView->>UsageAnalyticsStore: clear()
    UsageAnalyticsStore->>UserDefaults: removeObject(forKey: cotabbyUsageAnalytics)
Loading

Comments Outside Diff (4)

  1. Cotabby/Models/UsageAnalyticsStore.swift, line 481-488 (link)

    P2 Silent persist failure on every accept

    If JSONEncoder().encode(...) throws (e.g. the encoding implementation later gains a non-Encodable value type, or a system OOM), the guard silently returns without writing anything, and the in-memory buckets array is already updated. The next recordAcceptance call will successfully encode again — but if the process terminates before that happens, the last acceptance (or the entire current day's bucket on its first write) is lost with no indication. A Logger call here — similar to how other stores in the project log encode failures — would surface this in the debug log without breaking the feature.

    Fix in Codex Fix in Claude Code

  2. Cotabby/Models/UsageAnalyticsModels.swift, line 333-342 (link)

    P2 Mutable day bucket key breaks identity

    day is declared var and also serves as the id (via var id: Date { day }). Mutating day on a bucket that is already stored in buckets would silently corrupt the aggregator's sorted invariant — recording uses firstIndex(where: { $0.day == key }) to look up the bucket, and a mutated key would cause a "not found" miss, inserting a duplicate for the same calendar day. Because UsageAnalyticsDailyBucket is a value type the mutation can only happen inside UsageAnalyticsAggregator.recording (which does the right thing), but exposing the property as var leaves the door open for a future caller. Making day a let would enforce the invariant at the type level.

    Fix in Codex Fix in Claude Code

  3. Cotabby/UI/Settings/Panes/UsageAnalyticsPaneView.swift, line 655-658 (link)

    P2 Section title is stale when "All Time" is selected

    chartDays returns 30 for both .last30Days and .allTime, so the chart correctly caps at 30 bars regardless of which range the user has selected. However the containing section is always titled "Words Accepted per Day" without qualifying the window — a user on "All Time" may reasonably expect to see all history and instead sees only the most recent 30 days. A small inline clarification (e.g. "Words Accepted per Day (last 30 days)") when range != .last7Days would prevent the mismatch.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Codex Fix in Claude Code

  4. CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift, line 951-955 (link)

    P2 Named UserDefaults suite not removed after test

    The UsageAnalyticsStore for the new acceptance test is constructed with a UUID-named UserDefaults suite, which avoids cross-test contamination at record time. However the suite is not removed when the test tears down, so each test run accumulates a dead UserDefaults suite entry in the host app's container — minor disk leak on repeated runs. Adding defaults.removePersistentDomain(forName: suiteName) in tearDown (mirroring the pattern used by the existing userDefaults suite in this same factory) would clean it up.

    Fix in Codex Fix in Claude Code

Reviews (2): Last reviewed commit: "feat(analytics): add a local usage-analy..." | Re-trigger Greptile

Adds a privacy-preserving "Usage" pane (issue #489) so users can see how
much Cotabby is helping them. It surfaces accepted suggestions, accepted
words, and accepted characters across Last 7 Days / Last 30 Days / All Time,
a per-day bar chart of words accepted, and a confirmed "Reset Stats" action.

How it works:
- New UsageAnalyticsStore persists per-day {acceptances, words, characters}
  tallies as a single JSON blob in UserDefaults. The entire on-disk surface
  is day buckets and counts: no accepted text, prompts, OCR, screenshots,
  app identity, or sub-day timestamps are ever stored.
- SuggestionCoordinator records into it at the existing acceptedWordCount
  chokepoint (the same hook that feeds the menu-bar total), so the counts
  agree and there is no double counting.
- UsageAnalyticsAggregator holds the pure date-bucketing and range math,
  unit-tested against a fixed gregorian/UTC calendar.

Tests cover counter accumulation and same-day merging, 7/30/all-time range
windows, dense zero-filled chart series, persistence across instances, reset,
and the coordinator accept hook.
@FuJacob FuJacob force-pushed the feat/local-usage-analytics branch from 3fe4a42 to 6c13f28 Compare June 11, 2026 07:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add local usage analytics to Settings

1 participant