Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 67 additions & 31 deletions BeeKit/Managers/GoalManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,17 @@ import SwiftyJSON
logger.notice("Goals unknown, doing full fetch")
// We must fetch the user object first, and then fetch goals afterwards, to guarantee User.updated_at is
// a safe timestamp for future fetches without losing data
let userResponse = JSON(try await requestManager.get(url: "api/v1/users/{username}.json")!)
let goalResponse = JSON(
try await requestManager.get(url: "api/v1/users/{username}/goals.json", parameters: ["emaciated": "true"])!
)
guard let getUser = try await requestManager.get(url: "api/v1/users/{username}.json") else {
throw GoalManagerError.getUserFailed
}
let userResponse = JSON(getUser)
guard
let getGoals = try await requestManager.get(
url: "api/v1/users/{username}/goals.json",
parameters: ["emaciated": "true"]
)
else { throw GoalManagerError.getGoalsFailed }
let goalResponse = JSON(getGoals)

// The user may have logged out during the network operation. If so we have nothing to do
modelContext.refreshAllObjects()
Expand All @@ -97,17 +104,18 @@ import SwiftyJSON
let goalsToDelete = user.goals.filter { !allGoalIds.contains($0.id) }
for goal in goalsToDelete { modelContext.delete(goal) }
}
updateGoalsFromJson(goalResponse)
try updateGoalsFromJson(goalResponse)
}
/// Perform an incremental refresh of goals for regular updates
private func refreshGoalsIncremental(user: User) async throws {
logger.notice("Doing incremental update since \(user.updatedAt, privacy: .public)")
let userResponse = JSON(
try await requestManager.get(
guard
let getUser = try await requestManager.get(
url: "api/v1/users/{username}.json",
parameters: ["diff_since": user.updatedAt.timeIntervalSince1970 + 1, "emaciated": "true"]
)!
)
)
else { throw GoalManagerError.getUserFailed }
let userResponse = JSON(getUser)
let goalResponse = userResponse["goals"]
let deletedGoals = userResponse["deleted_goals"]
// The user may have logged out during the network operation. If so we have nothing to do
Expand All @@ -118,52 +126,58 @@ import SwiftyJSON
let deletedGoalIds = Set(deletedGoals.arrayValue.map { $0["id"].stringValue })
let goalsToDelete = user.goals.filter { deletedGoalIds.contains($0.id) }
for goal in goalsToDelete { modelContext.delete(goal) }
updateGoalsFromJson(goalResponse)
try updateGoalsFromJson(goalResponse)
// Update lastUpdatedLocal for all goals, even those not in response
let now = Date()
for goal in user.goals { goal.lastUpdatedLocal = now }
}

public func refreshGoal(_ goalID: NSManagedObjectID) async throws {
let goal = try modelContext.existingObject(with: goalID) as! Goal

let responseObject = try await requestManager.get(
url: "/api/v1/users/\(currentUserManager.username!)/goals/\(goal.slug)",
parameters: ["datapoints_count": "5", "emaciated": "true"]
)
let goalJSON = JSON(responseObject!)

guard let goal = try modelContext.existingObject(with: goalID) as? Goal else {
throw GoalManagerError.refreshGoalFailed(goalID: goalID, reason: "goal not found")
}
guard
let responseObject = try await requestManager.get(
url: "/api/v1/users/\(goal.owner.username)/goals/\(goal.slug)",
parameters: ["datapoints_count": "5", "emaciated": "true"]
)
else { throw GoalManagerError.getGoalFailed(goalname: goal.slug, goalID: goal.id) }
let goalJSON = JSON(responseObject)
// The goal may have changed during the network operation, reload latest version
modelContext.refresh(goal, mergeChanges: false)
goal.updateToMatch(json: goalJSON)

try modelContext.save()

await performPostGoalUpdateBookkeeping()
}

public func forceAutodataRefresh(_ goal: Goal) async throws {
let _ = try await requestManager.get(
url: "/api/v1/users/\(currentUserManager.username!)/goals/\(goal.slug)/refresh_graph.json"
url: "/api/v1/users/\(goal.owner.username)/goals/\(goal.slug)/refresh_graph.json"
)
}

private func updateGoalsFromJson(_ responseJSON: JSON) {
guard let responseGoals = responseJSON.array else { return }
private func updateGoalsFromJson(_ responseJSON: JSON) throws {
guard let responseGoals = responseJSON.array else {
logger.error("responseJSON apparently not array")
return
}

// The user may have logged out while waiting for the data, so ignore if so
guard let user = self.currentUserManager.user(context: modelContext) else { return }
guard let user = self.currentUserManager.user(context: modelContext) else {
logger.info("The user may have logged out while waiting for the data, so ignore if so")
return
}

// Create and update existing goals
for goalJSON in responseGoals {
let goalId = goalJSON["id"].stringValue
guard let goalId = goalJSON["id"].string else {
logger.error("goalJSON missing id")
continue
}
let request = NSFetchRequest<Goal>(entityName: "Goal")
request.predicate = NSPredicate(format: "id == %@", goalId)
// TODO: Better error handling of failure here?
if let existingGoal = try! modelContext.fetch(request).first {

if let existingGoal = try modelContext.fetch(request).first {
existingGoal.updateToMatch(json: goalJSON)
} else {
let _ = Goal(context: modelContext, owner: user, json: goalJSON)
_ = Goal(context: modelContext, owner: user, json: goalJSON)
}
}

Expand Down Expand Up @@ -222,3 +236,25 @@ import SwiftyJSON
// TODO: Delete from CoreData
}
}

extension GoalManager {
fileprivate enum GoalManagerError: Error {
case getUserFailed
case getGoalsFailed
case getGoalFailed(goalname: String, goalID: String)
case refreshGoalFailed(goalID: NSManagedObjectID, reason: String)
}
}

extension GoalManager.GoalManagerError: LocalizedError {
public var errorDescription: String? {
switch self {
case .getGoalsFailed: return NSLocalizedString("Failed to get goals", comment: "getGoalsFailed")
case .getUserFailed: return NSLocalizedString("Failed to get user", comment: "getUserFailed")
case .getGoalFailed(let goalname, let goalID):
return NSLocalizedString("Failed to get goal: \(goalname) with id: \(goalID)", comment: "getGoalFailed")
case .refreshGoalFailed(let goalID, let reason):
return NSLocalizedString("Failed to refresh goal: \(goalID) because: \(reason)", comment: "refreshGoalFailed")
}
}
}
5 changes: 3 additions & 2 deletions BeeKit/Managers/VersionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ public class VersionManager {
return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String
}
private func checkIfUpdateRequired() async throws -> Bool {
let responseJSON = try await requestManager.get(url: "api/private/app_versions.json")
guard let responseJSON = try await requestManager.get(url: "api/private/app_versions.json"),
let response = JSON(responseJSON).dictionary
else { throw VersionError.invalidServerResponse }

guard let response = JSON(responseJSON!).dictionary else { throw VersionError.invalidServerResponse }
guard let minVersion = response["min_ios"]?.number?.decimalValue else { throw VersionError.noMinimumVersion }
minRequiredVersion = "\(minVersion)"

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788" systemVersion="24E263" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23G93" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="DataPoint" representedClassName="DataPoint" syncable="YES">
<attribute name="comment" optional="YES" attributeType="String"/>
<attribute name="daystampRaw" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="lastModifiedLocal" optional="YES" attributeType="Date" usesScalarValueType="NO" elementID="lastUpdatedLocal"/>
<attribute name="lastModifiedLocal" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="requestid" optional="YES" attributeType="String"/>
<attribute name="updatedAt" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="value" attributeType="Decimal" defaultValueString="0.0"/>
Expand All @@ -14,13 +14,12 @@
<attribute name="alertStart" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="autodata" optional="YES" attributeType="String"/>
<attribute name="deadline" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="dueBy" optional="YES" attributeType="Transformable" valueTransformerName="DueByTableValueTransformer" customClassName="NSDictionary"/>
<attribute name="graphUrl" attributeType="String"/>
<attribute name="healthKitMetric" optional="YES" attributeType="String"/>
<attribute name="hhmmFormat" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="id" attributeType="String"/>
<attribute name="initDay" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastModifiedLocal" optional="YES" attributeType="Date" usesScalarValueType="NO" elementID="lastUpdatedLocal"/>
<attribute name="lastModifiedLocal" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="lastTouch" attributeType="String"/>
<attribute name="leadTime" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="limSum" attributeType="String"/>
Expand All @@ -45,9 +44,8 @@
<attribute name="defaultAlertStart" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="defaultDeadline" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="defaultLeadTime" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="lastModifiedLocal" optional="YES" attributeType="Date" usesScalarValueType="NO" elementID="lastUpdatedLocal"/>
<attribute name="lastModifiedLocal" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="timezone" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" defaultDateTimeInterval="-978278400" usesScalarValueType="YES"/>
<attribute name="username" attributeType="String"/>
<relationship name="goals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Goal" inverseName="owner" inverseEntity="Goal"/>
</entity>
Expand Down
65 changes: 30 additions & 35 deletions BeeKitTests/MigrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import XCTest

class MigrationTests: XCTestCase {
private struct TestData {
static let userLastModified = Date(timeIntervalSince1970: 1_600_000_000)
static let goalLastModified = Date(timeIntervalSince1970: 1_610_000_000)
static let dataPointLastModified = Date(timeIntervalSince1970: 1_620_000_000)
static let userLastModified = Date(timeIntervalSince1970: 0)
static let goalLastModified = Date(timeIntervalSince1970: 0)
static let dataPointLastModified = Date(timeIntervalSince1970: 0)
}
override func tearDown() {
super.tearDown()
Expand All @@ -23,7 +23,7 @@ class MigrationTests: XCTestCase {
}
}
// Creates a CoreData store with the old model version (v1)
private func createStoreWithOldModel() -> URL {
private func createStoreWithb54Model() -> URL {
let storeURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(
"TestMigration_\(UUID().uuidString).sqlite"
)
Expand Down Expand Up @@ -52,8 +52,6 @@ class MigrationTests: XCTestCase {
user.setValue("testuser", forKey: "username")
user.setValue("America/Los_Angeles", forKey: "timezone")
user.setValue(false, forKey: "deadbeat")
user.setValue(Date(), forKey: "updatedAt")
user.setValue(TestData.userLastModified, forKey: "lastModifiedLocal")

// Create goal with minimal required fields
let goal = NSEntityDescription.insertNewObject(forEntityName: "Goal", into: context)
Expand All @@ -68,15 +66,13 @@ class MigrationTests: XCTestCase {
goal.setValue(0, forKey: field)
}
for field in ["hhmmFormat", "queued", "todayta", "useDefaults", "won"] { goal.setValue(false, forKey: field) }
goal.setValue(DueByDictionary(), forKey: "dueBy")
goal.setValue(TestData.goalLastModified, forKey: "lastModifiedLocal")
goal.setValue(user, forKey: "owner")
// Create datapoint
let dataPoint = NSEntityDescription.insertNewObject(forEntityName: "DataPoint", into: context)
dataPoint.setValue("dp1", forKey: "id")
dataPoint.setValue("20230101", forKey: "daystampRaw")
dataPoint.setValue(NSDecimalNumber(value: 1.0), forKey: "value")
dataPoint.setValue(TestData.dataPointLastModified, forKey: "lastModifiedLocal")

dataPoint.setValue(goal, forKey: "goal")
try context.save()
return storeURL
Expand All @@ -89,7 +85,7 @@ class MigrationTests: XCTestCase {
func testLastUpdatedLocalMigration() throws {
DueByTableValueTransformer.register()

let storeURL = createStoreWithOldModel()
let storeURL = createStoreWithb54Model()
let container = BeeminderPersistentContainer(name: "BeeminderModel")
let description = NSPersistentStoreDescription(url: storeURL)
container.persistentStoreDescriptions = [description]
Expand All @@ -106,43 +102,42 @@ class MigrationTests: XCTestCase {
let userRequest = NSFetchRequest<User>(entityName: "User")
let users = try context.fetch(userRequest)
XCTAssertEqual(users.count, 1, "Should have one user after migration")
if let user = users.first {
XCTAssertEqual(
user.lastUpdatedLocal.timeIntervalSince1970,
TestData.userLastModified.timeIntervalSince1970,
accuracy: 0.001,
"User date value should be preserved during migration"
)
}
let user = try XCTUnwrap(users.first)
XCTAssertEqual(
user.lastUpdatedLocal.timeIntervalSince1970,
TestData.userLastModified.timeIntervalSince1970,
accuracy: 0.001,
"User date value should be preserved during migration"
)
// Migration on Goal
let goalRequest = NSFetchRequest<Goal>(entityName: "Goal")
let goals = try context.fetch(goalRequest)
XCTAssertEqual(goals.count, 1, "Should have one goal after migration")
if let goal = goals.first {
XCTAssertEqual(
goal.lastUpdatedLocal.timeIntervalSince1970,
TestData.goalLastModified.timeIntervalSince1970,
accuracy: 0.001,
"Goal date value should be preserved during migration"
)
}
let goal = try XCTUnwrap(goals.first)
XCTAssertEqual(
goal.lastUpdatedLocal.timeIntervalSince1970,
TestData.goalLastModified.timeIntervalSince1970,
accuracy: 0.001,
"Goal date value should be preserved during migration"
)

// Migration on DataPoint
let dataPointRequest = NSFetchRequest<DataPoint>(entityName: "DataPoint")
let dataPoints = try context.fetch(dataPointRequest)
XCTAssertEqual(dataPoints.count, 1, "Should have one data point after migration")
if let dataPoint = dataPoints.first {
XCTAssertEqual(
dataPoint.lastUpdatedLocal.timeIntervalSince1970,
TestData.dataPointLastModified.timeIntervalSince1970,
accuracy: 0.001,
"DataPoint date value should be preserved during migration"
)
}
let dataPoint = try XCTUnwrap(dataPoints.first)
XCTAssertEqual(
dataPoint.lastUpdatedLocal.timeIntervalSince1970,
TestData.dataPointLastModified.timeIntervalSince1970,
accuracy: 0.001,
"DataPoint date value should be preserved during migration"
)

}
func testAutodataConfigMigration() throws {
DueByTableValueTransformer.register()

let storeURL = createStoreWithOldModel()
let storeURL = createStoreWithb54Model()
let container = BeeminderPersistentContainer(name: "BeeminderModel")
let description = NSPersistentStoreDescription(url: storeURL)
container.persistentStoreDescriptions = [description]
Expand Down
2 changes: 1 addition & 1 deletion BeeSwift/Components/DatapointValueAccessory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class DatapointValueAccessory: UIInputView {

@objc func colonButtonPressed() {
guard let valueField = self.valueField else { return }
valueField.text = "\(valueField.text!):"
valueField.text = valueField.text?.appending(":")
}

}
18 changes: 13 additions & 5 deletions BeeSwift/Components/UI/UIFontExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@ import UIKit

extension UIFont {
public struct beeminder {
public static var defaultFont: UIFont { return defaultFontLight }
public static var defaultFontLight: UIFont { return UIFont(name: "Avenir-Light", size: 18)! }
public static var defaultFontHeavy: UIFont { return UIFont(name: "Avenir-Heavy", size: 18)! }
public static var defaultBoldFont: UIFont { return UIFont(name: "Avenir-Black", size: 18)! }
public static var defaultFontPlain: UIFont { return UIFont(name: "Avenir", size: 18)! }
public static var defaultFont: UIFont = { defaultFontLight }()
public static var defaultFontLight: UIFont = {
UIFont(name: "Avenir-Light", size: 18) ?? .systemFont(ofSize: 18, weight: .light)
}()
public static var defaultFontHeavy: UIFont = {
UIFont(name: "Avenir-Heavy", size: 18) ?? .systemFont(ofSize: 18, weight: .heavy)
}()
public static var defaultBoldFont: UIFont = {
UIFont(name: "Avenir-Black", size: 18) ?? .systemFont(ofSize: 18, weight: .black)
}()
public static var defaultFontPlain: UIFont = {
UIFont(name: "Avenir", size: 18) ?? .systemFont(ofSize: 18, weight: .regular)
}()
}
}
Loading