Skip to content

Commit

Permalink
Add inactive tabs statistics to daily tab switcher pixel
Browse files Browse the repository at this point in the history
  • Loading branch information
dus7 committed Nov 27, 2024
1 parent 22fdf2e commit 4be9630
Show file tree
Hide file tree
Showing 2 changed files with 213 additions and 3 deletions.
60 changes: 59 additions & 1 deletion DuckDuckGo/TabSwitcherOpenDailyPixel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -51,6 +59,24 @@ struct TabSwitcherOpenDailyPixel {

}

private func bucketForTabs<Range: RangeExpression>(_ 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 }

Expand All @@ -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<R: RangeExpression>(daysDifferenceRange daysRange: R, fromDate baseDate: Date = .now) where R.Bound == Int {

switch daysRange {
case let daysRange as ClosedRange<R.Bound>:
self.init(start: baseDate.addingTimeInterval(Double(daysRange.lowerBound) * .dayInterval),
end: baseDate.addingTimeInterval(Double(daysRange.upperBound) * .dayInterval))

case let daysRange as PartialRangeThrough<R.Bound>:
self.init(start: Date.distantPast,
end: baseDate.addingTimeInterval(Double(daysRange.upperBound) * .dayInterval))

case let daysRange as PartialRangeFrom<R.Bound>:
self.init(start: baseDate.addingTimeInterval(Double(daysRange.lowerBound) * .dayInterval),
end: Date.distantFuture)

default:
fatalError("\(R.self) is not supported")
}
}
}
156 changes: 154 additions & 2 deletions DuckDuckGoTests/TabSwitcherDailyPixelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

0 comments on commit 4be9630

Please sign in to comment.