// // MealPlanManager.swift // Nextcloud Cookbook iOS Client // import Foundation import OSLog @MainActor class MealPlanManager: ObservableObject { @Published var entriesByDate: [String: [MealPlanEntry]] = [:] private var assignmentsByRecipe: [String: MealPlanAssignment] = [:] private var recipeNames: [String: String] = [:] private let dataStore = DataStore() var syncManager: MealPlanSyncManager? private static let persistencePath = "meal_plan.data" // MARK: - Persistence struct PersistenceData: Codable { var assignmentsByRecipe: [String: MealPlanAssignment] var recipeNames: [String: String] } func load() async { do { guard let data: PersistenceData = try await dataStore.load(fromPath: Self.persistencePath) else { return } assignmentsByRecipe = data.assignmentsByRecipe recipeNames = data.recipeNames pruneOldEntries() rebuildEntries() } catch { Logger.data.error("Unable to load meal plan data") } } func save() { pruneOldEntries() let data = PersistenceData(assignmentsByRecipe: assignmentsByRecipe, recipeNames: recipeNames) Task { await dataStore.save(data: data, toPath: Self.persistencePath) } } func configureSyncManager(appState: AppState) { syncManager = MealPlanSyncManager(appState: appState, mealPlanManager: self) } // MARK: - CRUD func assignRecipe(recipeId: String, recipeName: String, toDates dates: [Date]) { recipeNames[recipeId] = recipeName var assignment = assignmentsByRecipe[recipeId] ?? MealPlanAssignment() for date in dates { let dayStr = MealPlanDate.dayString(from: date) assignment.dates[dayStr] = MealPlanDateEntry(status: .assigned) } assignment.lastModified = MealPlanDate.now() assignmentsByRecipe[recipeId] = assignment rebuildEntries() save() syncManager?.scheduleSync(forRecipeId: recipeId) } func removeRecipe(recipeId: String, fromDate dateString: String) { guard var assignment = assignmentsByRecipe[recipeId] else { return } assignment.dates[dateString] = MealPlanDateEntry(status: .removed) assignment.lastModified = MealPlanDate.now() assignmentsByRecipe[recipeId] = assignment rebuildEntries() save() syncManager?.scheduleSync(forRecipeId: recipeId) } func removeAllAssignments(forRecipeId recipeId: String) { guard var assignment = assignmentsByRecipe[recipeId] else { return } let now = MealPlanDate.now() for key in assignment.dates.keys { assignment.dates[key] = MealPlanDateEntry(status: .removed, modifiedAt: now) } assignment.lastModified = now assignmentsByRecipe[recipeId] = assignment rebuildEntries() save() syncManager?.scheduleSync(forRecipeId: recipeId) } // MARK: - Queries func entries(for date: Date) -> [MealPlanEntry] { let dayStr = MealPlanDate.dayString(from: date) return entriesByDate[dayStr] ?? [] } func isRecipeAssigned(_ recipeId: String, on date: Date) -> Bool { let dayStr = MealPlanDate.dayString(from: date) guard let assignment = assignmentsByRecipe[recipeId], let entry = assignment.dates[dayStr] else { return false } return entry.status == .assigned } func assignedDates(forRecipeId recipeId: String) -> [String] { guard let assignment = assignmentsByRecipe[recipeId] else { return [] } return assignment.dates.compactMap { key, entry in entry.status == .assigned ? key : nil } } func assignment(forRecipeId recipeId: String) -> MealPlanAssignment? { assignmentsByRecipe[recipeId] } // MARK: - Reconciliation (Pull) func reconcileFromServer(serverAssignment: MealPlanAssignment?, recipeId: String, recipeName: String) { guard let serverAssignment, !serverAssignment.dates.isEmpty else { return } recipeNames[recipeId] = recipeName var local = assignmentsByRecipe[recipeId] ?? MealPlanAssignment() for (dayStr, serverEntry) in serverAssignment.dates { if let localEntry = local.dates[dayStr] { let localDate = MealPlanDate.date(from: localEntry.modifiedAt) ?? .distantPast let serverDate = MealPlanDate.date(from: serverEntry.modifiedAt) ?? .distantPast if serverDate > localDate { local.dates[dayStr] = serverEntry } } else { local.dates[dayStr] = serverEntry } } local.lastModified = MealPlanDate.now() assignmentsByRecipe[recipeId] = local rebuildEntries() save() } // MARK: - Internal private func pruneOldEntries() { let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Calendar.current.startOfDay(for: Date()))! var emptyRecipeIds: [String] = [] for (recipeId, var assignment) in assignmentsByRecipe { assignment.dates = assignment.dates.filter { dayStr, _ in guard let date = MealPlanDate.dateFromDay(dayStr) else { return true } return date >= cutoff } if assignment.dates.isEmpty { emptyRecipeIds.append(recipeId) } else { assignmentsByRecipe[recipeId] = assignment } } for recipeId in emptyRecipeIds { assignmentsByRecipe.removeValue(forKey: recipeId) recipeNames.removeValue(forKey: recipeId) } } private func rebuildEntries() { var newEntries: [String: [MealPlanEntry]] = [:] for (recipeId, assignment) in assignmentsByRecipe { let name = recipeNames[recipeId] ?? "Recipe \(recipeId)" for (dayStr, entry) in assignment.dates where entry.status == .assigned { guard let date = MealPlanDate.dateFromDay(dayStr) else { continue } let mealEntry = MealPlanEntry( recipeId: recipeId, recipeName: name, date: date, dateString: dayStr, mealType: entry.mealType ) newEntries[dayStr, default: []].append(mealEntry) } } // Sort entries within each day by recipe name for key in newEntries.keys { newEntries[key]?.sort(by: { $0.recipeName < $1.recipeName }) } entriesByDate = newEntries } }