commit 8fe22415778bfdd9c2971f0efe7e71b7b068c392 Author: zonble Date: Wed May 9 18:31:16 2018 +0800 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..357f152 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +.idea diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5c83cd5 --- /dev/null +++ b/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version:4.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ActiveDays", + products: [ + // Products define the executables and libraries produced by a package, and make them visible to other packages. + .library( + name: "ActiveDays", + targets: ["ActiveDays"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages which this package depends on. + .target( + name: "ActiveDays", + dependencies: []), + .testTarget( + name: "ActiveDaysTests", + dependencies: ["ActiveDays"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..af6da46 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ActiveDays + +A description of this package. diff --git a/Sources/ActiveDays/ActiveDays.swift b/Sources/ActiveDays/ActiveDays.swift new file mode 100644 index 0000000..70ebab0 --- /dev/null +++ b/Sources/ActiveDays/ActiveDays.swift @@ -0,0 +1,172 @@ +import Foundation + +public enum ActiveDaysCounterError: Error, LocalizedError { + case sessionUnstarted + case invalidDate + case needsNewSession + + public var errorDescription: String? { + switch self { + case .sessionUnstarted: + return NSLocalizedString("You did not start a session.", comment: "") + case .invalidDate: + return NSLocalizedString("The incoming date is before the week.", comment: "") + case .needsNewSession: + return NSLocalizedString("The incoming date is not in the week. Please start a new session.", comment: "") + } + } +} + + +/// The result that you get when you commit a new access date to +/// ActiveDaysPerWeekCounter. +/// +/// - noEvent: you should not send anything to an analytics platform. +/// - active: you should send an event to indicate how many days this +/// user has been active. +public enum ActiveDaysCounterResult { + case noEvent + case active(Int) +} + + +/// ActiveDaysPerWeekCounter is a class that we made to answer a +/// challenge: how do we know about how many days does a user uses our +/// app per week? +/// +/// In KKBOX, we would like to find methods to measure how much does +/// our app engadge with our users, and active days per week could be +/// an indicator. However, it looks like most of the popular analytics +/// platforms such as Firebase and so on do not provide such +/// information. +/// +/// Thus, we would like to make active days as "events" on analytics +/// platforms, for example, "active for one day", "active for two +/// days" could be events. ActiveDaysPerWeekCounter is a helper class +/// to let us decide if we should send an event, and which event, to +/// analytics platforms. +/// +/// After creating an instance of ActiveDaysPerWeekCounter, you should +/// ask it to start a session. And then you can get a result when you +/// commit a new access date to the counter. + +public class ActiveDaysPerWeekCounter { + private var setingKey: String + + /// Create an instance. + /// + /// - Parameter setingKey: the key for helping storing data. + public init(setingKey: String) { + self.setingKey = setingKey + } + + /// Remove all saved data. + public func reset() { + self.sessionBeginDate = nil + self.lastResult = nil + self.lastResultMadeDate = nil + } + + public func startNewSessionIfNoExitingOne() -> Bool { + if let _ = self.sessionBeginDate { + return false + } + let date = Date() + self.startSession(date: date) + return true + } + + /// Start a new session by a given date, and the base of the + /// session of be the start of the week where the given date is + /// in. + /// + /// - Parameter date: the date for helping deciding the time base + /// of the session. + public func startSession(date: Date) { + self.sessionBeginDate = date.weekBeginDay + self.lastResult = nil + self.lastResultMadeDate = nil + } + + public func commit(accessDate: Date) throws -> ActiveDaysCounterResult { + guard let beginDate = self.sessionBeginDate else { + throw ActiveDaysCounterError.sessionUnstarted + } + if accessDate.compare(beginDate) == ComparisonResult.orderedAscending { + throw ActiveDaysCounterError.invalidDate + } + if accessDate.timeIntervalSince(beginDate) > 7 * 24 * 60 * 60 { + throw ActiveDaysCounterError.needsNewSession + } + + guard let lastResultMadeDate = self.lastResultMadeDate, + let lastResult = self.lastResult else { + self.lastResultMadeDate = accessDate + self.lastResult = 1 + return .active(1) + } + + if accessDate.compare(lastResultMadeDate) == ComparisonResult.orderedAscending { + throw ActiveDaysCounterError.invalidDate + } + + if lastResultMadeDate.isInSameDay(with: accessDate) { + return .noEvent + } + + let newResult = lastResult + 1 + self.lastResult = newResult + self.lastResultMadeDate = accessDate + return .active(newResult) + } + +} + + +private extension ActiveDaysPerWeekCounter { + + private var sessionBeginDate: Date? { + get { + return UserDefaults.standard.object(forKey: setingKey + "-sessionBegin") as? Date + } + set { + guard let newValue = newValue else { + UserDefaults.standard.removeObject(forKey: setingKey + "-sessionBegin") + return + } + UserDefaults.standard.set(newValue, forKey: setingKey + "-sessionBegin") + UserDefaults.standard.synchronize() + } + } + + private var lastResult: Int? { + get { + guard let int = UserDefaults.standard.object(forKey: setingKey + "-lastResult") as? Int else { + return nil + } + return int + } + set { + guard let newValue = newValue else { + UserDefaults.standard.removeObject(forKey: setingKey + "-lastResult") + return + } + UserDefaults.standard.set(newValue, forKey: setingKey + "-lastResult") + UserDefaults.standard.synchronize() + } + } + + private var lastResultMadeDate: Date? { + get { + return UserDefaults.standard.object(forKey: setingKey + "-lastDate") as? Date + } + set { + guard let newValue = newValue else { + UserDefaults.standard.removeObject(forKey: setingKey + "-lastDate") + return + } + UserDefaults.standard.set(newValue, forKey: setingKey + "-lastDate") + UserDefaults.standard.synchronize() + } + } +} diff --git a/Sources/ActiveDays/DateExtensions.swift b/Sources/ActiveDays/DateExtensions.swift new file mode 100644 index 0000000..e15f74c --- /dev/null +++ b/Sources/ActiveDays/DateExtensions.swift @@ -0,0 +1,59 @@ +import Foundation + +extension Date { + + /// Create a new date instance basing on Gregorian calendar and + /// GMT+0 timezone. + /// + /// - Parameters: + /// - year: year + /// - month: month + /// - day: day + /// - hour: hour + /// - minute: minute + /// - second: second + /// - Returns: a date instance. + public static func from(year: Int, month: Int, day: Int, hour: Int, minute: Int, second: Int) -> Date? { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + components.second = second + let gmt0 = TimeZone(secondsFromGMT: 0)! + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = gmt0 + return calendar.date(from: components) + } + + /// Return the day of the week where the date is at, basing on + /// GMT+-0 timezone. + public var weekBeginDay: Date? { + let gmt0 = TimeZone(secondsFromGMT: 0)! + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = gmt0 + var components = calendar.dateComponents(in: gmt0, from: self) + components.hour = 0 + components.minute = 0 + components.second = 0 + components.nanosecond = 0 + components.day = components.day! - components.weekdayOrdinal! + components.weekdayOrdinal = 0 + return calendar.date(from: components) + } + + /// If the date and another date is in the same date. + public func isInSameDay(with date: Date) -> Bool { + let gmt0 = TimeZone(secondsFromGMT: 0)! + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = gmt0 + + let selfComponents = calendar.dateComponents(in: gmt0, from: self) + let incomingComponents = calendar.dateComponents(in: gmt0, from: date) + return selfComponents.year == incomingComponents.year && + selfComponents.month == incomingComponents.month && + selfComponents.day == incomingComponents.day + } + +} diff --git a/Tests/ActiveDaysTests/ActiveDaysTests.swift b/Tests/ActiveDaysTests/ActiveDaysTests.swift new file mode 100644 index 0000000..22fd4a9 --- /dev/null +++ b/Tests/ActiveDaysTests/ActiveDaysTests.swift @@ -0,0 +1,105 @@ +import XCTest +@testable import ActiveDays + +final class ActiveDaysTests: XCTestCase { + var counter: ActiveDaysPerWeekCounter! + + override func setUp() { + self.counter = ActiveDaysPerWeekCounter(setingKey: "com.kkbox.activedays") + self.counter.reset() + } + + override func tearDown() { + self.counter.reset() + self.counter = nil + } + + func testStartSession() { + XCTAssertTrue(self.counter.startNewSessionIfNoExitingOne()) + XCTAssertFalse(self.counter.startNewSessionIfNoExitingOne()) + } + + func testUnstartedSession() { + do { + _ = try self.counter.commit(accessDate: Date()) + XCTFail() + } catch { + XCTAssertNotNil(error) + } + } + + func testOldDate() { + self.counter.startSession(date: Date.from(year: 2018, month: 5, day: 9, hour: 1, minute: 2, second: 3)!) + do { + _ = try self.counter.commit(accessDate: Date.from(year: 2018, month: 4, day: 15, hour: 1, minute: 2, second: 3)!) + XCTFail() + } catch ActiveDaysCounterError.invalidDate { + } catch { + XCTFail() + } + } + + func testNeedNewSession() { + self.counter.startSession(date: Date.from(year: 2018, month: 5, day: 9, hour: 1, minute: 2, second: 3)!) + do { + _ = try self.counter.commit(accessDate: Date.from(year: 2018, month: 6, day: 15, hour: 1, minute: 2, second: 3)!) + XCTFail() + } catch ActiveDaysCounterError.needsNewSession { + } catch { + XCTFail() + } + } + + func testCommit() { + self.counter.startSession(date: Date.from(year: 2018, month: 5, day: 9, hour: 1, minute: 2, second: 3)!) + var result = try! self.counter.commit(accessDate: Date.from(year: 2018, month: 5, day: 9, hour: 1, minute: 2, second: 3)!) + if case let .active(days) = result { + XCTAssertTrue(days == 1) + } else { + XCTFail() + } + result = try! self.counter.commit(accessDate: Date.from(year: 2018, month: 5, day: 9, hour: 2, minute: 2, second: 3)!) + if case .noEvent = result { + } else { + XCTFail() + } + + result = try! self.counter.commit(accessDate: Date.from(year: 2018, month: 5, day: 11, hour: 2, minute: 2, second: 3)!) + if case let .active(days) = result { + XCTAssertTrue(days == 2) + } else { + XCTFail() + } + + result = try! self.counter.commit(accessDate: Date.from(year: 2018, month: 5, day: 11, hour: 8, minute: 2, second: 3)!) + if case .noEvent = result { + } else { + XCTFail() + } + + + do { + _ = try self.counter.commit(accessDate: Date.from(year: 2018, month: 5, day: 9, hour: 1, minute: 2, second: 3)!) + XCTFail() + } catch ActiveDaysCounterError.invalidDate { + } catch { + XCTFail() + } + + result = try! self.counter.commit(accessDate: Date.from(year: 2018, month: 5, day: 13, hour: 2, minute: 2, second: 3)!) + if case let .active(days) = result { + XCTAssertTrue(days == 3) + } else { + XCTFail() + } + + do { + _ = try self.counter.commit(accessDate: Date.from(year: 2018, month: 5, day: 14, hour: 1, minute: 2, second: 3)!) + XCTFail() + } catch ActiveDaysCounterError.needsNewSession { + } catch { + XCTFail() + } + } + +} diff --git a/Tests/ActiveDaysTests/DateExtensionTest.swift b/Tests/ActiveDaysTests/DateExtensionTest.swift new file mode 100644 index 0000000..3760f56 --- /dev/null +++ b/Tests/ActiveDaysTests/DateExtensionTest.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import ActiveDays + +class DateExtensionTest: XCTestCase { + + func testBeginOfTheWeek1() { + guard let date = Date.from(year: 2018, month: 5, day: 9, hour: 16, minute: 4, second: 0), + let begin = date.weekBeginDay else { + return + } + let gmt0 = TimeZone(secondsFromGMT: 0)! + let calendar = Calendar(identifier: .gregorian) + let components = calendar.dateComponents(in: gmt0, from: begin) + XCTAssertTrue(components.year == 2018) + XCTAssertTrue(components.month == 5) + XCTAssertTrue(components.day == 7) + XCTAssertTrue(components.hour == 0) + XCTAssertTrue(components.second == 0) + XCTAssertTrue(components.minute == 0) + } + + func testBeginOfTheWeek2() { + guard let date = Date.from(year: 2018, month: 5, day: 25, hour: 16, minute: 4, second: 0), + let begin = date.weekBeginDay else { + return + } + let gmt0 = TimeZone(secondsFromGMT: 0)! + let calendar = Calendar(identifier: .gregorian) + let components = calendar.dateComponents(in: gmt0, from: begin) + XCTAssertTrue(components.year == 2018) + XCTAssertTrue(components.month == 5) + XCTAssertTrue(components.day == 21) + XCTAssertTrue(components.hour == 0) + XCTAssertTrue(components.second == 0) + XCTAssertTrue(components.minute == 0) + } + + func testIsSameDay() { + guard let date1 = Date.from(year: 2018, month: 5, day: 25, hour: 16, minute: 4, second: 0), + let date2 = Date.from(year: 2018, month: 5, day: 25, hour: 17, minute: 4, second: 0) else { + return + } + XCTAssertTrue(date1.isInSameDay(with: date2)) + } + + func testIsNotSameDay() { + guard let date1 = Date.from(year: 2018, month: 5, day: 25, hour: 16, minute: 4, second: 0), + let date2 = Date.from(year: 2017, month: 5, day: 25, hour: 17, minute: 4, second: 0) else { + return + } + XCTAssertFalse(date1.isInSameDay(with: date2)) + + } + + +} diff --git a/Tests/ActiveDaysTests/XCTestManifests.swift b/Tests/ActiveDaysTests/XCTestManifests.swift new file mode 100644 index 0000000..8355395 --- /dev/null +++ b/Tests/ActiveDaysTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !os(macOS) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(ActiveDaysTests.allTests), + ] +} +#endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..44d8b6c --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import ActiveDaysTests + +var tests = [XCTestCaseEntry]() +tests += ActiveDaysTests.allTests() +XCTMain(tests) \ No newline at end of file