Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
Hendrik Hogertz 1f7f19c74b Fix meal plan not populating on first login and add pull-to-refresh sync
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>
2026-02-15 11:40:31 +01:00

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
)
}
}