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 28, 2024
1 parent 82cdfb4 commit 0120f33
Show file tree
Hide file tree
Showing 2 changed files with 224 additions and 3 deletions.
65 changes: 64 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
/// - 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
}
Expand All @@ -51,6 +59,24 @@ struct TabSwitcherOpenDailyPixel {

}

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

Expand All @@ -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<R: RangeExpression> 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<R.Bound>:
self.lowerBoundDate = referenceDate.addingTimeInterval(Double(daysRange.lowerBound) * .dayInterval)
self.upperBoundDate = referenceDate.addingTimeInterval(Double(daysRange.upperBound) * .dayInterval)

case let daysRange as PartialRangeThrough<R.Bound>:
self.lowerBoundDate = Date.distantPast
self.upperBoundDate = referenceDate.addingTimeInterval(Double(daysRange.upperBound) * .dayInterval)

case let daysRange as PartialRangeFrom<R.Bound>:
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
}
}
162 changes: 160 additions & 2 deletions DuckDuckGoTests/TabSwitcherOpenDailyPixelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

0 comments on commit 0120f33

Please sign in to comment.