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,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
}
}

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

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

View File

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

View File

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

View File

@@ -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 != "" {