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
+101 -7
View File
@@ -638,6 +638,13 @@ struct HomeView: View {
onFix: { slot in
viewModel.removeDish(from: slot, plan: plan, settings: settings)
},
onReplace: { slot in
// Straight to the picker for that slot so the user can swap
// the dish instead of just clearing it.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
selectedFilledSlotId = slot.id
}
},
onIgnore: { slot in
viewModel.acknowledgeViolation(slot: slot, plan: plan)
}
@@ -1309,6 +1316,7 @@ private struct RuleViolationsPanelSheet: View {
let tags: [Tag]
let settings: AppSettings
let onFix: (MealSlot) -> Void
let onReplace: (MealSlot) -> Void
let onIgnore: (MealSlot) -> Void
@Environment(\.dismiss) private var dismiss
@@ -1338,10 +1346,15 @@ private struct RuleViolationsPanelSheet: View {
ViolationRow(
violation: violation,
settings: settings,
tags: tags,
onFix: {
onFix(slot)
if violations.count <= 1 { dismiss() }
},
onReplace: {
onReplace(slot)
dismiss()
},
onIgnore: {
onIgnore(slot)
if violations.count <= 1 { dismiss() }
@@ -1369,17 +1382,60 @@ private struct RuleViolationsPanelSheet: View {
private struct ViolationRow: View {
let violation: AutocompleteEngine.RuleViolation
let settings: AppSettings
let tags: [Tag]
let onFix: () -> Void
let onReplace: () -> Void
let onIgnore: () -> Void
private var language: AppLanguage { settings.languageEnum.resolved() }
private var dayLabel: String {
let language = settings.languageEnum.resolved()
return localizedString(dayKey(for: violation.dayOfWeek), language: language)
localizedString(dayKey(for: violation.dayOfWeek), language: language)
}
private var mealLabel: String {
let language = settings.languageEnum.resolved()
return localizedString(violation.mealType, language: language)
localizedString(violation.mealType, language: language)
}
private func tagName(_ id: UUID?) -> String {
guard let id, let tag = tags.first(where: { $0.id == id }) else { return "" }
return tag.localizedName(language: language)
}
/// Plain-language "what rule is broken" for each reason.
private func explanation(_ reason: AutocompleteEngine.RuleViolation.Reason) -> String {
let tag = tagName(reason.tagId)
switch reason.kind {
case .repeatedInWeek:
return String(localized: "violation_reason_repeated")
case .maxPerWeek:
return String(format: String(localized: "violation_reason_max_per_week"), tag, reason.limit ?? 0)
case .noConsecutive:
return String(format: String(localized: "violation_reason_no_consecutive"), tag)
case .noDuplicateInDay:
return String(format: String(localized: "violation_reason_no_same_day"), tag)
case .mealTypeOnly:
let meal = localizedString(reason.restriction ?? "", language: language)
return String(format: String(localized: "violation_reason_meal_only"), tag, meal)
case .dayRestriction:
let key = reason.restriction == "weekend" ? "violation_scope_weekend" : "violation_scope_weekdays"
return String(format: String(localized: "violation_reason_day_scope"), tag, String(localized: String.LocalizationValue(key)))
}
}
/// What the user can do about it.
private var suggestion: String {
guard let first = violation.reasons.first else {
return String(localized: "violation_suggestion_generic")
}
switch first.kind {
case .repeatedInWeek:
return String(localized: "violation_suggestion_repeated")
case .maxPerWeek, .noConsecutive, .noDuplicateInDay:
return String(localized: "violation_suggestion_swap")
case .mealTypeOnly, .dayRestriction:
return String(localized: "violation_suggestion_move")
}
}
var body: some View {
@@ -1397,9 +1453,36 @@ private struct RuleViolationsPanelSheet: View {
.font(.mealMoodBodyBold)
.foregroundColor(.mealMoodTextPrimary)
HStack(spacing: 10) {
Button(action: onFix) {
Text("violations_fix")
if !violation.reasons.isEmpty {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(violation.reasons.enumerated()), id: \.offset) { _, reason in
HStack(alignment: .top, spacing: 6) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 11))
.foregroundColor(.mealMoodError)
Text(explanation(reason))
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextPrimary)
}
}
HStack(alignment: .top, spacing: 6) {
Image(systemName: "lightbulb.fill")
.font(.system(size: 11))
.foregroundColor(.mealMoodWarning)
Text(suggestion)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.mealMoodWarning.opacity(0.10))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
HStack(spacing: 8) {
Button(action: onReplace) {
Text("violations_replace")
.font(.mealMoodSmall)
.foregroundColor(.white)
.padding(.horizontal, 14)
@@ -1409,6 +1492,17 @@ private struct RuleViolationsPanelSheet: View {
}
.buttonStyle(.plain)
Button(action: onFix) {
Text("violations_fix")
.font(.mealMoodSmall)
.foregroundColor(.mealMoodCoral)
.padding(.horizontal, 14)
.padding(.vertical, 6)
.background(Color.mealMoodCoral.opacity(0.12))
.clipShape(Capsule())
}
.buttonStyle(.plain)
Button(action: onIgnore) {
Text("violations_ignore")
.font(.mealMoodSmall)
+138 -1
View File
@@ -15,6 +15,8 @@ struct StatsView: View {
summaryGrid
topDishesSection
tagBreakdownSection
tagByMealSection
dishesPerTagSection
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
@@ -190,7 +192,7 @@ struct StatsView: View {
.map { $0 }
}
private struct TagEntry { let tag: Tag; let count: Int }
struct TagEntry { let tag: Tag; let count: Int }
private var tagUsage: [TagEntry] {
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
@@ -212,6 +214,141 @@ struct StatsView: View {
}
}
// Internal (not private) so the render harness in the tests can draw these
// sections on their own ImageRenderer cannot draw the NavigationStack body.
extension StatsView {
/// Tag usage split by meal type "how many dinners are pasta?".
var tagByMealSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("stats_tag_by_meal_title")
.font(.mealMoodH3)
.foregroundColor(.mealMoodTextPrimary)
if tagMealMatrix.isEmpty {
Text("stats_no_data")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
} else {
VStack(spacing: 0) {
HStack {
Text("").frame(maxWidth: .infinity, alignment: .leading)
ForEach(usedMealTypes, id: \.self) { meal in
Text(LocalizedStringKey(meal.localizedKey))
.font(.mealMoodCaption.weight(.semibold))
.foregroundColor(.mealMoodTextSecondary)
.frame(width: 62)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
ForEach(tagMealMatrix, id: \.tag.id) { row in
Divider().padding(.leading, 12)
HStack {
HStack(spacing: 6) {
TagDot(color: row.tag.color, size: 8)
Text(row.tag.localizedName(language: language))
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextPrimary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
ForEach(usedMealTypes, id: \.self) { meal in
Text("\(row.counts[meal] ?? 0)")
.font(.mealMoodSmall.weight(.semibold))
.foregroundColor((row.counts[meal] ?? 0) > 0 ? .mealMoodTextPrimary : .mealMoodTextSecondary.opacity(0.5))
.frame(width: 62)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
}
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
/// How many dishes in the catalogue carry each tag.
var dishesPerTagSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("stats_dishes_per_tag_title")
.font(.mealMoodH3)
.foregroundColor(.mealMoodTextPrimary)
if dishesPerTag.isEmpty {
Text("stats_no_data")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
} else {
VStack(spacing: 10) {
ForEach(dishesPerTag, id: \.tag.id) { entry in
TagBarRow(
tagName: entry.tag.localizedName(language: language),
tagColor: entry.tag.color,
count: entry.count,
maxCount: dishesPerTag.first?.count ?? 1
)
}
}
.padding(14)
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
var usedMealTypes: [MealType] {
let raw = Set(weekPlans.flatMap { $0.slotList.map(\.mealType) })
let types = MealType.allCases.filter { raw.contains($0.rawValue) }
return types.isEmpty ? [.lunch, .dinner] : types
}
struct TagMealRow { let tag: Tag; let counts: [MealType: Int]; let total: Int }
var tagMealMatrix: [TagMealRow] {
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
var counts: [UUID: [MealType: Int]] = [:]
for plan in weekPlans {
for slot in plan.slotList {
guard let meal = MealType(rawValue: slot.mealType) else { continue }
for dishId in [slot.dishId, slot.secondaryDishId].compactMap({ $0 }) {
guard let dish = dishMap[dishId] else { continue }
for tagId in dish.tagIds {
counts[tagId, default: [:]][meal, default: 0] += 1
}
}
}
}
return counts.compactMap { tagId, byMeal -> TagMealRow? in
guard let tag = allTags.first(where: { $0.id == tagId }) else { return nil }
return TagMealRow(tag: tag, counts: byMeal, total: byMeal.values.reduce(0, +))
}
.sorted { $0.total > $1.total }
.prefix(8)
.map { $0 }
}
var dishesPerTag: [TagEntry] {
var counts: [UUID: Int] = [:]
for dish in allDishes {
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 }
}
return counts
.compactMap { id, count in allTags.first(where: { $0.id == id }).map { TagEntry(tag: $0, count: count) } }
.sorted { $0.count > $1.count }
.prefix(8)
.map { $0 }
}
}
// MARK: - Sub-views
private struct StatCard: View {