reglas: el panel explica que se incumple y como arreglarlo; stats por etiqueta

- RuleViolation.reasons: el motor devuelve ahora QUE regla rompe cada
  asignacion (repetido en la semana, maximo por semana superado, no
  consecutivo, no mismo dia, solo comida/cena, solo entre semana o fin
  de semana) con su etiqueta y limite
- Panel de avisos: bloque con cada regla rota en lenguaje claro + una
  sugerencia de que hacer, y boton nuevo "Cambiar plato" que abre el
  selector de ese hueco (antes solo se podia quitar o ignorar)
- Estadisticas: matriz "Etiquetas por tipo de comida" (p.ej. cuantas
  cenas son de pescado) y "Platos por etiqueta" del catalogo
- Tests: 4 casos de las razones de incumplimiento; harness que renderiza
  las secciones nuevas de stats a /tmp/stats_render.png
- 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-10 21:06:42 +02:00
parent 9406c3f8e7
commit 718eef16bf
14 changed files with 545 additions and 11 deletions
+93 -1
View File
@@ -13,6 +13,32 @@ struct AutocompleteEngine {
let mealType: String
let dishId: UUID
let dishName: String
/// Which rules this assignment breaks the panel turns these into a
/// plain-language explanation and a suggested fix.
let reasons: [Reason]
struct Reason: Hashable {
enum Kind: Hashable {
case maxPerWeek
case noConsecutive
case noDuplicateInDay
case mealTypeOnly
case dayRestriction
case repeatedInWeek
}
let kind: Kind
let tagId: UUID?
let limit: Int?
/// "lunch"/"dinner" for mealTypeOnly, "weekdays"/"weekend" for dayRestriction.
let restriction: String?
init(kind: Kind, tagId: UUID? = nil, limit: Int? = nil, restriction: String? = nil) {
self.kind = kind
self.tagId = tagId
self.limit = limit
self.restriction = restriction
}
}
}
// recentPlans: up to 12 previous weeks for period detection (weekly/bi-weekly/monthly cycles).
@@ -90,6 +116,7 @@ struct AutocompleteEngine {
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
return plan.slotList.compactMap { slot in
guard let dishId = slot.dishId,
let dish = dishMap[dishId],
@@ -100,11 +127,76 @@ struct AutocompleteEngine {
dayOfWeek: slot.dayOfWeek,
mealType: slot.mealType,
dishId: dishId,
dishName: dish.name
dishName: dish.name,
reasons: reasons(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
)
}
}
/// Every rule the dish breaks in this slot. Mirrors `violatesExplicitRules`
/// but collects instead of short-circuiting, so the UI can explain itself.
static func reasons(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> [RuleViolation.Reason] {
var found: [RuleViolation.Reason] = []
if plan.slotList.contains(where: { $0.id != slot.id && $0.dishId == dish.id }) {
found.append(.init(kind: .repeatedInWeek))
}
for tagId in dish.tagIds {
guard let tag = tagMap[tagId] else { continue }
if let maxPerWeek = tag.maxPerWeek {
var count = 0
for s in plan.slotList {
guard let did = s.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { count += 1 }
}
if count > maxPerWeek {
found.append(.init(kind: .maxPerWeek, tagId: tagId, limit: maxPerWeek))
}
}
if tag.noConsecutive {
for adjSlot in adjacentSlots(of: slot, in: plan) {
guard let did = adjSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) {
found.append(.init(kind: .noConsecutive, tagId: tagId))
break
}
}
}
if tag.noDuplicateInDay {
for sdSlot in plan.slotList where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) {
found.append(.init(kind: .noDuplicateInDay, tagId: tagId))
break
}
}
}
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
found.append(.init(kind: .mealTypeOnly, tagId: tagId, restriction: restriction))
}
if let dayRestriction = tag.dayRestriction {
let broken = (dayRestriction == "weekdays" && slot.dayOfWeek > 4) ||
(dayRestriction == "weekend" && slot.dayOfWeek < 5)
if broken {
found.append(.init(kind: .dayRestriction, tagId: tagId, restriction: dayRestriction))
}
}
}
return found
}
static func validateDrop(
dish: Dish,
slot: MealSlot,