Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.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

234 lines
8.9 KiB
Swift

//
// GroceryStateSyncManager.swift
// Nextcloud Cookbook iOS Client
//
import Foundation
import OSLog
@MainActor
class GroceryStateSyncManager {
private weak var appState: AppState?
private weak var groceryManager: GroceryListManager?
private var debounceTimers: [String: Task<Void, Never>] = [:]
private let debounceInterval: TimeInterval = 2.0
private var isReconciling = false
private let dataStore = DataStore()
private let pendingRemovalPath = "grocery_pending_removals.data"
private(set) var pendingRemovalRecipeIds: Set<String> = []
init(appState: AppState, groceryManager: GroceryListManager) {
self.appState = appState
self.groceryManager = groceryManager
}
// MARK: - Push Flow
/// Debounced sync trigger. Waits `debounceInterval` seconds then pushes state for the recipe.
func scheduleSync(forRecipeId recipeId: String) {
guard UserSettings.shared.grocerySyncEnabled else { return }
guard !isReconciling 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?.pushGroceryState(forRecipeId: recipeId)
}
}
/// Builds local grocery state, fetches server recipe, merges, and PUTs back.
func pushGroceryState(forRecipeId recipeId: String) async {
guard let appState, let groceryManager else { return }
guard let recipeIdInt = Int(recipeId) else { return }
// Fetch latest recipe from server first so we can detect deletions
guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else {
Logger.data.error("Grocery sync: failed to fetch recipe \(recipeId) from server")
return
}
let serverState = serverRecipe.groceryState
// Build local state, passing server state so deleted items can be marked .removed
let localState = buildLocalState(forRecipeId: recipeId, groceryManager: groceryManager, serverState: serverState)
// Merge local state with server state
let merged = mergeStates(local: localState, server: serverState)
// Upload merged state
var updatedRecipe = serverRecipe
updatedRecipe.groceryState = merged
let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false)
if let alert {
Logger.data.error("Grocery sync: failed to push state for recipe \(recipeId): \(String(describing: alert))")
}
}
// MARK: - Pull Flow
/// Reconciles server grocery state with local grocery data. Called when a recipe is loaded.
func reconcileFromServer(serverState: GroceryState?, recipeId: String, recipeName: String) {
guard let groceryManager else { return }
guard let serverState, !serverState.items.isEmpty else { return }
isReconciling = true
defer { isReconciling = false }
let localItems = Set(
groceryManager.groceryDict[recipeId]?.items.map(\.name) ?? []
)
for (itemName, itemState) in serverState.items {
switch itemState.status {
case .added:
if !localItems.contains(itemName) {
groceryManager.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
}
case .removed:
if localItems.contains(itemName) {
groceryManager.deleteItem(itemName, fromRecipe: recipeId)
}
case .completed:
// Don't re-add completed items; leave local state as-is
break
}
}
}
// MARK: - Initial Sync
/// Pushes any local-only items and reconciles server items on app launch.
func performSync() async {
guard let appState, let groceryManager else { return }
await loadPendingRemovals()
let recipeIds = Array(groceryManager.groceryDict.keys)
for recipeId in recipeIds {
guard let recipeIdInt = Int(recipeId) else { continue }
// Push local state to server
await pushGroceryState(forRecipeId: recipeId)
// Fetch back and reconcile
if let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) {
let recipeName = groceryManager.groceryDict[recipeId]?.name ?? serverRecipe.name
reconcileFromServer(
serverState: serverRecipe.groceryState,
recipeId: recipeId,
recipeName: recipeName
)
}
}
// Push deletion state for recipes whose items were fully removed
for recipeId in pendingRemovalRecipeIds {
guard !recipeIds.contains(recipeId) else {
// Recipe was re-added locally since removal was tracked; clear it
pendingRemovalRecipeIds.remove(recipeId)
continue
}
await pushGroceryState(forRecipeId: recipeId)
pendingRemovalRecipeIds.remove(recipeId)
}
savePendingRemovals()
}
// MARK: - Merge Logic
/// Merges local and server states using per-item last-writer-wins on `modifiedAt`.
private func mergeStates(local: GroceryState, server: GroceryState?) -> GroceryState {
guard let server else { return local }
var merged = local.items
for (itemName, serverItem) in server.items {
if let localItem = merged[itemName] {
// Both have the item keep the one with the later modifiedAt
let localDate = GroceryStateDate.date(from: localItem.modifiedAt) ?? .distantPast
let serverDate = GroceryStateDate.date(from: serverItem.modifiedAt) ?? .distantPast
if serverDate > localDate {
merged[itemName] = serverItem
}
} else {
// Only server has this item keep it
merged[itemName] = serverItem
}
}
// Garbage collection: remove items that are removed/completed and older than 30 days
let thirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60)
merged = merged.filter { _, item in
if item.status == .added { return true }
guard let modDate = GroceryStateDate.date(from: item.modifiedAt) else { return true }
return modDate > thirtyDaysAgo
}
return GroceryState(
lastModified: GroceryStateDate.now(),
items: merged
)
}
// MARK: - Build Local State
/// Builds a `GroceryState` from the current local grocery data for a recipe.
/// When `serverState` is provided, any server item with `.added` status that is
/// absent locally is emitted as `.removed` so the deletion propagates to the server.
private func buildLocalState(forRecipeId recipeId: String, groceryManager: GroceryListManager, serverState: GroceryState?) -> GroceryState {
var items: [String: GroceryItemState] = [:]
let now = GroceryStateDate.now()
// Existing local items
if let groceryRecipe = groceryManager.groceryDict[recipeId] {
for item in groceryRecipe.items {
let status: GroceryItemState.Status = item.isChecked ? .completed : .added
items[item.name] = GroceryItemState(status: status, addedAt: now, modifiedAt: now)
}
}
// Mark items that exist on server as .added but are absent locally as .removed
if let serverState {
for (itemName, serverItem) in serverState.items {
if items[itemName] == nil && serverItem.status == .added {
items[itemName] = GroceryItemState(
status: .removed,
addedAt: serverItem.addedAt,
modifiedAt: now
)
}
}
}
return GroceryState(lastModified: now, items: items)
}
// MARK: - Pending Removal Tracking
/// Records a recipe ID whose grocery items were fully removed, so that
/// `performSync` can push the deletion even after the key disappears
/// from `groceryDict`.
func trackPendingRemoval(recipeId: String) {
pendingRemovalRecipeIds.insert(recipeId)
savePendingRemovals()
}
func clearPendingRemoval(recipeId: String) {
guard pendingRemovalRecipeIds.remove(recipeId) != nil else { return }
savePendingRemovals()
}
private func loadPendingRemovals() async {
guard let loaded: Set<String> = try? await dataStore.load(fromPath: pendingRemovalPath) else { return }
pendingRemovalRecipeIds = loaded
}
private func savePendingRemovals() {
Task {
await dataStore.save(data: pendingRemovalRecipeIds, toPath: pendingRemovalPath)
}
}
}