feat(analytics): add a local usage-analytics Settings pane#673
Open
FuJacob wants to merge 1 commit into
Open
Conversation
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.
3fe4a42 to
6c13f28
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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+ groupedForm+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
SuggestionCoordinator.recordAcceptedWords, the existing chokepoint that already feeds the menu-bartotalTabAcceptedWordCount. It reuses the sameacceptedWordCount, 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.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.[day, acceptances, words, characters].xcodegen generatere-ran for the six new files; the diff is limited to those file references.acceptedWordCountbehavior (a space-less run counts as one word), so CJK word totals match the menu bar; the character count still reflects true size.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.UsageAnalyticsModels,UsageAnalyticsStore,UsageAnalyticsAggregator, andUsageAnalyticsPaneView— model, persistence, pure bucketing math, and SwiftUI pane respectively. The aggregator is cleanly separated for testability with 23 new unit tests.SuggestionCoordinator+Acceptance.swift, reusing the already-computedacceptedWordCountandacceptedChunk.countso menu-bar and Usage pane totals agree with no double-counting.UsageAnalyticsStoreis constructed once inCotabbyAppEnvironmentbefore 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
dayisvar(previously flagged), andversionin the Persisted envelope is stored but never read on decode.@MainActorisolation is correct,nonisolated deinitworkaround is well-documented. Silentpersist()failure already flagged as P2.cutoffDayclamp guards window=0 edge case,uniquingKeysWithis defensive. Well-tested.chartDaysreturns 30 for both Last30Days and AllTime (previously flagged);isEmptyandtotalsare two separate in-memory calls per render — acceptable for this scale.totalTabAcceptedWordCountupdate; reusesacceptedWordCountandacceptedChunk.count— no double-counting risk.InMemoryDefaults; covers accumulation, empty-accept no-op, cross-instance persistence, reset, and dense chart series. Clean isolation..usagecase with label andchart.bar.fillicon; consistent with existing pane conventions..usagecorrectly added to the exhaustivenil-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)Comments Outside Diff (4)
Cotabby/Models/UsageAnalyticsStore.swift, line 481-488 (link)If
JSONEncoder().encode(...)throws (e.g. the encoding implementation later gains a non-Encodablevalue type, or a system OOM), the guard silently returns without writing anything, and the in-memorybucketsarray is already updated. The nextrecordAcceptancecall 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. ALoggercall here — similar to how other stores in the project log encode failures — would surface this in the debug log without breaking the feature.Cotabby/Models/UsageAnalyticsModels.swift, line 333-342 (link)daybucket key breaks identitydayis declaredvarand also serves as theid(viavar id: Date { day }). Mutatingdayon a bucket that is already stored inbucketswould silently corrupt the aggregator's sorted invariant —recordingusesfirstIndex(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. BecauseUsageAnalyticsDailyBucketis a value type the mutation can only happen insideUsageAnalyticsAggregator.recording(which does the right thing), but exposing the property asvarleaves the door open for a future caller. Makingdayaletwould enforce the invariant at the type level.Cotabby/UI/Settings/Panes/UsageAnalyticsPaneView.swift, line 655-658 (link)chartDaysreturns30for both.last30Daysand.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)") whenrange != .last7Dayswould 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!
CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift, line 951-955 (link)The
UsageAnalyticsStorefor the new acceptance test is constructed with a UUID-namedUserDefaultssuite, 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 deadUserDefaultssuite entry in the host app's container — minor disk leak on repeated runs. Addingdefaults.removePersistentDomain(forName: suiteName)intearDown(mirroring the pattern used by the existinguserDefaultssuite in this same factory) would clean it up.Reviews (2): Last reviewed commit: "feat(analytics): add a local usage-analy..." | Re-trigger Greptile