diff --git a/DuckDuckGo/TabSwitcherOpenDailyPixel.swift b/DuckDuckGo/TabSwitcherOpenDailyPixel.swift index 8b6941ceb23..4e7caa50d2f 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 + /// - baseDate: Date to be used as a reference for calculating inactive tabs. Required for testing. + func parameters(with tabs: [Tab], baseDate: Date = .now) -> [String: String] { var parameters = [String: String]() parameters[ParameterName.tabCount] = tabCountBucket(for: tabs) parameters[ParameterName.newTabCount] = newTabCountBucket(for: tabs) + parameters[ParameterName.tabActive7dCount] = bucketForTabs(tabs, inDaysRange: (-7)..., fromDate: baseDate) + parameters[ParameterName.tabInactive1wCount] = bucketForTabs(tabs, inDaysRange: (-14)...(-8), fromDate: baseDate) + parameters[ParameterName.tabInactive2wCount] = bucketForTabs(tabs, inDaysRange: (-21)...(-15), fromDate: baseDate) + parameters[ParameterName.tabInactive3wCount] = bucketForTabs(tabs, inDaysRange: ...(-22), fromDate: baseDate) return parameters } @@ -51,6 +59,24 @@ struct TabSwitcherOpenDailyPixel { } + private func bucketForTabs(_ tabs: [Tab], inDaysRange range: Range, fromDate: Date) -> String? where Range.Bound == Int { + let dateInterval = DateInterval(daysDifferenceRange: range, fromDate: fromDate) + + 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,37 @@ 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 +} + +extension DateInterval { + + init(daysDifferenceRange daysRange: R, fromDate baseDate: Date = .now) where R.Bound == Int { + + switch daysRange { + case let daysRange as ClosedRange: + self.init(start: baseDate.addingTimeInterval(Double(daysRange.lowerBound) * .dayInterval), + end: baseDate.addingTimeInterval(Double(daysRange.upperBound) * .dayInterval)) + + case let daysRange as PartialRangeThrough: + self.init(start: Date.distantPast, + end: baseDate.addingTimeInterval(Double(daysRange.upperBound) * .dayInterval)) + + case let daysRange as PartialRangeFrom: + self.init(start: baseDate.addingTimeInterval(Double(daysRange.lowerBound) * .dayInterval), + end: Date.distantFuture) + + default: + fatalError("\(R.self) is not supported") + } } } diff --git a/DuckDuckGoTests/TabSwitcherDailyPixelTests.swift b/DuckDuckGoTests/TabSwitcherDailyPixelTests.swift index 7cc16a60740..4d89d3e51b1 100644 --- a/DuckDuckGoTests/TabSwitcherDailyPixelTests.swift +++ b/DuckDuckGoTests/TabSwitcherDailyPixelTests.swift @@ -85,15 +85,167 @@ final class TabSwitcherDailyPixelTests: 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 pixelParametersForSecondBucket = TabSwitcherOpenDailyPixel().parameters(with: tabs, baseDate: now) + + try testBucketParameters(pixelParametersForSecondBucket, expectedCount: 1) + } + + func testBucketParametersForInactiveTabs() throws { + let now = Date() + + let pixelParametersForSecondBucket = TabSwitcherOpenDailyPixel().parameters(with: Tab.stubCollectionForSecondBucket(baseDate: now), baseDate: now) + let pixelParametersForThirdBucket = TabSwitcherOpenDailyPixel().parameters(with: Tab.stubCollectionForThirdBucket(baseDate: now), baseDate: now) + + try testBucketParameters(pixelParametersForSecondBucket, expectedCount: 5) + try testBucketParameters(pixelParametersForThirdBucket, 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+" + ] + + let parameterDaysOffsetMapping = [ + ParameterName.tabActive7dCount: 0, + ParameterName.tabInactive1wCount: 8, + ParameterName.tabInactive2wCount: 15, + ParameterName.tabInactive3wCount: 21 + ] + + for bucket in expectedBuckets { + let count = bucket.key.lowerBound + + for parameter in parameterDaysOffsetMapping { + let offset = parameter.value + let tabs = Array(repeating: Tab.mock(lastViewedDate: now.daysAgo(offset)), count: count) + let parameters = TabSwitcherOpenDailyPixel().parameters(with: tabs, baseDate: now) + + XCTAssertEqual(parameters[parameter.key], bucket.value) + } + } + } + + // 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 stubCollectionForSecondBucket(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 stubCollectionForThirdBucket(baseDate: Date) -> [Tab] { + stubCollectionForSecondBucket(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)) + } }