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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
This commit is contained in:
alexandrev-tibco
2026-09-01 11:22:34 +02:00
parent 47daab9e51
commit 7193234024
10 changed files with 120 additions and 2 deletions
+11 -1
View File
@@ -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
}
}
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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.
+19 -1
View File
@@ -7,6 +7,9 @@ final class DishViewModel: ObservableObject {
@Published var descriptionText: String = ""
@Published var selectedTagIds: Set<UUID> = []
@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()
+54
View File
@@ -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 {