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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user