diff --git a/PennMobile.xcodeproj/project.pbxproj b/PennMobile.xcodeproj/project.pbxproj index 69b7ef2fe..1ee5fd5b0 100644 --- a/PennMobile.xcodeproj/project.pbxproj +++ b/PennMobile.xcodeproj/project.pbxproj @@ -298,6 +298,7 @@ CF29A1771FB788820067D946 /* OnboardingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF29A1671FB7887E0067D946 /* OnboardingController.swift */; }; CF29A1781FB788820067D946 /* PageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF29A1681FB7887E0067D946 /* PageCell.swift */; }; CF7FCA761FAFCD9E0052F0A9 /* RoomSelectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF7FCA751FAFCD9E0052F0A9 /* RoomSelectionViewController.swift */; }; + E735C9422AF81498000F7376 /* DiningSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E735C9412AF81498000F7376 /* DiningSettingsView.swift */; }; EF23946423EF4117005BA55F /* GSRGroupInviteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF23946323EF4117005BA55F /* GSRGroupInviteCell.swift */; }; EF30077223EE1380006C9CF0 /* HomeGroupInvitesCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF30077123EE1380006C9CF0 /* HomeGroupInvitesCellItem.swift */; }; EF30077423EE139F006C9CF0 /* HomeGroupInvitesCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF30077323EE139F006C9CF0 /* HomeGroupInvitesCell.swift */; }; @@ -706,6 +707,7 @@ CF29A1671FB7887E0067D946 /* OnboardingController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OnboardingController.swift; sourceTree = ""; }; CF29A1681FB7887E0067D946 /* PageCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PageCell.swift; sourceTree = ""; }; CF7FCA751FAFCD9E0052F0A9 /* RoomSelectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSelectionViewController.swift; sourceTree = ""; }; + E735C9412AF81498000F7376 /* DiningSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningSettingsView.swift; sourceTree = ""; }; EF23946323EF4117005BA55F /* GSRGroupInviteCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GSRGroupInviteCell.swift; sourceTree = ""; }; EF30077123EE1380006C9CF0 /* HomeGroupInvitesCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeGroupInvitesCellItem.swift; sourceTree = ""; }; EF30077323EE139F006C9CF0 /* HomeGroupInvitesCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeGroupInvitesCell.swift; sourceTree = ""; }; @@ -1428,6 +1430,7 @@ 6CC88D5627B1BF50006896F6 /* DiningViewControllerSwiftUI.swift */, 6CC88D5527B1BF50006896F6 /* DiningViewModelSwiftUI.swift */, 6CC88D3527B1BF50006896F6 /* Views */, + E735C9412AF81498000F7376 /* DiningSettingsView.swift */, ); path = SwiftUI; sourceTree = ""; @@ -2560,6 +2563,7 @@ 89913EAD2AE44FCE00AE30C9 /* CalendarCardView.swift in Sources */, 2190FD351EC625BB00EC683C /* Protocols.swift in Sources */, 2189C0A82027CE4B00771C1F /* ThumbLayer.swift in Sources */, + E735C9422AF81498000F7376 /* DiningSettingsView.swift in Sources */, 21B653B92245EB67001A97C5 /* PennAuthRequestable.swift in Sources */, 89E0DE682AE38A8800E918FF /* HomeView.swift in Sources */, 421B03CA29E22035003AE6DC /* FitnessRoomRow.swift in Sources */, @@ -2722,7 +2726,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CF_BUNDLE_LONG_VERSION_STRING = 6700; - CF_BUNDLE_SHORT_VERSION_STRING = 7.3.8; + CF_BUNDLE_SHORT_VERSION_STRING = 7.3.9; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -2784,7 +2788,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CF_BUNDLE_LONG_VERSION_STRING = 6700; - CF_BUNDLE_SHORT_VERSION_STRING = 7.3.8; + CF_BUNDLE_SHORT_VERSION_STRING = 7.3.9; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -2842,7 +2846,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = navigation; - CF_BUNDLE_SHORT_VERSION_STRING = 7.3.8; + CF_BUNDLE_SHORT_VERSION_STRING = 7.3.9; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = PennMobile/PennMobile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; @@ -2884,7 +2888,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = navigation; - CF_BUNDLE_SHORT_VERSION_STRING = 7.3.8; + CF_BUNDLE_SHORT_VERSION_STRING = 7.3.9; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = PennMobile/PennMobile.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; diff --git a/PennMobile/Auth/PennLoginController.swift b/PennMobile/Auth/PennLoginController.swift index ad8493da3..1eea537f8 100755 --- a/PennMobile/Auth/PennLoginController.swift +++ b/PennMobile/Auth/PennLoginController.swift @@ -76,28 +76,24 @@ class PennLoginController: UIViewController, WKUIDelegate, WKNavigationDelegate // Webview has redirected to desired site. self.handleSuccessfulNavigation(webView, decisionHandler: decisionHandler) } else { - if url.absoluteString.contains("password") { - webView.evaluateJavaScript("document.getElementById('pennname').value;") { (result, _) in - if let pennkey = result as? String { - webView.evaluateJavaScript("document.getElementById('password').value;") { (result, _) in - if let password = result as? String { - if !pennkey.isEmpty && !password.isEmpty { - self.pennkey = pennkey - self.password = password - if pennkey == "root" && password == "root" { - self.handleDefaultLogin(decisionHandler: decisionHandler) - return - } + webView.evaluateJavaScript("document.querySelector('input[name=j_username]').value;") { (result, _) in + if let pennkey = result as? String { + webView.evaluateJavaScript("document.querySelector('input[name=j_password]').value;") { (result, _) in + if let password = result as? String { + if !pennkey.isEmpty && !password.isEmpty { + self.pennkey = pennkey + self.password = password + if pennkey == "root" && password == "root" { + self.handleDefaultLogin(decisionHandler: decisionHandler) + return } } - decisionHandler(.allow) } - } else { decisionHandler(.allow) } + } else { + decisionHandler(.allow) } - } else { - decisionHandler(.allow) } } } @@ -123,7 +119,7 @@ class PennLoginController: UIViewController, WKUIDelegate, WKNavigationDelegate return } - if url.absoluteString.contains("twostep") { + if url.absoluteString.contains("prompt") { guard let pennkey = pennkey, let password = password else { return } if password != KeychainAccessible.instance.getPassword() { UserDBManager.shared.updateAnonymizationKeys() @@ -138,7 +134,7 @@ class PennLoginController: UIViewController, WKUIDelegate, WKNavigationDelegate func autofillCredentials() { guard let pennkey = pennkey else { return } - webView.evaluateJavaScript("document.getElementById('pennname').value = '\(pennkey)'") { (_, _) in + webView.evaluateJavaScript("document.getElementById('username').value = '\(pennkey)'") { (_, _) in } guard let password = password else { return } webView.evaluateJavaScript("document.getElementById('password').value = '\(password)'") { (_, _) in diff --git a/PennMobile/Course Alerts/Networking/CourseAlertNetworkManager.swift b/PennMobile/Course Alerts/Networking/CourseAlertNetworkManager.swift index 424950aac..b5ba30581 100644 --- a/PennMobile/Course Alerts/Networking/CourseAlertNetworkManager.swift +++ b/PennMobile/Course Alerts/Networking/CourseAlertNetworkManager.swift @@ -7,6 +7,7 @@ // import Foundation import SwiftyJSON +import PennMobileShared struct Response: Decodable { let message: String @@ -25,6 +26,7 @@ class CourseAlertNetworkManager: NSObject, Requestable { let settingsURL = "https://penncoursealert.com/accounts/me/" let coursesURL = "https://penncoursealert.com/api/base/" let registrationsURL = "https://penncoursealert.com/api/alert/registrations/" + let pathRegistrationURL = "https://penncourseplan.com/api/plan/schedules/path/" func getSearchedCourses(searchText: String, _ callback: @escaping (_ results: [CourseSection]?) -> Void) { @@ -158,6 +160,26 @@ class CourseAlertNetworkManager: NSObject, Requestable { } } + func updatePathRegistration(srcdb: String, crns: [String]) async throws { + let params: [String: Any] = ["semester": srcdb, "sections": crns.map { ["id": $0] }] + + return try await withCheckedThrowingContinuation { continuation in + makeAuthenticatedRequest(url: pathRegistrationURL, requestType: RequestType.PUT, params: params) { (data, response, error) in + if let error { + continuation.resume(throwing: error) + return + } + + guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { + continuation.resume(throwing: NetworkingError.serverError) + return + } + + continuation.resume(returning: ()) + } + } + } + } // MARK: - General Networking Functions @@ -233,10 +255,10 @@ extension CourseAlertNetworkManager { if let CSRFDict = (UserDefaults.standard.dictionary(forKey: "cookies"))?["csrftokenplatform.pennlabs.org"] as? [String: Any] { if let csrfToken = CSRFDict["Value"] as? String { callback(csrfToken) - } else { - callback(nil) + return } } + callback(nil) } diff --git a/PennMobile/Courses/Views/CoursesView.swift b/PennMobile/Courses/Views/CoursesView.swift index 06bdf30e2..44c0ab752 100644 --- a/PennMobile/Courses/Views/CoursesView.swift +++ b/PennMobile/Courses/Views/CoursesView.swift @@ -51,7 +51,7 @@ struct CoursesView: View { .foregroundColor(.red) .padding() .onAppear { - if case PathAtPennError.noTokenFound = error { + if case PathAtPennError.noTokenFound(_) = error { isPresentingLoginSheet = true } } diff --git a/PennMobile/Dining/Controllers/DiningLoginController.swift b/PennMobile/Dining/Controllers/DiningLoginController.swift index de46cd318..8b404e0a4 100644 --- a/PennMobile/Dining/Controllers/DiningLoginController.swift +++ b/PennMobile/Dining/Controllers/DiningLoginController.swift @@ -117,7 +117,7 @@ class DiningLoginController: UIViewController, WKUIDelegate, WKNavigationDelegat if url.absoluteString.contains("https://weblogin.pennkey.upenn.edu/") { guard let pennkey = KeychainAccessible.instance.getPennKey(), let password = KeychainAccessible.instance.getPassword() else { return } - webView.evaluateJavaScript("document.getElementById('pennname').value = '\(pennkey)'") { (_, _) in + webView.evaluateJavaScript("document.getElementById('username').value = '\(pennkey)'") { (_, _) in webView.evaluateJavaScript("document.getElementById('password').value = '\(password)'") { (_, _) in } } diff --git a/PennMobile/Dining/SwiftUI/DiningAnalyticsView.swift b/PennMobile/Dining/SwiftUI/DiningAnalyticsView.swift index 3e8590466..80092d61e 100644 --- a/PennMobile/Dining/SwiftUI/DiningAnalyticsView.swift +++ b/PennMobile/Dining/SwiftUI/DiningAnalyticsView.swift @@ -14,6 +14,7 @@ struct DiningAnalyticsView: View { @State var showMissingDiningTokenAlert = false @State var showDiningLoginView = false @State var notLoggedInAlertShowing = false + @State var showSettingsSheet = false @Environment(\.presentationMode) var presentationMode func showCorrectAlert () -> Alert { if !Account.isLoggedIn { @@ -30,17 +31,33 @@ struct DiningAnalyticsView: View { let dollarHistory = $diningAnalyticsViewModel.dollarHistory let swipeHistory = $diningAnalyticsViewModel.swipeHistory HStack { + Spacer() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + showSettingsSheet.toggle() + }) { + Image(systemName: "gear") + .imageScale(.large) + } + } + } if Account.isLoggedIn, let diningExpiration = UserDefaults.standard.getDiningTokenExpiration(), Date() <= diningExpiration { if dollarHistory.wrappedValue.isEmpty && swipeHistory.wrappedValue.isEmpty { ZStack { Image("DiningAnalyticsBackground") .resizable() .ignoresSafeArea() - Text("No Dining\nPlan Found\n ") - .multilineTextAlignment(.center) - .font(.system(size: 48, weight: .regular)) - .foregroundColor(.black) - .opacity(0.6) + VStack(spacing: 24) { + Text("No Dining Plan Found") + .font(.system(size: 48, weight: .regular)) + Text("Dining Analytics may not appear until the day after the semester begins.") + } + .frame(maxWidth: 280) + .padding(.bottom, 64) + .multilineTextAlignment(.center) + .foregroundColor(.black) + .opacity(0.6) } } else { ScrollView { @@ -78,6 +95,9 @@ struct DiningAnalyticsView: View { .environmentObject(diningAnalyticsViewModel) } .navigationTitle(Text("Dining Analytics")) + .sheet(isPresented: $showSettingsSheet) { + DiningSettingsView(viewModel: diningAnalyticsViewModel) // Replace with your settings view + } } else { let dollarXYHistory = Binding( get: { @@ -139,6 +159,10 @@ struct DiningAnalyticsView: View { DiningLoginNavigationView() .environmentObject(diningAnalyticsViewModel) } + .navigationTitle("Analytics") + .sheet(isPresented: $showSettingsSheet) { + DiningSettingsView(viewModel: diningAnalyticsViewModel) + } } } } diff --git a/PennMobile/Dining/SwiftUI/DiningSettingsView.swift b/PennMobile/Dining/SwiftUI/DiningSettingsView.swift new file mode 100644 index 000000000..67652c259 --- /dev/null +++ b/PennMobile/Dining/SwiftUI/DiningSettingsView.swift @@ -0,0 +1,73 @@ +// +// DiningSettingsView.swift +// PennMobile +// +// Created by Christina Qiu on 11/5/23. +// Copyright © 2023 PennLabs. All rights reserved. +// + +import SwiftUI +import PennMobileShared + +struct DiningSettingsView: View { + @ObservedObject var viewModel: DiningAnalyticsViewModel + + @Environment(\.presentationMode) var presentationMode + @State private var totalData = false + private let options = ["All data", + "Smart calculation", + "Weighted average"] + + var body: some View { + if #available(iOS 16.0, *) { + NavigationView { + Form { + Picker(selection: $viewModel.selectedOptionIndex, label: Text("Slope Calculation")) { + ForEach(0.. String { + private func getTokenWithoutReauthenticating() async throws -> String { let (data, _) = try await URLSession.shared.data(from: PathAtPennNetworkManager.oauthURL) let str = try String(data: data, encoding: .utf8).unwrap(orThrow: PathAtPennError.corruptString) let matches = str.getMatches(for: "value: \"(.*)\"") - let token = try matches.first.unwrap(orThrow: PathAtPennError.noTokenFound) - + let token = try matches.first.unwrap(orThrow: PathAtPennError.noTokenFound(str)) + return token } + + /// Fetches and returns a Path@Penn auth token. + func getToken() async throws -> String { + // First, attempt to acquire a token without reauthenticating + do { + return try await getTokenWithoutReauthenticating() + } catch PathAtPennError.noTokenFound(let body) { + logger.warning("Reauthenticating user for Path@Penn") + + // Attempt to reauthenticate the user + guard let pennkey = KeychainAccessible.instance.getPennKey(), + let password = KeychainAccessible.instance.getPassword() else { + throw PathAtPennError.pennkeyCredentialsNotStored + } + + var urlComponents = URLComponents() + urlComponents.queryItems = [ + URLQueryItem(name: "j_username", value: pennkey), + URLQueryItem(name: "j_password", value: password), + URLQueryItem(name: "_eventId_proceed", value: "") + ] + + guard let requestBody = urlComponents.percentEncodedQuery?.data(using: .utf8) else { + throw PathAtPennError.invalidRequestBody + } + + let authorizeDOM = try SwiftSoup.parse(body) + guard let form = try authorizeDOM.getElementById("loginform") else { + throw PathAtPennError.noExecutionFound + } + + let loginStr = try form.attr("action") + guard let loginURL = URL(string: loginStr, relativeTo: URL(string: "https://weblogin.pennkey.upenn.edu")!) else { + throw PathAtPennError.noExecutionFound + } + + var request = URLRequest(url: loginURL) + request.httpMethod = "POST" + request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = requestBody + + let (data, response) = try await URLSession.shared.data(for: request) + let twoFactorStr = try String(data: data, encoding: .utf8).unwrap(orThrow: PathAtPennError.corruptString) + let twoFactorDOM = try SwiftSoup.parse(twoFactorStr) + let twoFactorURL = try response.url.unwrap(orThrow: PathAtPennError.noExecutionFound) + + urlComponents = URLComponents() + let formFields = ["tx", "parent", "_xsrf"] + + urlComponents.queryItems = try formFields.map { name in + guard let element = try twoFactorDOM.getElementsByAttributeValue("name", name).first() else { + throw PathAtPennError.noExecutionFound + } + + return try URLQueryItem(name: name, value: element.val()) + } + + guard let twoFactorRequestBody = urlComponents.percentEncodedQuery?.data(using: .utf8) else { + throw PathAtPennError.invalidRequestBody + } + + request = URLRequest(url: twoFactorURL) + request.httpMethod = "POST" + request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = twoFactorRequestBody + + let (postData, postResponse) = try await URLSession.shared.data(for: request) + + return try await getTokenWithoutReauthenticating() + } catch { + throw error + } + } } // MARK: - Student Data @@ -146,11 +229,11 @@ extension PathAtPennNetworkManager { let (srcdb, descriptors) = $0 let crns = descriptors.compactMap { - $0.split(separator: "|").first + $0.split(separator: "|").first.map { String($0) } } return try await crns.asyncMap { crn in - try await self.fetchCourse(srcdb: srcdb, crn: String(crn)) + try await self.fetchCourse(srcdb: srcdb, crn: crn) }.compactMap { $0 } }.flatMap { $0 } } diff --git a/PennMobileShared/Dining/DiningAnalyticsViewModel.swift b/PennMobileShared/Dining/DiningAnalyticsViewModel.swift index b99d43672..aec939479 100644 --- a/PennMobileShared/Dining/DiningAnalyticsViewModel.swift +++ b/PennMobileShared/Dining/DiningAnalyticsViewModel.swift @@ -49,6 +49,12 @@ public class DiningAnalyticsViewModel: ObservableObject { @Published public var dollarAxisLabel: ([String], [String]) = ([], []) @Published public var dollarSlope: Double = 0.0 @Published public var swipeSlope: Double = 0.0 + + @Published public var selectedOptionIndex = 0 { + didSet { + populateAxesAndPredictions() + } + } var yIntercept = 0.0 var slope = 0.0 @@ -59,6 +65,7 @@ public class DiningAnalyticsViewModel: ObservableObject { clearStorageIfNewSemester() populateAxesAndPredictions() } + func clearStorageIfNewSemester() { if Storage.fileExists(DiningAnalyticsViewModel.dollarHistoryDirectory, in: .groupDocuments), let nextAnalyticsStartDate = Storage.retrieve(DiningAnalyticsViewModel.dollarHistoryDirectory, from: .groupDocuments, as: [DiningAnalyticsBalance].self).last?.date, nextAnalyticsStartDate < Date.startOfSemester { @@ -70,6 +77,7 @@ public class DiningAnalyticsViewModel: ObservableObject { Storage.remove(DiningAnalyticsViewModel.planStartDateDirectory, from: .groupDocuments) } } + public func refresh(refreshWidgets: Bool = false) async { guard let diningToken = KeychainAccessible.instance.getDiningToken() else { return @@ -108,46 +116,98 @@ public class DiningAnalyticsViewModel: ObservableObject { func populateAxesAndPredictions() { filterData() + + let last7dollarHistory: [DiningAnalyticsBalance] = dollarHistory.suffix(7) + let last7swipeHistory: [DiningAnalyticsBalance] = swipeHistory.suffix(7) + guard let lastDollarBalance = self.dollarHistory.last, let lastSwipeBalance = self.swipeHistory.last else { return } - guard let maxDollarBalance = (self.dollarHistory.max { $0.balance < $1.balance }), + guard let last7maxDollarBalance = (last7dollarHistory.max { $0.balance < $1.balance }), + let last7maxSwipeBalance = (last7swipeHistory.max { $0.balance < $1.balance }), + let maxDollarBalance = (self.dollarHistory.max { $0.balance < $1.balance }), let maxSwipeBalance = (self.swipeHistory.max { $0.balance < $1.balance }) else { return } + // If no dining plan found, refresh will return, these are just placeholders var startDollarBalance = maxDollarBalance var startSwipeBalance = maxSwipeBalance + var last7startDollarBalance = last7maxDollarBalance + var last7startSwipeBalance = last7maxSwipeBalance guard let planStartDate else { return } // If dining plan found, start prediction from the date dining plan started + last7startDollarBalance = (last7dollarHistory.first { $0.date == planStartDate }) ?? last7startDollarBalance + last7startSwipeBalance = (last7swipeHistory.first { $0.date == planStartDate }) ?? last7startSwipeBalance startDollarBalance = (self.dollarHistory.first { $0.date == planStartDate }) ?? startDollarBalance startSwipeBalance = (self.swipeHistory.first { $0.date == planStartDate }) ?? startSwipeBalance // However, it's possible that people recharged dining dollars (swipes maybe?), and if so, predict from this date (most recent increase) for (i, day) in self.dollarHistory.enumerated() { if i != 0 && day.date > planStartDate && day.balance > self.dollarHistory[i - 1].balance { startDollarBalance = day + if (day.date > last7startDollarBalance.date) { + last7startDollarBalance = day + } } } for (i, day) in self.swipeHistory.enumerated() { if i != 0 && day.date > planStartDate && day.balance > self.swipeHistory[i - 1].balance { startSwipeBalance = day + if (day.date > last7startDollarBalance.date) { + last7startSwipeBalance = day + } } } - let dollarPredictions = self.getPredictions(firstBalance: startDollarBalance, lastBalance: lastDollarBalance, maxBalance: maxDollarBalance) + // Get dollar predictions using data from all dates and dollar predictions using data for the appropriate calculation + var selectedDollarSlope = 0.0 + var selectedSwipeSlope = 0.0 + if selectedOptionIndex == 0 { + (selectedDollarSlope, _) = self.getSlopeAndWeight(firstBalance: startDollarBalance, lastBalance: lastDollarBalance) + (selectedSwipeSlope, _) = self.getSlopeAndWeight(firstBalance: startSwipeBalance, lastBalance: lastSwipeBalance) + } else if selectedOptionIndex == 1 { + let (allDollarSlope, _) = self.getSlopeAndWeight(firstBalance: startDollarBalance, lastBalance: lastDollarBalance) + let (last7DollarSlope, _) = self.getSlopeAndWeight(firstBalance: last7startDollarBalance, lastBalance: lastDollarBalance) + selectedDollarSlope = (allDollarSlope + last7DollarSlope) / 2.0 + + let (allSwipeSlope, _) = self.getSlopeAndWeight(firstBalance: startSwipeBalance, lastBalance: lastSwipeBalance) + let (last7SwipeSlope, _) = self.getSlopeAndWeight(firstBalance: last7startSwipeBalance, lastBalance: lastSwipeBalance) + selectedSwipeSlope = (allSwipeSlope + last7SwipeSlope) / 2.0 + } else if selectedOptionIndex == 2 { + selectedDollarSlope = getWeightedAverageSlope(allBalance: self.dollarHistory) + selectedSwipeSlope = getWeightedAverageSlope(allBalance: self.swipeHistory) + } + + let dollarPredictions = self.getPredictions(firstBalance: lastDollarBalance, slope: selectedDollarSlope, maxBalance: maxDollarBalance) self.dollarSlope = dollarPredictions.slope self.dollarPredictedZeroDate = dollarPredictions.predictedZeroDate self.predictedDollarSemesterEndBalance = dollarPredictions.predictedEndBalance - self.dollarAxisLabel = self.getAxisLabelsYX(from: self.dollarHistory) - let swipePredictions = self.getPredictions(firstBalance: startSwipeBalance, lastBalance: lastSwipeBalance, maxBalance: maxSwipeBalance) + + let swipePredictions = self.getPredictions(firstBalance: lastSwipeBalance, slope: selectedSwipeSlope, maxBalance: maxSwipeBalance) self.swipeSlope = swipePredictions.slope self.swipesPredictedZeroDate = swipePredictions.predictedZeroDate self.predictedSwipesSemesterEndBalance = swipePredictions.predictedEndBalance + + self.dollarAxisLabel = self.getAxisLabelsYX(from: self.dollarHistory) self.swipeAxisLabel = self.getAxisLabelsYX(from: self.swipeHistory) } + func getWeightedAverageSlope(allBalance: [DiningAnalyticsBalance]) -> Double { + var totalWeightedSlope = 0.0 + var totalWeight = 0.0 + + for i in 1.. 0 ? totalWeightedSlope / totalWeight : totalWeightedSlope + return averageSlope + } + func filterData() { if self.dollarHistory.count >= 2 { self.dollarHistory = self.dollarHistory.enumerated().filter { index, dollar in @@ -170,39 +230,46 @@ public class DiningAnalyticsViewModel: ObservableObject { }.map { $0.element } } } - - func getPredictions(firstBalance: DiningAnalyticsBalance, lastBalance: DiningAnalyticsBalance, maxBalance: DiningAnalyticsBalance) -> (slope: Double, predictedZeroDate: Date, predictedEndBalance: Double) { - if firstBalance.date == lastBalance.date || firstBalance.balance == lastBalance.balance { + + func getPredictions(firstBalance: DiningAnalyticsBalance, slope: Double, maxBalance: DiningAnalyticsBalance) -> (slope: Double, predictedZeroDate: Date, predictedEndBalance: Double) { + if slope == 0.0 || abs(slope) == Double.infinity { let zeroDate = Calendar.current.date(byAdding: .day, value: 1, to: Date.endOfSemester)! - return (Double(0.0), zeroDate, lastBalance.balance) + return (Double(0.0), zeroDate, firstBalance.balance) } else { // This is the slope needed to calculate zeroDate and endBalance - var slope = self.getSlope(firstBalance: firstBalance, lastBalance: lastBalance) - let zeroDate = self.predictZeroDate(firstBalance: firstBalance, lastBalance: lastBalance, slope: slope) - let endBalance = self.predictSemesterEndBalance(firstBalance: firstBalance, lastBalance: lastBalance, slope: slope) + let zeroDate = self.predictZeroDate(firstBalance: firstBalance, slope: slope) + let endBalance = self.predictSemesterEndBalance(firstBalance: firstBalance, slope: slope) let fullSemester = Date.startOfSemester.distance(to: Date.endOfSemester) let fullZeroDistance = firstBalance.date.distance(to: zeroDate) let deltaX = fullZeroDistance / fullSemester let deltaY = firstBalance.balance / maxBalance.balance - slope = -deltaY / deltaX // Resetting slope to different value for graph format - return (slope, zeroDate, endBalance) + let graphSlope = -deltaY / deltaX // Resetting slope to different value for graph format + return (graphSlope, zeroDate, endBalance) } } - func getSlope(firstBalance: DiningAnalyticsBalance, lastBalance: DiningAnalyticsBalance) -> Double { + + func getSlopeAndWeight(firstBalance: DiningAnalyticsBalance, lastBalance: DiningAnalyticsBalance) -> (slope: Double, weight: Double) { + if firstBalance.date == lastBalance.date || firstBalance.balance == lastBalance.balance { + return (Double(0.0), 1) + } let balanceDiff = lastBalance.balance - firstBalance.balance let timeDiff = Double(Calendar.current.dateComponents([.day], from: firstBalance.date, to: lastBalance.date).day!) - return balanceDiff / timeDiff + let weight = balanceDiff > 0 ? 0 : (timeDiff > 0 ? timeDiff : 1) // Days as weight + let slope = balanceDiff / timeDiff + return (slope, weight) } - func predictZeroDate(firstBalance: DiningAnalyticsBalance, lastBalance: DiningAnalyticsBalance, slope: Double) -> Date { + + func predictZeroDate(firstBalance: DiningAnalyticsBalance, slope: Double) -> Date { let offset = -firstBalance.balance / slope - let zeroDate = Calendar.current.date(byAdding: .day, value: Int(offset), to: firstBalance.date)! - return zeroDate + return slope == 0 ? Date.distantFuture : Calendar.current.date(byAdding: .day, value: Int(offset), to: firstBalance.date)! } - func predictSemesterEndBalance(firstBalance: DiningAnalyticsBalance, lastBalance: DiningAnalyticsBalance, slope: Double) -> Double { + + func predictSemesterEndBalance(firstBalance: DiningAnalyticsBalance, slope: Double) -> Double { let diffInDays = Calendar.current.dateComponents([.day], from: firstBalance.date, to: Date.endOfSemester).day! let endBalance = (slope * Double(diffInDays)) + firstBalance.balance return endBalance } + // Compute axis labels static func getAxisLabelsX() -> [String] { let xAxisLabelCount = 4 @@ -214,6 +281,7 @@ public class DiningAnalyticsViewModel: ObservableObject { dateFormatter.string(from: Date.startOfSemester.advanced(by: semesterStep * $0)) } } + func getAxisLabelsYX(from trans: [DiningAnalyticsBalance]) -> ([String], [String]) { let yAxisLabelCount = 5 var yLabels: [String] = [] diff --git a/PennMobileShared/General/Extensions.swift b/PennMobileShared/General/Extensions.swift index d2c3a441d..256c39c09 100755 --- a/PennMobileShared/General/Extensions.swift +++ b/PennMobileShared/General/Extensions.swift @@ -6,6 +6,7 @@ // import UIKit +import OSLog extension UIApplication { public static var isRunningFastlaneTest: Bool { @@ -668,3 +669,9 @@ public extension JSONDecoder { self.dateDecodingStrategy = dateDecodingStrategy } } + +public extension Logger { + init(category: String) { + self.init(subsystem: Bundle.main.bundleIdentifier ?? "Penn Mobile", category: category) + } +}