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:
197
Nextcloud Cookbook iOS Client/Data/MealPlanManager.swift
Normal file
197
Nextcloud Cookbook iOS Client/Data/MealPlanManager.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user