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.
|
/// CloudKit-friendly.
|
||||||
@Attribute(.externalStorage) var photoData: Data?
|
@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(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
name: String,
|
name: String,
|
||||||
@@ -28,7 +34,9 @@ final class Dish {
|
|||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
isPriority: Bool = false,
|
isPriority: Bool = false,
|
||||||
ingredients: [String] = [],
|
ingredients: [String] = [],
|
||||||
photoData: Data? = nil
|
photoData: Data? = nil,
|
||||||
|
fixedDayOfWeek: Int? = nil,
|
||||||
|
fixedMealType: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -38,6 +46,8 @@ final class Dish {
|
|||||||
self.isPriority = isPriority
|
self.isPriority = isPriority
|
||||||
self.ingredients = ingredients
|
self.ingredients = ingredients
|
||||||
self.photoData = photoData
|
self.photoData = photoData
|
||||||
|
self.fixedDayOfWeek = fixedDayOfWeek
|
||||||
|
self.fixedMealType = fixedMealType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -422,3 +422,7 @@
|
|||||||
|
|
||||||
"settings_show_dish_photos" = "Gerichtfotos im Planer anzeigen";
|
"settings_show_dish_photos" = "Gerichtfotos im Planer anzeigen";
|
||||||
"dish_sort_alphabetical" = "Alphabetisch sortieren";
|
"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";
|
"settings_show_dish_photos" = "Show dish photos in planner";
|
||||||
"dish_sort_alphabetical" = "Sort alphabetically";
|
"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";
|
"settings_show_dish_photos" = "Mostrar fotos de platos en el planificador";
|
||||||
"dish_sort_alphabetical" = "Ordenar alfabéticamente";
|
"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";
|
"settings_show_dish_photos" = "Afficher les photos des plats dans le planning";
|
||||||
"dish_sort_alphabetical" = "Trier par ordre alphabétique";
|
"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";
|
"settings_show_dish_photos" = "Mostra le foto dei piatti nel planner";
|
||||||
"dish_sort_alphabetical" = "Ordina alfabeticamente";
|
"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";
|
"settings_show_dish_photos" = "Mostrar fotos dos pratos no planejador";
|
||||||
"dish_sort_alphabetical" = "Ordenar alfabeticamente";
|
"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 unfilledCount = 0
|
||||||
var pendingSlots = emptySlots.filter { !$0.isEatingOut }
|
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.
|
// MRV: at each step pick the slot with fewest valid candidates first.
|
||||||
// Assignments update currentPlan.slotList in-place, so subsequent candidate
|
// Assignments update currentPlan.slotList in-place, so subsequent candidate
|
||||||
// counts automatically reflect the growing set of committed dishes.
|
// counts automatically reflect the growing set of committed dishes.
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ final class DishViewModel: ObservableObject {
|
|||||||
@Published var descriptionText: String = ""
|
@Published var descriptionText: String = ""
|
||||||
@Published var selectedTagIds: Set<UUID> = []
|
@Published var selectedTagIds: Set<UUID> = []
|
||||||
@Published var isPriority: Bool = false
|
@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 ingredients: [String] = []
|
||||||
@Published var photoData: Data?
|
@Published var photoData: Data?
|
||||||
@Published var isGeneratingIngredients: Bool = false
|
@Published var isGeneratingIngredients: Bool = false
|
||||||
@@ -29,6 +32,13 @@ final class DishViewModel: ObservableObject {
|
|||||||
descriptionText = dish.descriptionText ?? ""
|
descriptionText = dish.descriptionText ?? ""
|
||||||
selectedTagIds = Set(dish.tagIds)
|
selectedTagIds = Set(dish.tagIds)
|
||||||
isPriority = dish.isPriority
|
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
|
ingredients = dish.ingredients
|
||||||
photoData = dish.photoData
|
photoData = dish.photoData
|
||||||
}
|
}
|
||||||
@@ -39,6 +49,9 @@ final class DishViewModel: ObservableObject {
|
|||||||
descriptionText = ""
|
descriptionText = ""
|
||||||
selectedTagIds = []
|
selectedTagIds = []
|
||||||
isPriority = false
|
isPriority = false
|
||||||
|
hasFixedSlot = false
|
||||||
|
fixedDayOfWeek = 4
|
||||||
|
fixedMealType = .dinner
|
||||||
ingredients = []
|
ingredients = []
|
||||||
photoData = nil
|
photoData = nil
|
||||||
}
|
}
|
||||||
@@ -93,6 +106,8 @@ final class DishViewModel: ObservableObject {
|
|||||||
dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText
|
dish.descriptionText = descriptionText.isEmpty ? nil : descriptionText
|
||||||
dish.tagIds = Array(selectedTagIds)
|
dish.tagIds = Array(selectedTagIds)
|
||||||
dish.isPriority = isPriority
|
dish.isPriority = isPriority
|
||||||
|
dish.fixedDayOfWeek = hasFixedSlot ? fixedDayOfWeek : nil
|
||||||
|
dish.fixedMealType = hasFixedSlot ? fixedMealType.rawValue : nil
|
||||||
dish.ingredients = cleanIngredients
|
dish.ingredients = cleanIngredients
|
||||||
dish.photoData = photoData
|
dish.photoData = photoData
|
||||||
} else {
|
} else {
|
||||||
@@ -102,11 +117,14 @@ final class DishViewModel: ObservableObject {
|
|||||||
tagIds: Array(selectedTagIds),
|
tagIds: Array(selectedTagIds),
|
||||||
isPriority: isPriority,
|
isPriority: isPriority,
|
||||||
ingredients: cleanIngredients,
|
ingredients: cleanIngredients,
|
||||||
photoData: photoData
|
photoData: photoData,
|
||||||
|
fixedDayOfWeek: hasFixedSlot ? fixedDayOfWeek : nil,
|
||||||
|
fixedMealType: hasFixedSlot ? fixedMealType.rawValue : nil
|
||||||
)
|
)
|
||||||
context.insert(dish)
|
context.insert(dish)
|
||||||
AnalyticsService.logDishAdded(tagCount: selectedTagIds.count)
|
AnalyticsService.logDishAdded(tagCount: selectedTagIds.count)
|
||||||
}
|
}
|
||||||
|
if hasFixedSlot { AnalyticsService.logEvent("dish_fixed_slot_set") }
|
||||||
|
|
||||||
try? context.save()
|
try? context.save()
|
||||||
reset()
|
reset()
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ struct DishFormView: View {
|
|||||||
(allSettings.first?.languageEnum ?? .system).resolved()
|
(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 {
|
private var reachedFreeDishLimit: Bool {
|
||||||
guard let settings = allSettings.first else { return false }
|
guard let settings = allSettings.first else { return false }
|
||||||
if viewModel.isEditing { return false }
|
if viewModel.isEditing { return false }
|
||||||
@@ -290,6 +296,54 @@ struct DishFormView: View {
|
|||||||
.background(Color.mealMoodSurface)
|
.background(Color.mealMoodSurface)
|
||||||
.cornerRadius(14)
|
.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)
|
// Delete button (edit mode only)
|
||||||
if viewModel.isEditing {
|
if viewModel.isEditing {
|
||||||
|
|||||||
Reference in New Issue
Block a user