From 0120f33a06c60aec227d4dcda8bd2462d02676d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20=C5=9Apiewak?= Date: Wed, 27 Nov 2024 16:07:36 +0100 Subject: [PATCH] Add inactive tabs statistics to daily tab switcher pixel --- DuckDuckGo/TabSwitcherOpenDailyPixel.swift | 65 ++++++- .../TabSwitcherOpenDailyPixelTests.swift | 162 +++++++++++++++++- 2 files changed, 224 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/TabSwitcherOpenDailyPixel.swift b/DuckDuckGo/TabSwitcherOpenDailyPixel.swift index 8b6941ceb2..c46735c497 100644 --- a/DuckDuckGo/TabSwitcherOpenDailyPixel.swift +++ b/DuckDuckGo/TabSwitcherOpenDailyPixel.swift @@ -20,11 +20,19 @@ import Foundation struct TabSwitcherOpenDailyPixel { - func parameters(with tabs: [Tab]) -> [String: String] { + /// Returns parameters with buckets for respective tabs statistics. + /// - Parameters: + /// - tabs: Tabs to be included in the statistics + /// - referenceDate: Date to be used as a reference for calculating inactive tabs. Required for testing. + func parameters(with tabs: [Tab], referenceDate: Date = .now) -> [String: String] { var parameters = [String: String]() parameters[ParameterName.tabCount] = tabCountBucket(for: tabs) parameters[ParameterName.newTabCount] = newTabCountBucket(for: tabs) + parameters[ParameterName.tabActive7dCount] = bucketForInactiveTabs(tabs, within: (-7)..., from: referenceDate) + parameters[ParameterName.tabInactive1wCount] = bucketForInactiveTabs(tabs, within: (-14)...(-8), from: referenceDate) + parameters[ParameterName.tabInactive2wCount] = bucketForInactiveTabs(tabs, within: (-21)...(-15), from: referenceDate) + parameters[ParameterName.tabInactive3wCount] = bucketForInactiveTabs(tabs, within: ...(-22), from: referenceDate) return parameters } @@ -51,6 +59,24 @@ struct TabSwitcherOpenDailyPixel { } + private func bucketForInactiveTabs(_ tabs: [Tab], within daysInterval: Range, from referenceDate: Date) -> String? where Range.Bound == Int { + let dateInterval = AbsoluteDateInterval(daysInterval: daysInterval, basedOn: referenceDate) + + let matchingTabsCount = tabs.count { + guard let lastViewedDate = $0.lastViewedDate else { return false } + + return dateInterval.contains(lastViewedDate) + } + + switch matchingTabsCount { + case 0: return "0" + case 1...5: return "1-5" + case 6...10: return "6-10" + case 11...20: return "11-20" + default: return "21+" + } + } + private func newTabCountBucket(for tabs: [Tab]) -> String? { let count = tabs.count { $0.link == nil } @@ -64,5 +90,42 @@ struct TabSwitcherOpenDailyPixel { private enum ParameterName { static let tabCount = "tab_count" static let newTabCount = "new_tab_count" + + static let tabActive7dCount = "tab_active_7d" + static let tabInactive1wCount = "tab_inactive_1w" + static let tabInactive2wCount = "tab_inactive_2w" + static let tabInactive3wCount = "tab_inactive_3w" + } +} + +private extension TimeInterval { + static let dayInterval: TimeInterval = 86400 +} + +private struct AbsoluteDateInterval where R.Bound == Int { + private let lowerBoundDate: Date + private let upperBoundDate: Date + + init(daysInterval: R, basedOn referenceDate: Date) { + switch daysInterval { + case let daysRange as ClosedRange: + self.lowerBoundDate = referenceDate.addingTimeInterval(Double(daysRange.lowerBound) * .dayInterval) + self.upperBoundDate = referenceDate.addingTimeInterval(Double(daysRange.upperBound) * .dayInterval) + + case let daysRange as PartialRangeThrough: + self.lowerBoundDate = Date.distantPast + self.upperBoundDate = referenceDate.addingTimeInterval(Double(daysRange.upperBound) * .dayInterval) + + case let daysRange as PartialRangeFrom: + self.lowerBoundDate = referenceDate.addingTimeInterval(Double(daysRange.lowerBound) * .dayInterval) + self.upperBoundDate = Date.distantFuture + + default: + fatalError("\(R.self) is not supported") + } + } + + func contains(_ date: Date) -> Bool { + lowerBoundDate...upperBoundDate ~= date } } diff --git a/DuckDuckGoTests/TabSwitcherOpenDailyPixelTests.swift b/DuckDuckGoTests/TabSwitcherOpenDailyPixelTests.swift index 3115b4a252..9d5d9c984c 100644 --- a/DuckDuckGoTests/TabSwitcherOpenDailyPixelTests.swift +++ b/DuckDuckGoTests/TabSwitcherOpenDailyPixelTests.swift @@ -85,15 +85,173 @@ final class TabSwitcherOpenDailyPixelTests: XCTestCase { } } } + + // - MARK: Inactive tabs aggregation tests + + func testTabsWithoutLastVisitValueArentIncludedInBuckets() throws { + let tabs = [Tab.mock(), .mock()] + + let parameters = TabSwitcherOpenDailyPixel().parameters(with: tabs) + + try testBucketParameters(parameters, expectedCount: 0) + } + + func testEdgeCaseBucketParameterForInactiveTabs() throws { + let now = Date() + + let tabs: [Tab] = [ + .mock(lastViewedDate: now.daysAgo(7)), + .mock(lastViewedDate: now.daysAgo(14)), + .mock(lastViewedDate: now.daysAgo(21)), + .mock(lastViewedDate: now.daysAgo(22)) + ] + + let pixelParametersForSecondInterval = TabSwitcherOpenDailyPixel().parameters(with: tabs, referenceDate: now) + + try testBucketParameters(pixelParametersForSecondInterval, expectedCount: 1) + } + + func testBucketParametersForInactiveTabs() throws { + let now = Date() + + let tabsSecondInterval = Tab.stubCollectionForSecondInterval(baseDate: now) + let parametersForSecondInterval = TabSwitcherOpenDailyPixel().parameters(with: tabsSecondInterval, referenceDate: now) + + let tabsThirdInterval = Tab.stubCollectionForThirdInterval(baseDate: now) + let parametersForThirdInterval = TabSwitcherOpenDailyPixel().parameters(with: tabsThirdInterval, referenceDate: now) + + try testBucketParameters(parametersForSecondInterval, expectedCount: 5) + try testBucketParameters(parametersForThirdInterval, expectedCount: 6) + } + + func testBucketNamingForInactiveTabs() throws { + let now = Date() + let expectedBuckets = [ + 0...0: "0", + 1...5: "1-5", + 6...10: "6-10", + 11...20: "11-20", + 21...40: "21+" + ] + + // How many days need to pass for each interval bucket + let parameterDaysOffsetMapping = [ + ParameterName.tabActive7dCount: 0, + ParameterName.tabInactive1wCount: 8, + ParameterName.tabInactive2wCount: 15, + ParameterName.tabInactive3wCount: 22 + ] + + for bucket in expectedBuckets { + let count = bucket.key.lowerBound + + for parameter in parameterDaysOffsetMapping { + let daysOffset = parameter.value + // Create tabs based on expected count for bucket, using proper days offset + let tabs = Array(repeating: Tab.mock(lastViewedDate: now.daysAgo(daysOffset)), count: count) + + let parameters = TabSwitcherOpenDailyPixel().parameters(with: tabs, referenceDate: now) + + XCTAssertEqual(parameters[parameter.key], bucket.value, "Failed for bucket: \(bucket.key) with parameter: \(parameter.key)") + } + } + } + + // MARK: - Test helper methods + + private func testBucketParameters(_ parameters: [String: String], expectedCount: Int) throws { + let parameterNames = [ + ParameterName.tabActive7dCount, + ParameterName.tabInactive1wCount, + ParameterName.tabInactive2wCount, + ParameterName.tabInactive3wCount + ] + + let expectedBucket = try XCTUnwrap(Buckets.inactiveTabs.first { $0.key.contains(expectedCount) }).value + for parameterName in parameterNames { + let bucketValue = parameters[parameterName] + + XCTAssertEqual(bucketValue, expectedBucket, "Failed for parameter: \(parameterName)") + } + } } private extension Tab { - static func mock() -> Tab { - Tab(link: Link(title: nil, url: URL("https://example.com")!)) + static func mock(lastViewedDate: Date? = nil) -> Tab { + Tab(link: Link(title: nil, url: URL("https://example.com")!), lastViewedDate: lastViewedDate) + } + + static func stubCollectionForSecondInterval(baseDate: Date) -> [Tab] { + [ + // MARK: First week + .mock(lastViewedDate: baseDate), + .mock(lastViewedDate: baseDate.daysAgo(3)), + .mock(lastViewedDate: baseDate.daysAgo(4)), + .mock(lastViewedDate: baseDate.daysAgo(5)), + .mock(lastViewedDate: baseDate.daysAgo(7)), + + // MARK: >1 week + .mock(lastViewedDate: baseDate.daysAgo(8)), + .mock(lastViewedDate: baseDate.daysAgo(10)), + .mock(lastViewedDate: baseDate.daysAgo(11)), + .mock(lastViewedDate: baseDate.daysAgo(12)), + .mock(lastViewedDate: baseDate.daysAgo(14)), + + // MARK: >2 weeks + .mock(lastViewedDate: baseDate.daysAgo(15)), + .mock(lastViewedDate: baseDate.daysAgo(16)), + .mock(lastViewedDate: baseDate.daysAgo(17)), + .mock(lastViewedDate: baseDate.daysAgo(18)), + .mock(lastViewedDate: baseDate.daysAgo(21)), + + // MARK: >3 weeks + .mock(lastViewedDate: baseDate.daysAgo(22)), + .mock(lastViewedDate: baseDate.daysAgo(23)), + .mock(lastViewedDate: baseDate.daysAgo(24)), + .mock(lastViewedDate: baseDate.daysAgo(100)), + .mock(lastViewedDate: Date.distantPast), + ] + } + static func stubCollectionForThirdInterval(baseDate: Date) -> [Tab] { + stubCollectionForSecondInterval(baseDate: baseDate) + + [ + // MARK: First week + .mock(lastViewedDate: baseDate.daysAgo(4)), + + // MARK: >1 week + .mock(lastViewedDate: baseDate.daysAgo(14)), + + // MARK: >2 weeks + .mock(lastViewedDate: baseDate.daysAgo(15)), + + // MARK: >3 weeks + .mock(lastViewedDate: baseDate.daysAgo(22)) + ] } } private enum ParameterName { static let newTabCount = "new_tab_count" static let tabCount = "tab_count" + + static let tabActive7dCount = "tab_active_7d" + static let tabInactive1wCount = "tab_inactive_1w" + static let tabInactive2wCount = "tab_inactive_2w" + static let tabInactive3wCount = "tab_inactive_3w" +} + +private enum Buckets { + static let inactiveTabs = [ + 0...0: "0", + 1...5: "1-5", + 6...10: "6-10", + 11...20: "11-20", + 21...40: "21+" + ] +} + +private extension Date { + func daysAgo(_ days: Int) -> Date { + addingTimeInterval(TimeInterval(-days * 86400)) + } }