// // 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: - Full Sync func performSync() async { guard let appState, let mealPlanManager else { return } // Phase 1: Push locally-known meal plan state let localRecipeIds = Array(Set( mealPlanManager.entriesByDate.values.flatMap { $0 }.map(\.recipeId) )) for recipeId in localRecipeIds { await pushMealPlanState(forRecipeId: recipeId) } // Phase 2: Discover meal plan assignments from server let allRecipes = await appState.getRecipes() let lastSync = UserSettings.shared.lastMealPlanSyncDate // Filter to recipes modified since last sync let recipesToCheck: [Recipe] if let lastSync { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" formatter.timeZone = TimeZone(secondsFromGMT: 0) recipesToCheck = allRecipes.filter { recipe in guard let dateStr = recipe.dateModified, let date = formatter.date(from: dateStr) else { return true } return date > lastSync } } else { recipesToCheck = allRecipes // First sync: check all } // Fetch details concurrently (max 5 parallel) await withTaskGroup(of: (String, String, MealPlanAssignment?)?.self) { group in var iterator = recipesToCheck.makeIterator() let maxConcurrent = 5 var active = 0 while active < maxConcurrent, let recipe = iterator.next() { active += 1 group.addTask { guard let detail = await appState.getRecipe( id: recipe.recipe_id, fetchMode: .onlyServer ) else { return nil } return (String(recipe.recipe_id), detail.name, detail.mealPlanAssignment) } } for await result in group { if let (recipeId, recipeName, assignment) = result, let assignment, !assignment.dates.isEmpty { mealPlanManager.reconcileFromServer( serverAssignment: assignment, recipeId: recipeId, recipeName: recipeName ) } if let recipe = iterator.next() { group.addTask { guard let detail = await appState.getRecipe( id: recipe.recipe_id, fetchMode: .onlyServer ) else { return nil } return (String(recipe.recipe_id), detail.name, detail.mealPlanAssignment) } } } } UserSettings.shared.lastMealPlanSyncDate = Date() } // 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 ) } }