From 79aebd014e95ea68d80edabe4b719f0bedd18719 Mon Sep 17 00:00:00 2001 From: voynow Date: Sun, 27 Oct 2024 15:13:17 -0400 Subject: [PATCH 1/3] profiling API get requests --- mobile/mobile.xcodeproj/project.pbxproj | 4 +- mobile/mobile/APIManager.swift | 19 +++++++-- mobile/mobile/mobileApp.swift | 56 ++++++++++++------------- src/frontend_router.py | 10 ++++- 4 files changed, 55 insertions(+), 34 deletions(-) diff --git a/mobile/mobile.xcodeproj/project.pbxproj b/mobile/mobile.xcodeproj/project.pbxproj index 1248fee..97722bf 100644 --- a/mobile/mobile.xcodeproj/project.pbxproj +++ b/mobile/mobile.xcodeproj/project.pbxproj @@ -280,7 +280,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = voynow.mobile; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -310,7 +310,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = voynow.mobile; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/mobile/mobile/APIManager.swift b/mobile/mobile/APIManager.swift index cf5ca57..d0f9991 100644 --- a/mobile/mobile/APIManager.swift +++ b/mobile/mobile/APIManager.swift @@ -7,8 +7,11 @@ class APIManager { private let baseURL = "https://lwg77yq7dd.execute-api.us-east-1.amazonaws.com/prod/signup" func fetchProfileData(token: String, completion: @escaping (Result) -> Void) { + let startTime = Date() let body: [String: Any] = ["jwt_token": token, "method": "get_profile"] performRequest(body: body, responseType: ProfileResponse.self) { result in + let totalTime = Date().timeIntervalSince(startTime) + print("Profile fetch took: \(totalTime) seconds") switch result { case .success(let response): if response.success, let profile = response.profile { @@ -29,8 +32,12 @@ class APIManager { func fetchTrainingWeekData( token: String, completion: @escaping (Result) -> Void ) { + let startTime = Date() let body: [String: Any] = ["jwt_token": token, "method": "get_training_week"] + performRequest(body: body, responseType: TrainingWeekResponse.self) { result in + let totalTime = Date().timeIntervalSince(startTime) + print("Training week fetch took: \(totalTime) seconds") switch result { case .success(let response): if response.success, let trainingWeekString = response.trainingWeek, @@ -118,8 +125,12 @@ class APIManager { func fetchWeeklySummaries( token: String, completion: @escaping (Result<[WeekSummary], Error>) -> Void ) { + let startTime = Date() let body: [String: Any] = ["jwt_token": token, "method": "get_weekly_summaries"] + performRequest(body: body, responseType: WeeklySummariesResponse.self) { result in + let totalTime = Date().timeIntervalSince(startTime) + print("Weekly summaries fetch took: \(totalTime) seconds") switch result { case .success(let response): if response.success, let summariesStrings = response.weekly_summaries { @@ -193,7 +204,9 @@ class APIManager { completion(.success(())) } else { let errorMessage = response.message ?? "Failed to start onboarding" - completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage]))) + completion( + .failure( + NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage]))) } case .failure(let error): completion(.failure(error)) @@ -203,6 +216,6 @@ class APIManager { } struct GenericResponse: Codable { - let success: Bool - let message: String? + let success: Bool + let message: String? } diff --git a/mobile/mobile/mobileApp.swift b/mobile/mobile/mobileApp.swift index 16bf628..ad6fe3a 100644 --- a/mobile/mobile/mobileApp.swift +++ b/mobile/mobile/mobileApp.swift @@ -2,38 +2,38 @@ import SwiftUI @main struct mobileApp: App { - @StateObject private var appState = AppState() + @StateObject private var appState = AppState() - var body: some Scene { - WindowGroup { - ContentView(appState: appState) - .environmentObject(appState) - .onAppear { - if let token = UserDefaults.standard.string(forKey: "jwt_token") { - appState.status = .loggedIn - appState.jwtToken = token - } - } + var body: some Scene { + WindowGroup { + ContentView(appState: appState) + .environmentObject(appState) + .onAppear { + if let token = UserDefaults.standard.string(forKey: "jwt_token") { + appState.status = .loggedIn + appState.jwtToken = token + } } } + } - private func checkAndRefreshToken() { - if let token = UserDefaults.standard.string(forKey: "jwt_token") { - APIManager.shared.refreshToken(token: token) { result in - DispatchQueue.main.async { - switch result { - case .success(let newToken): - appState.status = .loggedIn - appState.jwtToken = newToken - UserDefaults.standard.set(newToken, forKey: "jwt_token") - case .failure(_): - // Token refresh failed, user needs to log in again - appState.status = .loggedOut - appState.jwtToken = nil - UserDefaults.standard.removeObject(forKey: "jwt_token") - } - } - } + private func checkAndRefreshToken() { + if let token = UserDefaults.standard.string(forKey: "jwt_token") { + APIManager.shared.refreshToken(token: token) { result in + DispatchQueue.main.async { + switch result { + case .success(let newToken): + appState.status = .loggedIn + appState.jwtToken = newToken + UserDefaults.standard.set(newToken, forKey: "jwt_token") + case .failure(_): + // Token refresh failed, user needs to log in again + appState.status = .loggedOut + appState.jwtToken = nil + UserDefaults.standard.removeObject(forKey: "jwt_token") + } } + } } + } } diff --git a/src/frontend_router.py b/src/frontend_router.py index e727dfe..00adba9 100644 --- a/src/frontend_router.py +++ b/src/frontend_router.py @@ -18,7 +18,10 @@ def get_training_week_handler(athlete_id: str, payload: dict) -> dict: """Handle get_training_week request.""" + start = time.time() training_week = get_training_week(athlete_id) + end = time.time() + print(f"get_training_week took {end - start} seconds") return { "success": True, "training_week": training_week.json(), @@ -27,8 +30,11 @@ def get_training_week_handler(athlete_id: str, payload: dict) -> dict: def get_profile_handler(athlete_id: str, payload: dict) -> dict: """Handle get_profile request.""" + start = time.time() user = get_user(athlete_id) athlete = auth_manager.get_strava_client(athlete_id).get_athlete() + end = time.time() + print(f"get_profile took {end - start} seconds") return { "success": True, "profile": { @@ -58,11 +64,13 @@ def get_weekly_summaries_handler(athlete_id: str, payload: dict) -> dict: :param payload: unused payload :return: List of WeekSummary objects as JSON """ - + start = time.time() user = get_user(athlete_id) strava_client = get_strava_client(user.athlete_id) activities_df = get_activities_df(strava_client) weekly_summaries = get_weekly_summaries(activities_df) + end = time.time() + print(f"get_weekly_summaries took {end - start} seconds") return { "success": True, "weekly_summaries": [summary.json() for summary in weekly_summaries], From c05351e6ba3ce77e673920421488c2b1ef9fc629 Mon Sep 17 00:00:00 2001 From: voynow Date: Sun, 27 Oct 2024 20:08:33 -0400 Subject: [PATCH 2/3] Non blocking dashboard data loading --- mobile/mobile/APIManager.swift | 17 ++++-- mobile/mobile/AppState.swift | 13 +---- mobile/mobile/DashboardView.swift | 30 ++++------- mobile/mobile/TrainingWeek.swift | 89 +++++++++++++++++++++++-------- 4 files changed, 93 insertions(+), 56 deletions(-) diff --git a/mobile/mobile/APIManager.swift b/mobile/mobile/APIManager.swift index d0f9991..0ec8881 100644 --- a/mobile/mobile/APIManager.swift +++ b/mobile/mobile/APIManager.swift @@ -2,8 +2,17 @@ import Foundation class APIManager { static let shared = APIManager() - private init() {} + private init() { + // Configure session for connection reuse + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 300 + config.httpMaximumConnectionsPerHost = 6 + config.waitsForConnectivity = true + session = URLSession(configuration: config) + } + private let session: URLSession private let baseURL = "https://lwg77yq7dd.execute-api.us-east-1.amazonaws.com/prod/signup" func fetchProfileData(token: String, completion: @escaping (Result) -> Void) { @@ -11,7 +20,6 @@ class APIManager { let body: [String: Any] = ["jwt_token": token, "method": "get_profile"] performRequest(body: body, responseType: ProfileResponse.self) { result in let totalTime = Date().timeIntervalSince(startTime) - print("Profile fetch took: \(totalTime) seconds") switch result { case .success(let response): if response.success, let profile = response.profile { @@ -37,7 +45,6 @@ class APIManager { performRequest(body: body, responseType: TrainingWeekResponse.self) { result in let totalTime = Date().timeIntervalSince(startTime) - print("Training week fetch took: \(totalTime) seconds") switch result { case .success(let response): if response.success, let trainingWeekString = response.trainingWeek, @@ -130,7 +137,6 @@ class APIManager { performRequest(body: body, responseType: WeeklySummariesResponse.self) { result in let totalTime = Date().timeIntervalSince(startTime) - print("Weekly summaries fetch took: \(totalTime) seconds") switch result { case .success(let response): if response.success, let summariesStrings = response.weekly_summaries { @@ -172,7 +178,8 @@ class APIManager { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: body) - URLSession.shared.dataTask(with: request) { data, response, error in + // Use shared session instead of URLSession.shared + session.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(error)) return diff --git a/mobile/mobile/AppState.swift b/mobile/mobile/AppState.swift index 717506b..9408beb 100644 --- a/mobile/mobile/AppState.swift +++ b/mobile/mobile/AppState.swift @@ -1,15 +1,6 @@ import SwiftUI class AppState: ObservableObject { - @Published var status: AppStateStatus = .loggedOut { - didSet { - print("AppState status changed to: \(status)") - } - } - - @Published var jwtToken: String? = nil { - didSet { - print("AppState jwtToken changed to: \(jwtToken ?? "nil")") - } - } + @Published var status: AppStateStatus = .loggedOut + @Published var jwtToken: String? = nil } diff --git a/mobile/mobile/DashboardView.swift b/mobile/mobile/DashboardView.swift index d360bf6..da440e8 100644 --- a/mobile/mobile/DashboardView.swift +++ b/mobile/mobile/DashboardView.swift @@ -3,9 +3,9 @@ import SwiftUI struct DashboardView: View { @EnvironmentObject var appState: AppState @State private var trainingWeekData: TrainingWeekData? - @State private var isLoadingTrainingWeek: Bool = true - @State private var showProfile: Bool = false @State private var weeklySummaries: [WeekSummary]? + @State private var isLoadingTrainingWeek = true + @State private var showProfile: Bool = false @State private var showOnboarding: Bool = false @State private var showErrorAlert: Bool = false @State private var errorMessage: String = "" @@ -19,17 +19,18 @@ struct DashboardView: View { .zIndex(1) ScrollView { - if isLoadingTrainingWeek { + if let data = trainingWeekData { + // Show training week as soon as it's available + TrainingWeekView( + trainingWeekData: data, + weeklySummaries: weeklySummaries // Can be nil + ) + } else if isLoadingTrainingWeek { DashboardSkeletonView() - } else if let data = trainingWeekData, let summaries = weeklySummaries { - TrainingWeekView(trainingWeekData: data, weeklySummaries: summaries) } else { Text("No training data available") .font(.headline) .foregroundColor(ColorTheme.lightGrey) - Button(action: handleLogout) { - Text("Logout") - } } } .refreshable { @@ -67,21 +68,12 @@ struct DashboardView: View { private func fetchData() { isLoadingTrainingWeek = true - let group = DispatchGroup() - - group.enter() - fetchWeeklySummaries { - group.leave() - } - group.enter() fetchTrainingWeekData { - group.leave() + isLoadingTrainingWeek = false } - group.notify(queue: .main) { - self.isLoadingTrainingWeek = false - } + fetchWeeklySummaries {} } private func fetchTrainingWeekData(completion: @escaping () -> Void) { diff --git a/mobile/mobile/TrainingWeek.swift b/mobile/mobile/TrainingWeek.swift index b382172..d4c0927 100644 --- a/mobile/mobile/TrainingWeek.swift +++ b/mobile/mobile/TrainingWeek.swift @@ -1,13 +1,15 @@ import SwiftUI - struct TrainingWeekView: View { let trainingWeekData: TrainingWeekData - let weeklySummaries: [WeekSummary] + let weeklySummaries: [WeekSummary]? var body: some View { VStack(spacing: 16) { - WeeklyProgressView(sessions: trainingWeekData.sessions, weeklySummaries: weeklySummaries) + WeeklyProgressView( + sessions: trainingWeekData.sessions, + weeklySummaries: weeklySummaries + ) SessionListView(sessions: trainingWeekData.sessions) } .padding(20) @@ -18,7 +20,7 @@ struct TrainingWeekView: View { struct WeeklyProgressView: View { let sessions: [TrainingSession] - let weeklySummaries: [WeekSummary] + let weeklySummaries: [WeekSummary]? @State private var showingMultiWeek: Bool = false private var completedMileage: Double { @@ -32,17 +34,24 @@ struct WeeklyProgressView: View { var body: some View { VStack { if showingMultiWeek { - MultiWeekProgressView(weeklySummaries: weeklySummaries, numberOfWeeks: 8) - .transition(.asymmetric( - insertion: .opacity.combined(with: .move(edge: .bottom)), - removal: .opacity.combined(with: .move(edge: .top)) - )) + if let summaries = weeklySummaries { + MultiWeekProgressView(weeklySummaries: summaries, numberOfWeeks: 8) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .move(edge: .bottom)), + removal: .opacity.combined(with: .move(edge: .top)) + )) + } else { + MultiWeekProgressSkeletonView() + .transition(.opacity) + } } else { WeeklyProgressContent(completedMileage: completedMileage, totalMileage: totalMileage) - .transition(.asymmetric( - insertion: .opacity.combined(with: .move(edge: .bottom)), - removal: .opacity.combined(with: .move(edge: .top)) - )) + .transition( + .asymmetric( + insertion: .opacity.combined(with: .move(edge: .bottom)), + removal: .opacity.combined(with: .move(edge: .top)) + )) } } .animation(.easeInOut(duration: 0.3), value: showingMultiWeek) @@ -90,39 +99,39 @@ struct WeeklyProgressContent: View { struct MultiWeekProgressView: View { let weeklySummaries: [WeekSummary] let numberOfWeeks: Int - + private var displayedSummaries: [WeekSummary] { Array(weeklySummaries.prefix(numberOfWeeks).reversed()) } - + private var maxMileage: Double { displayedSummaries.map(\.totalDistance).max() ?? 1 } - + var body: some View { VStack(spacing: 12) { Text("Last \(numberOfWeeks) Weeks") .font(.headline) .foregroundColor(ColorTheme.white) .frame(maxWidth: .infinity, alignment: .leading) - + ForEach(displayedSummaries, id: \.parsedWeekStartDate) { summary in HStack(spacing: 10) { Text(weekLabel(for: summary.parsedWeekStartDate)) .font(.subheadline) .foregroundColor(ColorTheme.lightGrey) .frame(width: 50, alignment: .leading) - + ProgressBar(progress: summary.totalDistance / maxMileage) .frame(height: 8) - + Text(String(format: "%.1f mi", summary.totalDistance)) .font(.subheadline) .foregroundColor(ColorTheme.white) .frame(width: 60, alignment: .trailing) } } - + HStack { Spacer() Text("Total:") @@ -135,7 +144,7 @@ struct MultiWeekProgressView: View { .padding(.top, 8) } } - + private func weekLabel(for date: Date?) -> String { guard let date = date else { return "" } let formatter = DateFormatter() @@ -238,3 +247,41 @@ struct SessionView: View { } } } + +struct MultiWeekProgressSkeletonView: View { + let numberOfWeeks: Int = 8 + + var body: some View { + VStack(spacing: 12) { + Text("Last \(numberOfWeeks) Weeks") + .font(.headline) + .foregroundColor(ColorTheme.white) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach(0.. Date: Mon, 28 Oct 2024 06:14:20 -0400 Subject: [PATCH 3/3] adding profile cache --- mobile/mobile/ProfileView.swift | 61 +++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/mobile/mobile/ProfileView.swift b/mobile/mobile/ProfileView.swift index 74a50a5..c06ca9b 100644 --- a/mobile/mobile/ProfileView.swift +++ b/mobile/mobile/ProfileView.swift @@ -7,6 +7,8 @@ struct ProfileView: View { @Binding var showProfile: Bool @State private var isSaving: Bool = false @State private var isLoading: Bool = true + @State private var lastFetchTime: Date? + private let cacheTimeout: TimeInterval = 300 var body: some View { ZStack { @@ -68,27 +70,41 @@ struct ProfileView: View { let preferencesString = String(data: preferencesJSON, encoding: .utf8) { profileData?.preferences = preferencesString + ProfileCache.updatePreferences(preferencesString) } } ) } + private func shouldRefetchData() -> Bool { + guard let lastFetch = lastFetchTime else { + return true + } + let timeSinceLastFetch = Date().timeIntervalSince(lastFetch) + let shouldRefetch = timeSinceLastFetch > cacheTimeout + return shouldRefetch + } + private func fetchProfileData() { guard let token = appState.jwtToken else { - isLoading = false - return + isLoading = false + return + } + + if !ProfileCache.shouldRefetch() && ProfileCache.data != nil { + self.profileData = ProfileCache.data + isLoading = false + return } APIManager.shared.fetchProfileData(token: token) { result in - DispatchQueue.main.async { - self.isLoading = false - switch result { - case .success(let profile): - self.profileData = profile - case .failure(let error): - print("Error fetching profile data: \(error)") + DispatchQueue.main.async { + self.isLoading = false + if case .success(let profile) = result { + ProfileCache.update(profile) + self.profileData = profile + } } - } } } } @@ -197,3 +213,28 @@ struct LoadingIcon: View { } } } + +private enum ProfileCache { + static var lastFetchTime: Date? + static var data: ProfileData? + static let timeout: TimeInterval = 300 + + static func shouldRefetch() -> Bool { + guard let lastFetch = lastFetchTime else { return true } + return Date().timeIntervalSince(lastFetch) > timeout + } + + static func update(_ profile: ProfileData) { + data = profile + lastFetchTime = Date() + } + + static func updatePreferences(_ preferences: String) { + data?.preferences = preferences + } + + static func clear() { + data = nil + lastFetchTime = nil + } +}