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>
198 lines
6.6 KiB
Swift
198 lines
6.6 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|