From 719323402452b59cd95f088f15d59f5021ca2a40 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Tue, 1 Sep 2026 11:22:34 +0200 Subject: [PATCH] reglas: plato fijo por dia y comida (ej. tortilla los viernes de cena) - Dish.fixedDayOfWeek/fixedMealType (opcionales, aditivos para CloudKit) - Toggle + pickers de dia/comida en el formulario de plato - AutocompleteEngine: pre-pasada que asigna los platos fijos a su hueco antes del bucle MRV, de modo que cuentan para el resto de reglas - Evento analytics dish_fixed_slot_set; strings en 6 idiomas Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG --- MealMood/Models/Dish.swift | 12 ++++- .../Resources/de.lproj/Localizable.strings | 4 ++ .../Resources/en.lproj/Localizable.strings | 4 ++ .../Resources/es.lproj/Localizable.strings | 4 ++ .../Resources/fr.lproj/Localizable.strings | 4 ++ .../Resources/it.lproj/Localizable.strings | 4 ++ .../Resources/pt-BR.lproj/Localizable.strings | 4 ++ MealMood/Services/AutocompleteEngine.swift | 12 +++++ MealMood/ViewModels/DishViewModel.swift | 20 ++++++- MealMood/Views/Dishes/DishFormView.swift | 54 +++++++++++++++++++ 10 files changed, 120 insertions(+), 2 deletions(-) diff --git a/MealMood/Models/Dish.swift b/MealMood/Models/Dish.swift index cdbbecd..721fa88 100644 --- a/MealMood/Models/Dish.swift +++ b/MealMood/Models/Dish.swift @@ -20,6 +20,12 @@ final class Dish { /// CloudKit-friendly. @Attribute(.externalStorage) var photoData: Data? + /// Fixed-slot rule ("tortilla every Friday dinner"): when both are set, + /// autocomplete pins this dish to that weekday+meal before filling the + /// rest of the week. 0=Monday … 6=Sunday, matching `MealSlot.dayOfWeek`. + var fixedDayOfWeek: Int? + var fixedMealType: String? + init( id: UUID = UUID(), name: String, @@ -28,7 +34,9 @@ final class Dish { createdAt: Date = Date(), isPriority: Bool = false, ingredients: [String] = [], - photoData: Data? = nil + photoData: Data? = nil, + fixedDayOfWeek: Int? = nil, + fixedMealType: String? = nil ) { self.id = id self.name = name @@ -38,6 +46,8 @@ final class Dish { self.isPriority = isPriority self.ingredients = ingredients self.photoData = photoData + self.fixedDayOfWeek = fixedDayOfWeek + self.fixedMealType = fixedMealType } } diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index d9ec0ac..3afbd1b 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -422,3 +422,7 @@ "settings_show_dish_photos" = "Gerichtfotos im Planer anzeigen"; "dish_sort_alphabetical" = "Alphabetisch sortieren"; +"dish_fixed_slot" = "Festes Gericht"; +"dish_fixed_slot_desc" = "Beim Ausfüllen immer am selben Tag und zur selben Mahlzeit"; +"dish_fixed_slot_day" = "Tag"; +"dish_fixed_slot_meal" = "Mahlzeit"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 8377b9a..e8ea363 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -422,3 +422,7 @@ "settings_show_dish_photos" = "Show dish photos in planner"; "dish_sort_alphabetical" = "Sort alphabetically"; +"dish_fixed_slot" = "Fixed dish"; +"dish_fixed_slot_desc" = "Always on the same day and meal when auto-filling"; +"dish_fixed_slot_day" = "Day"; +"dish_fixed_slot_meal" = "Meal"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 3130a7b..5d5d92f 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -422,3 +422,7 @@ "settings_show_dish_photos" = "Mostrar fotos de platos en el planificador"; "dish_sort_alphabetical" = "Ordenar alfabéticamente"; +"dish_fixed_slot" = "Plato fijo"; +"dish_fixed_slot_desc" = "Siempre en el mismo día y comida al autocompletar"; +"dish_fixed_slot_day" = "Día"; +"dish_fixed_slot_meal" = "Comida"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 2356e59..7839ca9 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -422,3 +422,7 @@ "settings_show_dish_photos" = "Afficher les photos des plats dans le planning"; "dish_sort_alphabetical" = "Trier par ordre alphabétique"; +"dish_fixed_slot" = "Plat fixe"; +"dish_fixed_slot_desc" = "Toujours le même jour et le même repas lors du remplissage auto"; +"dish_fixed_slot_day" = "Jour"; +"dish_fixed_slot_meal" = "Repas"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index c8c5bc0..0fa3093 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -422,3 +422,7 @@ "settings_show_dish_photos" = "Mostra le foto dei piatti nel planner"; "dish_sort_alphabetical" = "Ordina alfabeticamente"; +"dish_fixed_slot" = "Piatto fisso"; +"dish_fixed_slot_desc" = "Sempre nello stesso giorno e pasto con il riempimento automatico"; +"dish_fixed_slot_day" = "Giorno"; +"dish_fixed_slot_meal" = "Pasto"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index e982ae8..b19817b 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -422,3 +422,7 @@ "settings_show_dish_photos" = "Mostrar fotos dos pratos no planejador"; "dish_sort_alphabetical" = "Ordenar alfabeticamente"; +"dish_fixed_slot" = "Prato fixo"; +"dish_fixed_slot_desc" = "Sempre no mesmo dia e refeição no preenchimento automático"; +"dish_fixed_slot_day" = "Dia"; +"dish_fixed_slot_meal" = "Refeição"; diff --git a/MealMood/Services/AutocompleteEngine.swift b/MealMood/Services/AutocompleteEngine.swift index 3c37817..7144fc2 100644 --- a/MealMood/Services/AutocompleteEngine.swift +++ b/MealMood/Services/AutocompleteEngine.swift @@ -33,6 +33,18 @@ struct AutocompleteEngine { var unfilledCount = 0 var pendingSlots = emptySlots.filter { !$0.isEatingOut } + // Fixed-slot rules win outright: a dish pinned to a weekday+meal fills + // that slot before any scoring. Runs before the MRV loop so the pinned + // dish also counts against no-repeat/tag rules for the rest of the week. + for slot in pendingSlots { + guard let fixed = allDishes.first(where: { + $0.fixedDayOfWeek == slot.dayOfWeek && $0.fixedMealType == slot.mealType + }) else { continue } + slot.dishId = fixed.id + filledSlots.append((slotId: slot.id, dishId: fixed.id)) + } + pendingSlots.removeAll { $0.dishId != nil } + // MRV: at each step pick the slot with fewest valid candidates first. // Assignments update currentPlan.slotList in-place, so subsequent candidate // counts automatically reflect the growing set of committed dishes. diff --git a/MealMood/ViewModels/DishViewModel.swift b/MealMood/ViewModels/DishViewModel.swift index f9e1ec5..f5150dd 100644 --- a/MealMood/ViewModels/DishViewModel.swift +++ b/MealMood/ViewModels/DishViewModel.swift @@ -7,6 +7,9 @@ final class DishViewModel: ObservableObject { @Published var descriptionText: String = "" @Published var selectedTagIds: Set = [] @Published var isPriority: Bool = false + @Published var hasFixedSlot: Bool = false + @Published var fixedDayOfWeek: Int = 4 // Friday + @Published var fixedMealType: MealType = .dinner @Published var ingredients: [String] = [] @Published var photoData: Data? @Published var isGeneratingIngredients: Bool = false @@ -29,6 +32,13 @@ final class DishViewModel: ObservableObject { descriptionText = dish.descriptionText ?? "" selectedTagIds = Set(dish.tagIds) isPriority = dish.isPriority + if let day = dish.fixedDayOfWeek, let meal = dish.fixedMealType.flatMap(MealType.init) { + hasFixedSlot = true + fixedDayOfWeek = day + fixedMealType = meal + } else { + hasFixedSlot = false + } ingredients = dish.ingredients photoData = dish.photoData } @@ -39,6 +49,9 @@ final class DishViewModel: ObservableObject { descriptionText = "" selectedTagIds = [] isPriority = false + hasFixedSlot = false + fixedDayOfWeek = 4 + fixedMealType = .dinner ingredients = [] photoData = nil } @@ -93,6 +106,8 @@ final class DishViewModel: ObservableObject { dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText dish.tagIds = Array(selectedTagIds) dish.isPriority = isPriority + dish.fixedDayOfWeek = hasFixedSlot ? fixedDayOfWeek : nil + dish.fixedMealType = hasFixedSlot ? fixedMealType.rawValue : nil dish.ingredients = cleanIngredients dish.photoData = photoData } else { @@ -102,11 +117,14 @@ final class DishViewModel: ObservableObject { tagIds: Array(selectedTagIds), isPriority: isPriority, ingredients: cleanIngredients, - photoData: photoData + photoData: photoData, + fixedDayOfWeek: hasFixedSlot ? fixedDayOfWeek : nil, + fixedMealType: hasFixedSlot ? fixedMealType.rawValue : nil ) context.insert(dish) AnalyticsService.logDishAdded(tagCount: selectedTagIds.count) } + if hasFixedSlot { AnalyticsService.logEvent("dish_fixed_slot_set") } try? context.save() reset() diff --git a/MealMood/Views/Dishes/DishFormView.swift b/MealMood/Views/Dishes/DishFormView.swift index 81162c4..0ec0aa5 100644 --- a/MealMood/Views/Dishes/DishFormView.swift +++ b/MealMood/Views/Dishes/DishFormView.swift @@ -27,6 +27,12 @@ struct DishFormView: View { (allSettings.first?.languageEnum ?? .system).resolved() } + /// Localized weekday name for the app's 0=Monday … 6=Sunday convention. + static func weekdayName(_ day: Int) -> String { + let symbols = Calendar.current.standaloneWeekdaySymbols // [0]=Sunday + return symbols[(day + 1) % 7].capitalized + } + private var reachedFreeDishLimit: Bool { guard let settings = allSettings.first else { return false } if viewModel.isEditing { return false } @@ -290,6 +296,54 @@ struct DishFormView: View { .background(Color.mealMoodSurface) .cornerRadius(14) + // Fixed-slot rule ("tortilla every Friday dinner") + VStack(alignment: .leading, spacing: 10) { + Toggle(isOn: Binding( + get: { viewModel.hasFixedSlot }, + set: { viewModel.hasFixedSlot = $0 } + )) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Image(systemName: "pin.fill") + .foregroundColor(.mealMoodCoral) + .font(.system(size: 13)) + Text("dish_fixed_slot") + .font(.mealMoodBodyBold) + .foregroundColor(.mealMoodTextPrimary) + } + Text("dish_fixed_slot_desc") + .font(.mealMoodCaption) + .foregroundColor(.mealMoodTextSecondary) + } + } + .tint(.mealMoodCoral) + + if viewModel.hasFixedSlot { + HStack(spacing: 12) { + Picker("dish_fixed_slot_day", selection: Binding( + get: { viewModel.fixedDayOfWeek }, + set: { viewModel.fixedDayOfWeek = $0 } + )) { + ForEach(0..<7, id: \.self) { day in + Text(Self.weekdayName(day)).tag(day) + } + } + Picker("dish_fixed_slot_meal", selection: Binding( + get: { viewModel.fixedMealType }, + set: { viewModel.fixedMealType = $0 } + )) { + ForEach(allSettings.first?.activeMealTypes ?? MealType.allCases, id: \.self) { meal in + Text(LocalizedStringKey(meal.localizedKey)).tag(meal) + } + } + } + .pickerStyle(.menu) + .tint(.mealMoodCoral) + } + } + .padding(16) + .background(Color.mealMoodSurface) + .cornerRadius(14) // Delete button (edit mode only) if viewModel.isEditing {