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