Rewrite MealPlanSyncManager.performSync() (renamed from performInitialSync) to discover _mealPlanAssignment metadata from all server recipes, not just locally- known ones. On first sync all recipes are checked; on subsequent syncs only recipes modified since lastMealPlanSyncDate are fetched (max 5 concurrent). Trigger meal plan sync from pull-to-refresh on both the recipe and meal plan tabs, and from the "Refresh all" toolbar button. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
166 lines
6.2 KiB
Swift
166 lines
6.2 KiB
Swift
//
|
|
// 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<Void, Never>] = [:]
|
|
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
|
|
)
|
|
}
|
|
}
|