Add meal plan feature with cross-device sync and automatic stale data cleanup
Introduces weekly meal planning with a calendar-based tab view, per-recipe date assignments synced via Nextcloud Cookbook custom metadata, and 30-day automatic pruning of old entries on load, save, and sync merge. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
117
Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
Normal file
117
Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
Normal file
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// 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: - 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user