// // MealPlanSyncManager.swift // Nextcloud Cookbook iOS Client // import Foundation import OSLog @MainActor class MealPlanSyncManager { private weak var appState: AppState? private weak var mealPlanManager: MealPlanManager? private var debounceTimers: [String: Task] = [:] private let debounceInterval: TimeInterval = 2.0 init(appState: AppState, mealPlanManager: MealPlanManager) { self.appState = appState self.mealPlanManager = mealPlanManager } // MARK: - Push Flow func scheduleSync(forRecipeId recipeId: String) { guard UserSettings.shared.mealPlanSyncEnabled else { return } debounceTimers[recipeId]?.cancel() debounceTimers[recipeId] = Task { [weak self] in try? await Task.sleep(nanoseconds: UInt64(2_000_000_000)) guard !Task.isCancelled else { return } await self?.pushMealPlanState(forRecipeId: recipeId) } } func pushMealPlanState(forRecipeId recipeId: String) async { guard let appState, let mealPlanManager else { return } guard let recipeIdInt = Int(recipeId) else { return } let localAssignment = mealPlanManager.assignment(forRecipeId: recipeId) guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else { Logger.data.error("Meal plan sync: failed to fetch recipe \(recipeId) from server") return } let merged = mergeAssignments(local: localAssignment, server: serverRecipe.mealPlanAssignment) var updatedRecipe = serverRecipe updatedRecipe.mealPlanAssignment = merged let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false) if let alert { Logger.data.error("Meal plan sync: failed to push state for recipe \(recipeId): \(String(describing: alert))") } } // MARK: - Pull Flow func reconcileFromServer(serverAssignment: MealPlanAssignment?, recipeId: String, recipeName: String) { guard let mealPlanManager else { return } mealPlanManager.reconcileFromServer(serverAssignment: serverAssignment, recipeId: recipeId, recipeName: recipeName) } // MARK: - Initial Sync func performInitialSync() async { guard let appState, let mealPlanManager else { return } let recipeIds = Array(mealPlanManager.entriesByDate.values.flatMap { $0 }.map(\.recipeId)) let uniqueIds = Array(Set(recipeIds)) for recipeId in uniqueIds { guard let recipeIdInt = Int(recipeId) else { continue } await pushMealPlanState(forRecipeId: recipeId) if let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) { reconcileFromServer( serverAssignment: serverRecipe.mealPlanAssignment, recipeId: recipeId, recipeName: serverRecipe.name ) } } } // MARK: - Merge Logic private func mergeAssignments(local: MealPlanAssignment?, server: MealPlanAssignment?) -> MealPlanAssignment { guard let local else { return server ?? MealPlanAssignment() } guard let server else { return local } var merged = local.dates for (dayStr, serverEntry) in server.dates { if let localEntry = merged[dayStr] { let localDate = MealPlanDate.date(from: localEntry.modifiedAt) ?? .distantPast let serverDate = MealPlanDate.date(from: serverEntry.modifiedAt) ?? .distantPast if serverDate > localDate { merged[dayStr] = serverEntry } } else { merged[dayStr] = serverEntry } } // Prune all date entries older than 30 days let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Calendar.current.startOfDay(for: Date()))! merged = merged.filter { dayStr, _ in guard let date = MealPlanDate.dateFromDay(dayStr) else { return true } return date >= cutoff } return MealPlanAssignment( lastModified: MealPlanDate.now(), dates: merged ) } }