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:
2026-02-15 05:23:29 +01:00
parent 5890dbcad4
commit 8b23652f10
17 changed files with 1332 additions and 6 deletions

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