Initial commit.

This commit is contained in:
zonble 2018-05-09 18:31:16 +08:00
commit 8fe2241577
9 changed files with 444 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
.idea

28
Package.swift Normal file
View File

@ -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"]),
]
)

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# ActiveDays
A description of this package.

View File

@ -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()
}
}
}

View File

@ -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
}
}

View File

@ -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()
}
}
}

View File

@ -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))
}
}

View File

@ -0,0 +1,9 @@
import XCTest
#if !os(macOS)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(ActiveDaysTests.allTests),
]
}
#endif

7
Tests/LinuxMain.swift Normal file
View File

@ -0,0 +1,7 @@
import XCTest
import ActiveDaysTests
var tests = [XCTestCaseEntry]()
tests += ActiveDaysTests.allTests()
XCTMain(tests)