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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user