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>
118 lines
4.2 KiB
Swift
118 lines
4.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: - 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
|
|
)
|
|
}
|
|
}
|