diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 66ce36c..544989f 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -139,11 +139,13 @@ 323500F336AF70C520926383 /* MacroReferenceSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64442042F5B57CB0A701DA85 /* MacroReferenceSheet.swift */; }; 32A2915FAE21CD9CE818A9D9 /* SuggestionSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */; }; 333C09921443BDDF21A9753D /* SuggestionAvailabilityEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3609CC88A5280B3AA40414DF /* SuggestionAvailabilityEvaluator.swift */; }; + 33465A6F339D5BA6605F26F4 /* UsageAnalyticsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779DB003BE693D2158A7EEA7 /* UsageAnalyticsStore.swift */; }; 344B9BF352C97CFA830853D6 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */; }; 35F6F62A299713660CFB4797 /* SettingsPaneScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */; }; 36312821AEE03E3E62845958 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */; }; 3682DBB9DCF6C011F382A1B0 /* SuggestionWorkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */; }; 37625DC44E2228CC897222B7 /* ru-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6CF1FBAABEF545B620AF8D78 /* ru-100k.txt */; }; + 378651889D13EF947B377FF1 /* UsageAnalyticsAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C4F245D9B3B7D97381C09FC /* UsageAnalyticsAggregator.swift */; }; 378EE9C111040353A6335454 /* CurrentWordSpellChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 733BF6287BDE599B02A12271 /* CurrentWordSpellChecker.swift */; }; 39332A6824B1935AE3B0D8C0 /* fr-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6DB982BF30B3601F57277776 /* fr-100k.txt */; }; 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; @@ -175,6 +177,7 @@ 41DD807E251E1DC653540EFD /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033A468451259A3214EECBE5 /* InlinePreviewView.swift */; }; 429CE592897D8A952F2916C3 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BD71ECC2AE4821B643E0935 /* ConfidenceSuppressionPolicy.swift */; }; 42D40F37086294D0E58200C5 /* GhostFontSizeStabilizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9458F0820B3161FE9CF1DDAF /* GhostFontSizeStabilizer.swift */; }; + 435240CB607A962DFB3AC8E1 /* UsageAnalyticsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C447D8C6FB218015F675A1A6 /* UsageAnalyticsPaneView.swift */; }; 4531645066A73971EB2A5FA1 /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC3BF78835C8F2C315932F1 /* EmojiCatalog.swift */; }; 45CE438CC67179356224AFD9 /* FocusTrackingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D42CD456B4B3C988B148A6 /* FocusTrackingModel.swift */; }; 467E2EF8E7B1EC83F60F6A35 /* EmojiRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E72A3972E15749337539C2D /* EmojiRecents.swift */; }; @@ -281,6 +284,7 @@ 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; 709F365A846B908D953FA92D /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */; }; 70D6F9480DA4104AD5669569 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */; }; + 719AC6ACEC1D9907563C1DBB /* UsageAnalyticsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7061A99869F521CC271115D0 /* UsageAnalyticsModels.swift */; }; 735C2E64CA51F58098B30A0D /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; 74422BB837D6A319D12BF981 /* BaseCompletionPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; @@ -339,6 +343,7 @@ 90F287ED3B23FB2AB3EF8CCE /* SuggestionTextNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B424E2AC97C99D335B0D5751 /* SuggestionTextNormalizer.swift */; }; 914266D314BBBCE2EAB5CBA7 /* OCRTextHygiene.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22FDEB3B1DCC9ADE906CC73 /* OCRTextHygiene.swift */; }; 91ADE463EE72D77E0D3EBBCA /* TickMarkSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */; }; + 91B798CF7BB973F7F7905F26 /* UsageAnalyticsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C447D8C6FB218015F675A1A6 /* UsageAnalyticsPaneView.swift */; }; 91C27021750AC03AA4A0115A /* HuggingFaceAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110CB0B53016644EF7840301 /* HuggingFaceAPIClient.swift */; }; 91D1F16B8C5DA281D4B7F699 /* CustomRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD752451330486FE270018B0 /* CustomRulesTests.swift */; }; 91D8189EFCD1BA992EA6F038 /* ConfidenceSuppressionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FF2B0A3094A952A8EBA9B5 /* ConfidenceSuppressionPolicyTests.swift */; }; @@ -369,6 +374,7 @@ 9CEBD6AF4405F1BBE0E3D16C /* MidWordContinuationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 357C18383B047F24A531BDCD /* MidWordContinuationPolicy.swift */; }; 9D0F4829D11BCD4DB1290410 /* InsertionStrategySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D2FEEA4304C86324BAADAB /* InsertionStrategySelector.swift */; }; 9E031B67A275BB3E049EFC2F /* frequency_dictionary_en_82_765.txt in Resources */ = {isa = PBXBuildFile; fileRef = 99FBB636008490B66CF26772 /* frequency_dictionary_en_82_765.txt */; }; + 9E283021593F62B10B53725B /* UsageAnalyticsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0223601ACC30CD13D29907 /* UsageAnalyticsStoreTests.swift */; }; 9EB8E3DC796A0C8BFDE8E683 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3E8E86A14090BC7BD13BA76 /* AppDelegate.swift */; }; 9F2FDCABCC941CBECAA3B4AB /* CotabbyInference in Frameworks */ = {isa = PBXBuildFile; productRef = 48A46AD6B613CF06072603E4 /* CotabbyInference */; }; 9F6F88ED74ECA3E23A8E3CC0 /* SecureFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1827565F4FAD3E4E61CA65C3 /* SecureFieldDetector.swift */; }; @@ -429,6 +435,7 @@ B7A98BC225304E4DFED9E622 /* OnboardingTemplateRecommender.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */; }; B93AB7E845086F6FBB068369 /* SuggestionRequestFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */; }; B9623395B31459D9D45B1320 /* CurrentWordExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247561C626843957CFB4B632 /* CurrentWordExtractor.swift */; }; + B9A52846F98235EAEA138ACF /* UsageAnalyticsAggregatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C54D0A14CE6FA76F94E8F03 /* UsageAnalyticsAggregatorTests.swift */; }; B9F400BCC20757DA5DB0B5F9 /* FoundationModelSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5664E34B23FBDF69292FEF43 /* FoundationModelSuggestionEngine.swift */; }; BA74281E2DDE659C5CACBF24 /* KeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A567677424A82D9EEF47495 /* KeyRecorderView.swift */; }; BB3166B270865C8C6EFBA1CF /* FoundationModelAvailabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8724ECA8FABBC82B0A2B943B /* FoundationModelAvailabilityService.swift */; }; @@ -497,6 +504,7 @@ D90C889A623A928F4F5FDC7B /* HardwareCapabilityProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */; }; D9C51DEDF01033E276A479CE /* AXTextGeometryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */; }; DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */; }; + DAAAB18F728D70055485A06F /* UsageAnalyticsAggregator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C4F245D9B3B7D97381C09FC /* UsageAnalyticsAggregator.swift */; }; DAD77998F793468D4D64B705 /* DateMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F671FE53CDEAE9091EFBCE45 /* DateMacroEvaluator.swift */; }; DB1310FF3576ACA6472C4DB1 /* TrailingDuplicationFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19A5B462891263BDFB56607 /* TrailingDuplicationFilterTests.swift */; }; DC0394C83D334B92A512A775 /* NOTICE.md in Resources */ = {isa = PBXBuildFile; fileRef = 66CF2A70D4699421AC9BD849 /* NOTICE.md */; }; @@ -537,6 +545,8 @@ EE87886AC1BFC8BB3DE09762 /* HuggingFaceModelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */; }; EF0DE5E045F328F1E912A02A /* AppsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */; }; EF5BAB96DDADABB86F9E02D9 /* SyntheticReplacePlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71031E8DB171047318B92FC /* SyntheticReplacePlannerTests.swift */; }; + F0106C1A8A0C99342A927E2D /* UsageAnalyticsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 779DB003BE693D2158A7EEA7 /* UsageAnalyticsStore.swift */; }; + F041B893115BA6D151A58920 /* UsageAnalyticsModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7061A99869F521CC271115D0 /* UsageAnalyticsModels.swift */; }; F04D9470439699DB1F016000 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */; }; F067EA26AC2D007382CE520F /* EmojiPickerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5F074ED7E340E9B9E4C5E0 /* EmojiPickerModels.swift */; }; F08C139B246C1EC7BB435455 /* MenuBarPresentationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */; }; @@ -707,13 +717,16 @@ 684737E62EE6495A71344923 /* DeepGeometryWalkThrottle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepGeometryWalkThrottle.swift; sourceTree = ""; }; 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGate.swift; sourceTree = ""; }; 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWorkController.swift; sourceTree = ""; }; + 6C54D0A14CE6FA76F94E8F03 /* UsageAnalyticsAggregatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageAnalyticsAggregatorTests.swift; sourceTree = ""; }; 6CF1FBAABEF545B620AF8D78 /* ru-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "ru-100k.txt"; sourceTree = ""; }; 6DB982BF30B3601F57277776 /* fr-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "fr-100k.txt"; sourceTree = ""; }; 6DC693E00430F46E41CB56E6 /* RequestID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestID.swift; sourceTree = ""; }; + 6E0223601ACC30CD13D29907 /* UsageAnalyticsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageAnalyticsStoreTests.swift; sourceTree = ""; }; 6E3B1232C4BE8072A5183F9C /* SymSpell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymSpell.swift; sourceTree = ""; }; 6E3EC87078D3A4C21DB3252C /* RandomMacroEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomMacroEvaluator.swift; sourceTree = ""; }; 6F0EE728C0B1A7AD6B19CD0C /* AGPL-3.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "AGPL-3.0.txt"; sourceTree = ""; }; 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityResolver.swift; sourceTree = ""; }; + 7061A99869F521CC271115D0 /* UsageAnalyticsModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageAnalyticsModels.swift; sourceTree = ""; }; 711293EA57808B9428C7B908 /* CotabbyAppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyAppEnvironment.swift; sourceTree = ""; }; 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsPaneView.swift; sourceTree = ""; }; 723E1EFA85D2E61B6C5F33E8 /* EmojiTriggerStateMachineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiTriggerStateMachineTests.swift; sourceTree = ""; }; @@ -723,6 +736,7 @@ 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "LGPL-3.0.txt"; sourceTree = ""; }; 75396860978E81EFAA506CD4 /* EmojiQueryRunTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiQueryRunTests.swift; sourceTree = ""; }; 764659D09C3F0E8FBD267102 /* EmojiPickerPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelController.swift; sourceTree = ""; }; + 779DB003BE693D2158A7EEA7 /* UsageAnalyticsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageAnalyticsStore.swift; sourceTree = ""; }; 77B0121E7BB173F8A2B0B108 /* WindowScreenshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScreenshotService.swift; sourceTree = ""; }; 78AFA4586C82E92D7FBF381B /* ArithmeticEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArithmeticEvaluatorTests.swift; sourceTree = ""; }; 78E280F4F39A9D86840800D2 /* SuggestionCoordinator+Lifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Lifecycle.swift"; sourceTree = ""; }; @@ -763,6 +777,7 @@ 9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizerTests.swift; sourceTree = ""; }; 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotContextGenerator.swift; sourceTree = ""; }; + 9C4F245D9B3B7D97381C09FC /* UsageAnalyticsAggregator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageAnalyticsAggregator.swift; sourceTree = ""; }; 9C8F07AC52C7A482F5FE34C5 /* SuggestionSessionReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionReconcilerTests.swift; sourceTree = ""; }; 9CC2D6472ACD377FD73A5801 /* ControlTokenMarkers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlTokenMarkers.swift; sourceTree = ""; }; 9CF4FB0EC6C1BEB4EA74910A /* ClipboardContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContextProvider.swift; sourceTree = ""; }; @@ -829,6 +844,7 @@ C1C5DE0F3FF63545000E2453 /* DisplayCoordinateConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverterTests.swift; sourceTree = ""; }; C375227649689775275AA4B3 /* SuggestionCoordinatorAcceptanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorAcceptanceTests.swift; sourceTree = ""; }; C379D77029D6E88C8C1B9AF7 /* emoji.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = emoji.json; sourceTree = ""; }; + C447D8C6FB218015F675A1A6 /* UsageAnalyticsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageAnalyticsPaneView.swift; sourceTree = ""; }; C648EBB10D7F8E0B904DEC91 /* de.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = de.txt; sourceTree = ""; }; C71031E8DB171047318B92FC /* SyntheticReplacePlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticReplacePlannerTests.swift; sourceTree = ""; }; C727BF6FF8ACAAED30B0329F /* TypoGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoGateTests.swift; sourceTree = ""; }; @@ -1012,6 +1028,7 @@ DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */, 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */, EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */, + C447D8C6FB218015F675A1A6 /* UsageAnalyticsPaneView.swift */, D48B95B6665109B6C6A63B42 /* WritingPaneView.swift */, ); path = Panes; @@ -1164,6 +1181,8 @@ 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */, DEB16474A67CE1D210B944C9 /* SuggestionSubsystemContracts.swift */, 807148A920E003DEF8BA6092 /* SystemMetricsStore.swift */, + 7061A99869F521CC271115D0 /* UsageAnalyticsModels.swift */, + 779DB003BE693D2158A7EEA7 /* UsageAnalyticsStore.swift */, BE97A8169438D593C6C23412 /* VisualContextModels.swift */, ); path = Models; @@ -1283,6 +1302,8 @@ E19A5B462891263BDFB56607 /* TrailingDuplicationFilterTests.swift */, D2D0FE44138BCA8B2EE05AFE /* TypoCaseTransferTests.swift */, C727BF6FF8ACAAED30B0329F /* TypoGateTests.swift */, + 6C54D0A14CE6FA76F94E8F03 /* UsageAnalyticsAggregatorTests.swift */, + 6E0223601ACC30CD13D29907 /* UsageAnalyticsStoreTests.swift */, 050D929E13BE52E6282B64D2 /* VisualContextStartCoalescerTests.swift */, 1E0513E3B23937B099A3CFF2 /* WordCountFormatterTests.swift */, ); @@ -1475,6 +1496,7 @@ D408D647412C59F3E692C42B /* TrailingDuplicationFilter.swift */, 08CE63B8725EBD71A4C024E1 /* TypoCaseTransfer.swift */, B8412FE2BAC406421248A03B /* TypoGate.swift */, + 9C4F245D9B3B7D97381C09FC /* UsageAnalyticsAggregator.swift */, 2F01FAC4F57EB08471521196 /* VisualContextStartCoalescer.swift */, 815F2ABAF6AB75DA3AFBBCEF /* WordCountFormatter.swift */, ); @@ -1875,6 +1897,10 @@ C178E35A9A713BD4D9943E62 /* TypoCaseTransfer.swift in Sources */, 06B7E7339877B334B28BE2D3 /* TypoGate.swift in Sources */, A8A294534897C461CA5968F3 /* UnitConversionEvaluator.swift in Sources */, + 378651889D13EF947B377FF1 /* UsageAnalyticsAggregator.swift in Sources */, + 719AC6ACEC1D9907563C1DBB /* UsageAnalyticsModels.swift in Sources */, + 91B798CF7BB973F7F7905F26 /* UsageAnalyticsPaneView.swift in Sources */, + 33465A6F339D5BA6605F26F4 /* UsageAnalyticsStore.swift in Sources */, 0A15FAD03A93D57372D267E0 /* VisualContextCoordinator.swift in Sources */, F77DB394E9D6C6C482131BF9 /* VisualContextModels.swift in Sources */, 641A9FAF3009A3E2AA06D74B /* VisualContextStartCoalescer.swift in Sources */, @@ -2092,6 +2118,10 @@ B709B362B786AA6ED548C673 /* TypoCaseTransfer.swift in Sources */, E311B80968761E90FBA19A8A /* TypoGate.swift in Sources */, B6815BBBC91809A6EAA914CB /* UnitConversionEvaluator.swift in Sources */, + DAAAB18F728D70055485A06F /* UsageAnalyticsAggregator.swift in Sources */, + F041B893115BA6D151A58920 /* UsageAnalyticsModels.swift in Sources */, + 435240CB607A962DFB3AC8E1 /* UsageAnalyticsPaneView.swift in Sources */, + F0106C1A8A0C99342A927E2D /* UsageAnalyticsStore.swift in Sources */, E9E4CC657771DF9F4C56183C /* VisualContextCoordinator.swift in Sources */, 4190F8A76196B16ED94D0A55 /* VisualContextModels.swift in Sources */, 19CB55B62977376E9AE8D428 /* VisualContextStartCoalescer.swift in Sources */, @@ -2197,6 +2227,8 @@ DB1310FF3576ACA6472C4DB1 /* TrailingDuplicationFilterTests.swift in Sources */, 5C6B59C2E56A3C4260591095 /* TypoCaseTransferTests.swift in Sources */, BE688EC1957B4AE004063EFE /* TypoGateTests.swift in Sources */, + B9A52846F98235EAEA138ACF /* UsageAnalyticsAggregatorTests.swift in Sources */, + 9E283021593F62B10B53725B /* UsageAnalyticsStoreTests.swift in Sources */, D5CAF3B590E5EC2AFC72E57A /* VisualContextStartCoalescerTests.swift in Sources */, 6AE0B46FB52D189D94E1F79A /* WordCountFormatterTests.swift in Sources */, ); diff --git a/Cotabby/App/Coordinators/SettingsCoordinator.swift b/Cotabby/App/Coordinators/SettingsCoordinator.swift index 96a8132..7008eab 100644 --- a/Cotabby/App/Coordinators/SettingsCoordinator.swift +++ b/Cotabby/App/Coordinators/SettingsCoordinator.swift @@ -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 @@ -37,6 +38,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { huggingFaceSearchService: HuggingFaceSearchService, performanceMetricsStore: PerformanceMetricsStore, systemMetricsStore: SystemMetricsStore, + usageAnalyticsStore: UsageAnalyticsStore, onShowWelcome: @escaping () -> Void, clearEmojiHistory: @escaping () -> Void ) { @@ -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 } @@ -77,6 +80,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { huggingFaceSearchService: huggingFaceSearchService, performanceMetricsStore: performanceMetricsStore, systemMetricsStore: systemMetricsStore, + usageAnalyticsStore: usageAnalyticsStore, onShowWelcome: onShowWelcome, clearEmojiHistory: clearEmojiHistory ) diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index a68b361..77008ac 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -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 diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index 14980d9..77a52ef 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -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 @@ -133,6 +136,7 @@ final class SuggestionCoordinator: ObservableObject { configuration: SuggestionConfiguration, spellChecker: CurrentWordSpellChecker, symSpellCorrector: SymSpellCorrector, + usageAnalyticsStore: UsageAnalyticsStore, spellingLanguageResolver: SpellingLanguageResolver = SpellingLanguageResolver(), userDefaults: UserDefaults = .standard ) { @@ -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. diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index f045130..52a2f5e 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -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? @@ -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() @@ -166,6 +171,7 @@ final class CotabbyAppEnvironment { huggingFaceSearchService: huggingFaceSearchService, performanceMetricsStore: performanceMetricsStore, systemMetricsStore: systemMetricsStore, + usageAnalyticsStore: usageAnalyticsStore, onShowWelcome: { [weak welcomeCoordinator] in welcomeCoordinator?.showWelcome() }, @@ -204,6 +210,7 @@ final class CotabbyAppEnvironment { configuration: configuration, spellChecker: spellChecker, symSpellCorrector: symSpellCorrector, + usageAnalyticsStore: usageAnalyticsStore, spellingLanguageResolver: SpellingLanguageResolver() ) @@ -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 diff --git a/Cotabby/Models/UsageAnalyticsModels.swift b/Cotabby/Models/UsageAnalyticsModels.swift new file mode 100644 index 0000000..443c656 --- /dev/null +++ b/Cotabby/Models/UsageAnalyticsModels.swift @@ -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 + } + } +} diff --git a/Cotabby/Models/UsageAnalyticsStore.swift b/Cotabby/Models/UsageAnalyticsStore.swift new file mode 100644 index 0000000..26bb773 --- /dev/null +++ b/Cotabby/Models/UsageAnalyticsStore.swift @@ -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) + } +} diff --git a/Cotabby/Support/SettingsAttentionEvaluator.swift b/Cotabby/Support/SettingsAttentionEvaluator.swift index 7a34e9d..b5fa77d 100644 --- a/Cotabby/Support/SettingsAttentionEvaluator.swift +++ b/Cotabby/Support/SettingsAttentionEvaluator.swift @@ -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 } } diff --git a/Cotabby/Support/UsageAnalyticsAggregator.swift b/Cotabby/Support/UsageAnalyticsAggregator.swift new file mode 100644 index 0000000..8566949 --- /dev/null +++ b/Cotabby/Support/UsageAnalyticsAggregator.swift @@ -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) + } +} diff --git a/Cotabby/UI/Settings/Panes/UsageAnalyticsPaneView.swift b/Cotabby/UI/Settings/Panes/UsageAnalyticsPaneView.swift new file mode 100644 index 0000000..136bbd3 --- /dev/null +++ b/Cotabby/UI/Settings/Panes/UsageAnalyticsPaneView.swift @@ -0,0 +1,180 @@ +import Charts +import SwiftUI + +/// File overview: +/// "Usage" detail pane (issue #489): local, privacy-preserving stats on how much the user has +/// accepted from Cotabby. It reads only the aggregated day buckets in `UsageAnalyticsStore`; there +/// is no raw text behind these numbers. A range picker switches the totals across 7 days / 30 days / +/// all time, a per-day bar chart shows the recent trend, and a confirmed Reset clears the store. +struct UsageAnalyticsPaneView: View { + @ObservedObject var usageAnalyticsStore: UsageAnalyticsStore + + @State private var range: UsageAnalyticsRange = .last7Days + @State private var isConfirmingReset = false + + var body: some View { + SettingsPaneScaffold { + Section { + Picker("Time Range", selection: $range) { + ForEach(UsageAnalyticsRange.allCases) { range in + Text(range.label).tag(range) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + summaryTiles + } header: { + Text("Accepted Suggestions") + } footer: { + if isEmpty { + Text( + "Accept a suggestion with your accept key to start tracking. " + + "Everything here stays on this device." + ) + } + } + + Section("Words Accepted per Day") { + trendChart + } + + Section { + Button(role: .destructive) { + isConfirmingReset = true + } label: { + SettingsRowLabel( + title: "Reset Stats", + description: "Clear all recorded usage counts on this device.", + systemImage: "trash" + ) + } + .disabled(isEmpty) + } footer: { + Text( + "All usage stats are stored locally. Cotabby never records the text you accept, " + + "which app you accept it in, or anything finer than a daily count." + ) + } + } + .alert("Reset usage stats?", isPresented: $isConfirmingReset) { + Button("Reset", role: .destructive) { usageAnalyticsStore.clear() } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "This permanently clears your accepted-suggestion, word, and character counts on " + + "this device. It cannot be undone." + ) + } + } + + // MARK: - Summary + + private var totals: UsageAnalyticsTotals { + usageAnalyticsStore.totals(in: range) + } + + /// "Has the user ever accepted anything?" It drives the empty-state hint and disables Reset. Keyed + /// on all-time totals so switching to a quiet 7-day window still shows the (zeroed) tiles rather + /// than the first-run hint. + private var isEmpty: Bool { + usageAnalyticsStore.totals(in: .allTime) == .zero + } + + private var summaryTiles: some View { + HStack(spacing: 12) { + UsageStatTile( + title: "Acceptances", + value: totals.acceptances, + systemImage: "checkmark.circle.fill", + tint: .accentColor + ) + UsageStatTile( + title: "Words", + value: totals.words, + systemImage: "textformat", + tint: .blue + ) + UsageStatTile( + title: "Characters", + value: totals.characters, + systemImage: "character", + tint: .green + ) + } + .padding(.vertical, 4) + } + + // MARK: - Trend chart + + /// Bars track the active range: a 7-day window shows seven daily bars, the wider windows show a + /// fixed 30-day trail so the chart never grows unbounded for long-running installs. + private var chartDays: Int { + range == .last7Days ? 7 : 30 + } + + @ViewBuilder + private var trendChart: some View { + let series = usageAnalyticsStore.recentDailyBuckets(days: chartDays) + if series.contains(where: { $0.words > 0 }) { + Chart(series) { bucket in + BarMark( + x: .value("Day", bucket.day, unit: .day), + y: .value("Words", bucket.words) + ) + .foregroundStyle(Color.accentColor.gradient) + } + .chartYAxis { + AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) + } + .chartXAxis { + AxisMarks(values: .stride(by: .day, count: chartDays > 7 ? 7 : 1)) { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.month(.abbreviated).day()) + } + } + .frame(height: 150) + .padding(.vertical, 4) + } else { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.secondary.opacity(0.08)) + .frame(height: 150) + .overlay( + Text("No words accepted yet.") + .font(.callout) + .foregroundStyle(.secondary) + ) + .padding(.vertical, 4) + } + } +} + +/// One stat in the summary row: an icon, a large grouped number, and a caption. Kept private to the +/// pane since it only makes sense inside this dashboard layout. +private struct UsageStatTile: View { + let title: String + let value: Int + let systemImage: String + let tint: Color + + var body: some View { + VStack(spacing: 4) { + Image(systemName: systemImage) + .imageScale(.large) + .foregroundStyle(tint) + Text(value.formatted()) + .font(.title2.weight(.semibold).monospacedDigit()) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(tint.opacity(0.1)) + ) + } +} diff --git a/Cotabby/UI/Settings/SettingsCategory.swift b/Cotabby/UI/Settings/SettingsCategory.swift index 9e554a8..516f31c 100644 --- a/Cotabby/UI/Settings/SettingsCategory.swift +++ b/Cotabby/UI/Settings/SettingsCategory.swift @@ -21,6 +21,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case apps case permissions case performance + case usage case about var id: String { rawValue } @@ -38,6 +39,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .apps: return "Apps" case .permissions: return "Permissions" case .performance: return "Performance" + case .usage: return "Usage" case .about: return "About" } } @@ -56,6 +58,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .apps: return "app.badge.fill" case .permissions: return "lock.shield.fill" case .performance: return "speedometer" + case .usage: return "chart.bar.fill" case .about: return "info.circle.fill" } } diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index 9300aac..58a5578 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -22,6 +22,7 @@ struct SettingsContainerView: View { @ObservedObject var huggingFaceSearchService: HuggingFaceSearchService @ObservedObject var performanceMetricsStore: PerformanceMetricsStore @ObservedObject var systemMetricsStore: SystemMetricsStore + @ObservedObject var usageAnalyticsStore: UsageAnalyticsStore let onShowWelcome: () -> Void let clearEmojiHistory: () -> Void @@ -132,6 +133,8 @@ struct SettingsContainerView: View { performanceMetricsStore: performanceMetricsStore, systemMetricsStore: systemMetricsStore ) + case .usage: + UsageAnalyticsPaneView(usageAnalyticsStore: usageAnalyticsStore) case .about: AboutPaneView(appUpdateManager: appUpdateManager) } diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 0c4f96d..c1d6bd9 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -68,6 +68,9 @@ enum SettingsItem: String, CaseIterable, Identifiable { case performanceTracking case resourceUsage case recentRequests + // Usage + case usageStats + case resetUsageStats // About case checkForUpdates case support @@ -129,6 +132,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .performanceTracking: return "Performance Tracking" case .resourceUsage: return "Live Resource Usage" case .recentRequests: return "Recent Requests" + case .usageStats: return "Usage Stats" + case .resetUsageStats: return "Reset Usage Stats" case .checkForUpdates: return "Check for Updates" case .support: return "Support Cotabby" case .githubRepository: return "GitHub Repository" @@ -189,6 +194,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .performanceTracking: return "stopwatch" case .resourceUsage: return "chart.line.uptrend.xyaxis" case .recentRequests: return "list.bullet.clipboard" + case .usageStats: return "chart.bar.fill" + case .resetUsageStats: return "trash" case .checkForUpdates: return "arrow.triangle.2.circlepath" case .support: return "heart.fill" case .githubRepository: return "chevron.left.forwardslash.chevron.right" @@ -225,6 +232,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { return .permissions case .performanceTracking, .resourceUsage, .recentRequests: return .performance + case .usageStats, .resetUsageStats: + return .usage case .checkForUpdates, .support, .githubRepository, .wiki, .acknowledgements, .uninstall: return .about @@ -384,6 +393,13 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .recentRequests: return ["recent", "requests", "history", "log", "completions", "latency", "clear", "list", "past"] + case .usageStats: + return ["usage", "stats", "statistics", "analytics", "words accepted", "acceptances", + "characters", "productivity", "word count", "completions accepted", "history", + "value", "how many", "tracking", "metrics", "dashboard"] + case .resetUsageStats: + return ["reset", "clear", "delete", "wipe", "erase", "usage", "stats", "analytics", + "start over", "forget"] case .checkForUpdates: return ["update", "version", "upgrade", "sparkle", "release", "new version", "check updates", "auto update"] diff --git a/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift b/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift index 50ae89f..eea0579 100644 --- a/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift +++ b/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift @@ -60,6 +60,40 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { } } + func test_acceptRecordsLocalUsageAnalytics() { + runOnMainActor { + let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello") + let context = FocusedInputContext(snapshot: snapshot, generation: 7) + let interactionState = SuggestionInteractionState() + _ = interactionState.startSession( + fullText: " world again", + liveContext: context, + latency: 0.1 + ) + let overlayState = OverlayState.visible( + text: " world again", + geometry: CotabbyTestFixtures.overlayGeometry(caretRect: context.caretRect), + mode: .inline + ) + let coordinator = makeCoordinator( + snapshot: snapshot, + overlayState: overlayState, + inputMonitor: StubSuggestionInputMonitor(), + inserter: StubSuggestionInserter(), + interactionState: interactionState + ) + + XCTAssertTrue(coordinator.acceptCurrentSuggestion()) + + // Accepting " world" (one word, six characters incl. the leading space) records exactly + // one acceptance into the local Usage store, matching the menu-bar word total. + let totals = coordinator.usageAnalyticsStore.totals(in: .allTime) + XCTAssertEqual(totals.acceptances, 1) + XCTAssertEqual(totals.words, 1) + XCTAssertEqual(totals.characters, " world".count) + } + } + func test_acceptCurrentSuggestionCleansVisibleOverlayWhenSessionDisappears() { runOnMainActor { let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello") @@ -301,6 +335,9 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { configuration: .standard, spellChecker: CurrentWordSpellChecker(), symSpellCorrector: SymSpellCorrector(preloadLanguage: nil), + usageAnalyticsStore: UsageAnalyticsStore( + defaults: UserDefaults(suiteName: "CotabbyTests.usage.\(UUID().uuidString)") ?? .standard + ), userDefaults: UserDefaults(suiteName: "CotabbyTests.\(UUID().uuidString)") ?? .standard ) Self.retainedCoordinators.append(coordinator) diff --git a/CotabbyTests/UsageAnalyticsAggregatorTests.swift b/CotabbyTests/UsageAnalyticsAggregatorTests.swift new file mode 100644 index 0000000..a15f6c8 --- /dev/null +++ b/CotabbyTests/UsageAnalyticsAggregatorTests.swift @@ -0,0 +1,178 @@ +import XCTest +@testable import Cotabby + +/// Pure tests for usage-analytics date bucketing and range math (issue #489). No store, no +/// UserDefaults, no main actor: just `UsageAnalyticsAggregator` against a fixed gregorian/UTC +/// calendar and pinned dates, so day boundaries are deterministic regardless of the host's locale or +/// timezone. +final class UsageAnalyticsAggregatorTests: XCTestCase { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + }() + + // MARK: - dayStart + + func test_dayStart_collapsesTimeOfDayToOneKey() { + let morning = date(2026, 3, 14, 8) + let evening = date(2026, 3, 14, 23) + XCTAssertEqual( + UsageAnalyticsAggregator.dayStart(for: morning, calendar: calendar), + UsageAnalyticsAggregator.dayStart(for: evening, calendar: calendar) + ) + } + + // MARK: - recording + + func test_recording_mergesAcceptsOnTheSameDay() { + var buckets: [UsageAnalyticsDailyBucket] = [] + buckets = record(2, 9, on: date(2026, 3, 14, 9), into: buckets) + buckets = record(3, 11, on: date(2026, 3, 14, 20), into: buckets) + + XCTAssertEqual(buckets.count, 1) + XCTAssertEqual(buckets[0].acceptances, 2) + XCTAssertEqual(buckets[0].words, 5) + XCTAssertEqual(buckets[0].characters, 20) + } + + func test_recording_keepsDistinctDaysSortedOldestFirst() { + var buckets: [UsageAnalyticsDailyBucket] = [] + buckets = record(1, 4, on: date(2026, 3, 14), into: buckets) + buckets = record(1, 4, on: date(2026, 3, 12), into: buckets) + buckets = record(1, 4, on: date(2026, 3, 13), into: buckets) + + XCTAssertEqual(buckets.map { calendar.component(.day, from: $0.day) }, [12, 13, 14]) + } + + func test_recording_clampsNegativeCountsButStillCountsTheAcceptance() { + let buckets = record(-5, -2, on: date(2026, 3, 14), into: []) + XCTAssertEqual(buckets[0].words, 0) + XCTAssertEqual(buckets[0].characters, 0) + XCTAssertEqual(buckets[0].acceptances, 1) + } + + // MARK: - totals + + func test_totals_allTimeSumsEveryBucket() { + let totals = UsageAnalyticsAggregator.totals( + in: sampleBuckets(), + range: .allTime, + now: date(2026, 3, 31), + calendar: calendar + ) + XCTAssertEqual(totals.acceptances, 3) + XCTAssertEqual(totals.words, 10) + XCTAssertEqual(totals.characters, 40) + } + + func test_totals_last7DaysIncludesTodayAndPreviousSixOnly() { + let buckets = [ + bucket(2026, 3, 24, words: 100), + bucket(2026, 3, 25, words: 1), + bucket(2026, 3, 31, words: 2) + ] + let totals = UsageAnalyticsAggregator.totals( + in: buckets, + range: .last7Days, + now: date(2026, 3, 31), + calendar: calendar + ) + // Mar 25...Mar 31 inclusive, so Mar 24 falls outside the window. + XCTAssertEqual(totals.words, 3) + } + + func test_totals_last30DaysExcludesDaysBeforeTheWindow() { + let buckets = [ + bucket(2026, 3, 1, words: 5), + bucket(2026, 3, 2, words: 7), + bucket(2026, 3, 31, words: 2) + ] + let totals = UsageAnalyticsAggregator.totals( + in: buckets, + range: .last30Days, + now: date(2026, 3, 31), + calendar: calendar + ) + // 30-day window ending Mar 31 starts Mar 2, so Mar 1 is excluded. + XCTAssertEqual(totals.words, 9) + } + + func test_totals_emptyBucketsAreZero() { + let totals = UsageAnalyticsAggregator.totals( + in: [], + range: .allTime, + now: date(2026, 3, 31), + calendar: calendar + ) + XCTAssertEqual(totals, .zero) + } + + // MARK: - dailyBuckets + + func test_dailyBuckets_isDenseZeroFilledOldestFirst() { + let series = UsageAnalyticsAggregator.dailyBuckets( + from: [bucket(2026, 3, 31, words: 4)], + days: 7, + now: date(2026, 3, 31), + calendar: calendar + ) + XCTAssertEqual(series.count, 7) + XCTAssertEqual(calendar.component(.day, from: series.first!.day), 25) + XCTAssertEqual(series.last?.words, 4) + XCTAssertEqual(series.dropLast().reduce(0) { $0 + $1.words }, 0) + } + + func test_dailyBuckets_zeroDaysIsEmpty() { + let series = UsageAnalyticsAggregator.dailyBuckets( + from: sampleBuckets(), + days: 0, + now: date(2026, 3, 31), + calendar: calendar + ) + XCTAssertTrue(series.isEmpty) + } + + // MARK: - Helpers + + private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int = 12) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + return calendar.date(from: components)! + } + + private func record( + _ words: Int, + _ characters: Int, + on date: Date, + into buckets: [UsageAnalyticsDailyBucket] + ) -> [UsageAnalyticsDailyBucket] { + UsageAnalyticsAggregator.recording( + words: words, + characters: characters, + on: date, + into: buckets, + calendar: calendar + ) + } + + private func bucket(_ year: Int, _ month: Int, _ day: Int, words: Int) -> UsageAnalyticsDailyBucket { + UsageAnalyticsDailyBucket( + day: UsageAnalyticsAggregator.dayStart(for: date(year, month, day), calendar: calendar), + acceptances: 1, + words: words, + characters: words * 4 + ) + } + + private func sampleBuckets() -> [UsageAnalyticsDailyBucket] { + [ + bucket(2026, 3, 10, words: 3), + bucket(2026, 3, 20, words: 5), + bucket(2026, 3, 30, words: 2) + ] + } +} diff --git a/CotabbyTests/UsageAnalyticsStoreTests.swift b/CotabbyTests/UsageAnalyticsStoreTests.swift new file mode 100644 index 0000000..b4246ba --- /dev/null +++ b/CotabbyTests/UsageAnalyticsStoreTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import Cotabby + +/// Tests for `UsageAnalyticsStore`: recording, range totals, persistence across instances, and reset +/// (issue #489). Uses an in-memory defaults stand-in and a fixed gregorian/UTC calendar so day +/// bucketing is deterministic. The class is intentionally NOT `@MainActor` (an isolated XCTest +/// subclass crashes the app-hosted runner), so each body hops onto the main actor via +/// `runOnMainActor`. +final class UsageAnalyticsStoreTests: XCTestCase { + private final class InMemoryDefaults: UsageAnalyticsDefaults { + private var storage: [String: Data] = [:] + func data(forKey defaultName: String) -> Data? { storage[defaultName] } + func set(_ value: Any?, forKey defaultName: String) { storage[defaultName] = value as? Data } + func removeObject(forKey defaultName: String) { storage[defaultName] = nil } + } + + private static let utcCalendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + return calendar + }() + + func test_recordAcceptance_accumulatesAllTimeTotals() { + runOnMainActor { + let store = UsageAnalyticsStore(defaults: InMemoryDefaults(), calendar: Self.utcCalendar) + store.recordAcceptance(words: 2, characters: 10, date: Self.date(2026, 3, 14)) + store.recordAcceptance(words: 3, characters: 12, date: Self.date(2026, 3, 14)) + + let totals = store.totals(in: .allTime, now: Self.date(2026, 3, 14)) + XCTAssertEqual(totals.acceptances, 2) + XCTAssertEqual(totals.words, 5) + XCTAssertEqual(totals.characters, 22) + } + } + + func test_recordAcceptance_ignoresFullyEmptyAccept() { + runOnMainActor { + let store = UsageAnalyticsStore(defaults: InMemoryDefaults(), calendar: Self.utcCalendar) + store.recordAcceptance(words: 0, characters: 0, date: Self.date(2026, 3, 14)) + XCTAssertTrue(store.buckets.isEmpty) + } + } + + func test_rangeTotals_respectTheSevenDayWindow() { + runOnMainActor { + let store = UsageAnalyticsStore(defaults: InMemoryDefaults(), calendar: Self.utcCalendar) + store.recordAcceptance(words: 9, characters: 9, date: Self.date(2026, 3, 1)) + store.recordAcceptance(words: 4, characters: 4, date: Self.date(2026, 3, 28)) + + let now = Self.date(2026, 3, 31) + XCTAssertEqual(store.totals(in: .last7Days, now: now).words, 4) + XCTAssertEqual(store.totals(in: .allTime, now: now).words, 13) + } + } + + func test_statePersistsAcrossInstances() { + runOnMainActor { + let defaults = InMemoryDefaults() + UsageAnalyticsStore(defaults: defaults, calendar: Self.utcCalendar) + .recordAcceptance(words: 7, characters: 30, date: Self.date(2026, 3, 14)) + + let reopened = UsageAnalyticsStore(defaults: defaults, calendar: Self.utcCalendar) + let totals = reopened.totals(in: .allTime, now: Self.date(2026, 3, 14)) + XCTAssertEqual(totals.acceptances, 1) + XCTAssertEqual(totals.words, 7) + XCTAssertEqual(totals.characters, 30) + } + } + + func test_clearForgetsEverythingAndRemovesPersistedBlob() { + runOnMainActor { + let defaults = InMemoryDefaults() + let store = UsageAnalyticsStore(defaults: defaults, calendar: Self.utcCalendar) + store.recordAcceptance(words: 1, characters: 5, date: Self.date(2026, 3, 14)) + store.clear() + + XCTAssertTrue(store.buckets.isEmpty) + let reopened = UsageAnalyticsStore(defaults: defaults, calendar: Self.utcCalendar) + XCTAssertTrue(reopened.buckets.isEmpty) + } + } + + func test_recentDailyBuckets_isDenseAndOldestFirst() { + runOnMainActor { + let store = UsageAnalyticsStore(defaults: InMemoryDefaults(), calendar: Self.utcCalendar) + store.recordAcceptance(words: 6, characters: 24, date: Self.date(2026, 3, 31)) + + let series = store.recentDailyBuckets(days: 7, now: Self.date(2026, 3, 31)) + XCTAssertEqual(series.count, 7) + XCTAssertEqual(series.last?.words, 6) + XCTAssertEqual(series.dropLast().reduce(0) { $0 + $1.words }, 0) + } + } + + private static func date(_ year: Int, _ month: Int, _ day: Int) -> Date { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = 12 + return utcCalendar.date(from: components)! + } +} + +private func runOnMainActor( + _ body: @MainActor () throws -> Result +) rethrows -> Result { + if Thread.isMainThread { + return try MainActor.assumeIsolated(body) + } + return try DispatchQueue.main.sync { + try MainActor.assumeIsolated(body) + } +}