Initial commit.
This commit is contained in:
commit
8fe2241577
|
@ -0,0 +1,5 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
.idea
|
|
@ -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"]),
|
||||
]
|
||||
)
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import XCTest
|
||||
|
||||
#if !os(macOS)
|
||||
public func allTests() -> [XCTestCaseEntry] {
|
||||
return [
|
||||
testCase(ActiveDaysTests.allTests),
|
||||
]
|
||||
}
|
||||
#endif
|
|
@ -0,0 +1,7 @@
|
|||
import XCTest
|
||||
|
||||
import ActiveDaysTests
|
||||
|
||||
var tests = [XCTestCaseEntry]()
|
||||
tests += ActiveDaysTests.allTests()
|
||||
XCTMain(tests)
|
Loading…
Reference in New Issue