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
|
||||
}
|
||||
}
|
||||
83
Nextcloud Cookbook iOS Client/Data/MealPlanModels.swift
Normal file
83
Nextcloud Cookbook iOS Client/Data/MealPlanModels.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// MealPlanModels.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Tracks meal plan assignments for a recipe, stored as `_mealPlanAssignment` in the recipe JSON on the server.
|
||||
struct MealPlanAssignment: Codable {
|
||||
var version: Int = 1
|
||||
var lastModified: String
|
||||
var dates: [String: MealPlanDateEntry]
|
||||
|
||||
init(lastModified: String = MealPlanDate.now(), dates: [String: MealPlanDateEntry] = [:]) {
|
||||
self.version = 1
|
||||
self.lastModified = lastModified
|
||||
self.dates = dates
|
||||
}
|
||||
}
|
||||
|
||||
struct MealPlanDateEntry: Codable {
|
||||
enum Status: String, Codable {
|
||||
case assigned
|
||||
case removed
|
||||
}
|
||||
|
||||
var status: Status
|
||||
var mealType: String?
|
||||
var modifiedAt: String
|
||||
|
||||
init(status: Status, mealType: String? = nil, modifiedAt: String = MealPlanDate.now()) {
|
||||
self.status = status
|
||||
self.mealType = mealType
|
||||
self.modifiedAt = modifiedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO 8601 date helpers for meal plan dates.
|
||||
enum MealPlanDate {
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.timeZone = .current
|
||||
return f
|
||||
}()
|
||||
|
||||
static func now() -> String {
|
||||
isoFormatter.string(from: Date())
|
||||
}
|
||||
|
||||
static func date(from string: String) -> Date? {
|
||||
isoFormatter.date(from: string)
|
||||
}
|
||||
|
||||
static func string(from date: Date) -> String {
|
||||
isoFormatter.string(from: date)
|
||||
}
|
||||
|
||||
static func dayString(from date: Date) -> String {
|
||||
dayFormatter.string(from: date)
|
||||
}
|
||||
|
||||
static func dateFromDay(_ dayString: String) -> Date? {
|
||||
dayFormatter.date(from: dayString)
|
||||
}
|
||||
}
|
||||
|
||||
/// Local-only aggregated view struct used by the UI.
|
||||
struct MealPlanEntry: Identifiable {
|
||||
let recipeId: String
|
||||
let recipeName: String
|
||||
let date: Date
|
||||
let dateString: String
|
||||
let mealType: String?
|
||||
|
||||
var id: String { "\(recipeId)-\(dateString)" }
|
||||
}
|
||||
117
Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
Normal file
117
Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
@Published var recipeInstructions: [String]
|
||||
@Published var nutrition: [String:String]
|
||||
var groceryState: GroceryState?
|
||||
var mealPlanAssignment: MealPlanAssignment?
|
||||
|
||||
// Additional functionality
|
||||
@Published var ingredientMultiplier: Double
|
||||
@@ -50,6 +51,7 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
recipeInstructions = []
|
||||
nutrition = [:]
|
||||
groceryState = nil
|
||||
mealPlanAssignment = nil
|
||||
|
||||
ingredientMultiplier = 1
|
||||
}
|
||||
@@ -71,6 +73,7 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
recipeInstructions = recipeDetail.recipeInstructions
|
||||
nutrition = recipeDetail.nutrition
|
||||
groceryState = recipeDetail.groceryState
|
||||
mealPlanAssignment = recipeDetail.mealPlanAssignment
|
||||
|
||||
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
|
||||
}
|
||||
@@ -94,7 +97,8 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
recipeIngredient: self.recipeIngredient,
|
||||
recipeInstructions: self.recipeInstructions,
|
||||
nutrition: self.nutrition,
|
||||
groceryState: self.groceryState
|
||||
groceryState: self.groceryState,
|
||||
mealPlanAssignment: self.mealPlanAssignment
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,8 +51,9 @@ struct RecipeDetail: Codable {
|
||||
var recipeInstructions: [String]
|
||||
var nutrition: [String:String]
|
||||
var groceryState: GroceryState?
|
||||
var mealPlanAssignment: MealPlanAssignment?
|
||||
|
||||
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String], groceryState: GroceryState? = nil) {
|
||||
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String], groceryState: GroceryState? = nil, mealPlanAssignment: MealPlanAssignment? = nil) {
|
||||
self.name = name
|
||||
self.keywords = keywords
|
||||
self.dateCreated = dateCreated
|
||||
@@ -71,6 +72,7 @@ struct RecipeDetail: Codable {
|
||||
self.recipeInstructions = recipeInstructions
|
||||
self.nutrition = nutrition
|
||||
self.groceryState = groceryState
|
||||
self.mealPlanAssignment = mealPlanAssignment
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -92,12 +94,14 @@ struct RecipeDetail: Codable {
|
||||
recipeInstructions = []
|
||||
nutrition = [:]
|
||||
groceryState = nil
|
||||
mealPlanAssignment = nil
|
||||
}
|
||||
|
||||
// Custom decoder to handle value type ambiguity
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
|
||||
case groceryState = "_groceryState"
|
||||
case mealPlanAssignment = "_mealPlanAssignment"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -138,6 +142,7 @@ struct RecipeDetail: Codable {
|
||||
}
|
||||
|
||||
groceryState = try? container.decode(GroceryState.self, forKey: .groceryState)
|
||||
mealPlanAssignment = try? container.decode(MealPlanAssignment.self, forKey: .mealPlanAssignment)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
@@ -161,6 +166,7 @@ struct RecipeDetail: Codable {
|
||||
try container.encode(recipeInstructions, forKey: .recipeInstructions)
|
||||
try container.encode(nutrition, forKey: .nutrition)
|
||||
try container.encodeIfPresent(groceryState, forKey: .groceryState)
|
||||
try container.encodeIfPresent(mealPlanAssignment, forKey: .mealPlanAssignment)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,12 @@ class UserSettings: ObservableObject {
|
||||
UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var mealPlanSyncEnabled: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(mealPlanSyncEnabled, forKey: "mealPlanSyncEnabled")
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
||||
@@ -161,6 +167,7 @@ class UserSettings: ObservableObject {
|
||||
self.groceryListMode = UserDefaults.standard.object(forKey: "groceryListMode") as? String ?? GroceryListMode.inApp.rawValue
|
||||
self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? ""
|
||||
self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true
|
||||
self.mealPlanSyncEnabled = UserDefaults.standard.object(forKey: "mealPlanSyncEnabled") as? Bool ?? true
|
||||
|
||||
if authString == "" {
|
||||
if token != "" && username != "" {
|
||||
|
||||
Reference in New Issue
Block a user