diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index a425b2b..7491d94 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; }; + A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; @@ -82,6 +83,7 @@ D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = ""; }; F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = ""; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = ""; }; @@ -125,7 +127,8 @@ 7ABBFDF43A1EB773E0A49CFE /* Services */ = { isa = PBXGroup; children = ( - 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */, + A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */, + 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */, 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, @@ -279,7 +282,8 @@ A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */, - BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, + A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */, + BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */, C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, diff --git a/Marks/Services/AnalyticsService.swift b/Marks/Services/AnalyticsService.swift new file mode 100644 index 0000000..d138ff0 --- /dev/null +++ b/Marks/Services/AnalyticsService.swift @@ -0,0 +1,26 @@ +import Foundation + +enum Analytics { + static func track(_ event: String, _ properties: [String: Any] = [:]) { + Task.detached { + do { + let token = try await MarksAuth.validToken() + guard let url = URL(string: MarksAuth.baseURL + "/v1/marks/events") else { return } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + var body: [String: Any] = ["event": event] + if !properties.isEmpty { body["properties"] = properties } + request.httpBody = try JSONSerialization.data(withJSONObject: body) + let (_, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + if status < 200 || status > 299 { + print("[Analytics] \(event) → HTTP \(status)") + } + } catch { + print("[Analytics] \(event) failed: \(error.localizedDescription)") + } + } + } +} diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index 428bc04..c7d9af7 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -88,6 +88,7 @@ final class BookmarksViewModel { restoreAIData(into: &all) bookmarks = try await claude.semanticSearch(query: searchQuery, in: all) nextPageUrl = nil + Analytics.track("search.semantic", ["query": searchQuery, "resultCount": bookmarks.count]) } catch { self.error = error.localizedDescription } @@ -159,6 +160,7 @@ final class BookmarksViewModel { do { let allResponse = try await api.fetchBookmarks(limit: 200) smartCollections = try await claude.generateCollections(from: allResponse.results) + Analytics.track("collections.generated", ["collectionCount": smartCollections.count]) } catch { self.error = error.localizedDescription } @@ -180,6 +182,8 @@ final class BookmarksViewModel { break } } + let enriched = toEnrich.count - bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.count + if enriched > 0 { Analytics.track("enrich.completed", ["count": enriched]) } enrichmentProgress = 0 } diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 9c4fd4c..1749882 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -59,10 +59,12 @@ final class PodcastPlayerViewModel { let cached = ClaudeService.cachedPodcastURL(for: articleUrl) if FileManager.default.fileExists(atPath: cached.path) { + Analytics.track("podcast.started", ["url": articleUrl, "cached": true]) setupPlayer(url: cached, title: "Podcast") return } + Analytics.track("podcast.started", ["url": articleUrl, "cached": false]) phase = .generating(progress: 0, label: "Starting…") pollingTask = Task { @@ -81,13 +83,16 @@ final class PodcastPlayerViewModel { return } if job.isFailed { - phase = .failed(job.error ?? "Generation failed") + let errMsg = job.error ?? "Generation failed" + Analytics.track("podcast.failed", ["url": articleUrl, "error": errMsg]) + phase = .failed(errMsg) return } try await Task.sleep(for: .seconds(3)) } } catch is CancellationError { } catch { + Analytics.track("podcast.failed", ["url": articleUrl, "error": error.localizedDescription]) phase = .failed(error.localizedDescription) } } @@ -155,8 +160,14 @@ final class PodcastPlayerViewModel { let interval = CMTime(seconds: 0.5, preferredTimescale: 600) timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in - self?.currentTime = t.seconds - self?.updateNowPlaying() + guard let self else { return } + let prev = self.currentTime + self.currentTime = t.seconds + self.updateNowPlaying() + // Track completion when crossing 97% + if self.duration > 1 && prev / self.duration < 0.97 && t.seconds / self.duration >= 0.97 { + Analytics.track("podcast.completed", ["url": self.currentArticleUrl, "durationSec": Int(self.duration)]) + } } setupRemoteCommands()