diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings
index 2c50232..60eae50 100644
--- a/MealMood/Resources/de.lproj/Localizable.strings
+++ b/MealMood/Resources/de.lproj/Localizable.strings
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Schon zu Hause";
"premium_privacy_link" = "Datenschutzrichtlinie";
"premium_terms_link" = "Nutzungsbedingungen (EULA)";
+"violation_reason_repeated" = "Dieses Gericht ist diese Woche schon eingeplant";
+"violation_reason_max_per_week" = "Maximal %1$@: %2$d pro Woche — überschritten";
+"violation_reason_no_consecutive" = "%@ darf nicht an aufeinanderfolgenden Tagen wiederholt werden";
+"violation_reason_no_same_day" = "%@ ist an diesem Tag schon in einer anderen Mahlzeit";
+"violation_reason_meal_only" = "%1$@ ist nur bei %2$@ erlaubt";
+"violation_reason_day_scope" = "%1$@ ist nur %2$@ erlaubt";
+"violation_scope_weekdays" = "unter der Woche";
+"violation_scope_weekend" = "am Wochenende";
+"violation_suggestion_repeated" = "Tausche es gegen ein anderes Gericht";
+"violation_suggestion_swap" = "Tausche es gegen ein Gericht mit anderem Tag";
+"violation_suggestion_move" = "Verschiebe es auf einen erlaubten Tag oder eine erlaubte Mahlzeit";
+"violation_suggestion_generic" = "Gericht ändern oder Hinweis ignorieren";
+"violations_replace" = "Gericht ändern";
+"stats_tag_by_meal_title" = "Tags nach Mahlzeit";
+"stats_dishes_per_tag_title" = "Gerichte pro Tag";
diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings
index e7a4560..ad999e0 100644
--- a/MealMood/Resources/en.lproj/Localizable.strings
+++ b/MealMood/Resources/en.lproj/Localizable.strings
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Already at home";
"premium_privacy_link" = "Privacy Policy";
"premium_terms_link" = "Terms of Use (EULA)";
+"violation_reason_repeated" = "This dish is already planned in another meal this week";
+"violation_reason_max_per_week" = "Max %1$@: %2$d per week, and you are over it";
+"violation_reason_no_consecutive" = "%@ cannot repeat on consecutive days";
+"violation_reason_no_same_day" = "%@ is already in another meal on this day";
+"violation_reason_meal_only" = "%1$@ is only allowed at %2$@";
+"violation_reason_day_scope" = "%1$@ is only allowed %2$@";
+"violation_scope_weekdays" = "on weekdays";
+"violation_scope_weekend" = "on weekends";
+"violation_suggestion_repeated" = "Swap it for another dish to vary the week";
+"violation_suggestion_swap" = "Swap it for a dish with a different tag";
+"violation_suggestion_move" = "Move it to a day or meal where it is allowed";
+"violation_suggestion_generic" = "Change the dish or dismiss the warning";
+"violations_replace" = "Change dish";
+"stats_tag_by_meal_title" = "Tags by meal type";
+"stats_dishes_per_tag_title" = "Dishes per tag";
diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings
index 4f90fd5..c0567e0 100644
--- a/MealMood/Resources/es.lproj/Localizable.strings
+++ b/MealMood/Resources/es.lproj/Localizable.strings
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Ya en casa";
"premium_privacy_link" = "Política de privacidad";
"premium_terms_link" = "Términos de uso (EULA)";
+"violation_reason_repeated" = "Este plato ya está en otra comida de la semana";
+"violation_reason_max_per_week" = "Máximo %1$@: %2$d por semana, y ya lo superas";
+"violation_reason_no_consecutive" = "%@ no puede repetirse en días seguidos";
+"violation_reason_no_same_day" = "%@ ya está en otra comida de este mismo día";
+"violation_reason_meal_only" = "%1$@ solo está permitido en %2$@";
+"violation_reason_day_scope" = "%1$@ solo está permitido %2$@";
+"violation_scope_weekdays" = "entre semana";
+"violation_scope_weekend" = "en fin de semana";
+"violation_suggestion_repeated" = "Cámbialo por otro plato para variar la semana";
+"violation_suggestion_swap" = "Cámbialo por un plato de otra etiqueta";
+"violation_suggestion_move" = "Muévelo a un día o comida donde sí esté permitido";
+"violation_suggestion_generic" = "Cambia el plato o ignora el aviso";
+"violations_replace" = "Cambiar plato";
+"stats_tag_by_meal_title" = "Etiquetas por tipo de comida";
+"stats_dishes_per_tag_title" = "Platos por etiqueta";
diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings
index f7f27ad..da22f29 100644
--- a/MealMood/Resources/fr.lproj/Localizable.strings
+++ b/MealMood/Resources/fr.lproj/Localizable.strings
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Déjà à la maison";
"premium_privacy_link" = "Politique de confidentialité";
"premium_terms_link" = "Conditions d'utilisation (CLUF)";
+"violation_reason_repeated" = "Ce plat est déjà prévu dans un autre repas cette semaine";
+"violation_reason_max_per_week" = "Maximum %1$@ : %2$d par semaine, et vous le dépassez";
+"violation_reason_no_consecutive" = "%@ ne peut pas se répéter deux jours de suite";
+"violation_reason_no_same_day" = "%@ est déjà dans un autre repas ce jour-là";
+"violation_reason_meal_only" = "%1$@ est autorisé uniquement au %2$@";
+"violation_reason_day_scope" = "%1$@ est autorisé uniquement %2$@";
+"violation_scope_weekdays" = "en semaine";
+"violation_scope_weekend" = "le week-end";
+"violation_suggestion_repeated" = "Remplacez-le par un autre plat pour varier";
+"violation_suggestion_swap" = "Remplacez-le par un plat d'une autre étiquette";
+"violation_suggestion_move" = "Déplacez-le vers un jour ou un repas autorisé";
+"violation_suggestion_generic" = "Changez le plat ou ignorez l'alerte";
+"violations_replace" = "Changer de plat";
+"stats_tag_by_meal_title" = "Étiquettes par type de repas";
+"stats_dishes_per_tag_title" = "Plats par étiquette";
diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings
index e487fa6..a92b3ba 100644
--- a/MealMood/Resources/it.lproj/Localizable.strings
+++ b/MealMood/Resources/it.lproj/Localizable.strings
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Già a casa";
"premium_privacy_link" = "Informativa sulla privacy";
"premium_terms_link" = "Termini di utilizzo (EULA)";
+"violation_reason_repeated" = "Questo piatto è già previsto in un altro pasto della settimana";
+"violation_reason_max_per_week" = "Massimo %1$@: %2$d a settimana, e lo hai superato";
+"violation_reason_no_consecutive" = "%@ non può ripetersi in giorni consecutivi";
+"violation_reason_no_same_day" = "%@ è già in un altro pasto di questo giorno";
+"violation_reason_meal_only" = "%1$@ è consentito solo a %2$@";
+"violation_reason_day_scope" = "%1$@ è consentito solo %2$@";
+"violation_scope_weekdays" = "nei giorni feriali";
+"violation_scope_weekend" = "nel fine settimana";
+"violation_suggestion_repeated" = "Sostituiscilo con un altro piatto per variare";
+"violation_suggestion_swap" = "Sostituiscilo con un piatto di un'altra etichetta";
+"violation_suggestion_move" = "Spostalo in un giorno o pasto consentito";
+"violation_suggestion_generic" = "Cambia il piatto o ignora l'avviso";
+"violations_replace" = "Cambia piatto";
+"stats_tag_by_meal_title" = "Etichette per tipo di pasto";
+"stats_dishes_per_tag_title" = "Piatti per etichetta";
diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings
index 156d815..ba89f85 100644
--- a/MealMood/Resources/pt-BR.lproj/Localizable.strings
+++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings
@@ -445,3 +445,18 @@
"shopping_at_home_section" = "Já em casa";
"premium_privacy_link" = "Política de privacidade";
"premium_terms_link" = "Termos de uso (EULA)";
+"violation_reason_repeated" = "Este prato já está planejado em outra refeição desta semana";
+"violation_reason_max_per_week" = "Máximo %1$@: %2$d por semana, e você já passou";
+"violation_reason_no_consecutive" = "%@ não pode se repetir em dias seguidos";
+"violation_reason_no_same_day" = "%@ já está em outra refeição deste mesmo dia";
+"violation_reason_meal_only" = "%1$@ só é permitido no %2$@";
+"violation_reason_day_scope" = "%1$@ só é permitido %2$@";
+"violation_scope_weekdays" = "durante a semana";
+"violation_scope_weekend" = "no fim de semana";
+"violation_suggestion_repeated" = "Troque por outro prato para variar a semana";
+"violation_suggestion_swap" = "Troque por um prato de outra etiqueta";
+"violation_suggestion_move" = "Mova para um dia ou refeição permitidos";
+"violation_suggestion_generic" = "Mude o prato ou ignore o aviso";
+"violations_replace" = "Trocar prato";
+"stats_tag_by_meal_title" = "Etiquetas por tipo de refeição";
+"stats_dishes_per_tag_title" = "Pratos por etiqueta";
diff --git a/MealMood/Services/AutocompleteEngine.swift b/MealMood/Services/AutocompleteEngine.swift
index b9cc113..b48a356 100644
--- a/MealMood/Services/AutocompleteEngine.swift
+++ b/MealMood/Services/AutocompleteEngine.swift
@@ -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,
diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift
index f09c21a..4e6a785 100644
--- a/MealMood/Views/Home/HomeView.swift
+++ b/MealMood/Views/Home/HomeView.swift
@@ -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)
diff --git a/MealMood/Views/Stats/StatsView.swift b/MealMood/Views/Stats/StatsView.swift
index a0d8c92..caf0f13 100644
--- a/MealMood/Views/Stats/StatsView.swift
+++ b/MealMood/Views/Stats/StatsView.swift
@@ -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 {
diff --git a/MealMoodTests/AutocompleteEngineTests.swift b/MealMoodTests/AutocompleteEngineTests.swift
index be6a914..863d8c8 100644
--- a/MealMoodTests/AutocompleteEngineTests.swift
+++ b/MealMoodTests/AutocompleteEngineTests.swift
@@ -81,3 +81,69 @@ final class AutocompleteEngineTests: XCTestCase {
XCTAssertEqual(preferred.id, duplicateWithDish.id)
}
}
+
+/// The violations panel explains *which* rule is broken; these cover the
+/// reason detection that feeds it.
+final class ViolationReasonsTests: XCTestCase {
+
+ private func makePlan(slots: [(day: Int, meal: String, dishId: UUID?)]) -> WeekPlan {
+ let plan = WeekPlan(weekStartDate: Date().startOfWeek())
+ for s in slots {
+ let slot = MealSlot(dayOfWeek: s.day, mealType: s.meal, dishId: s.dishId)
+ slot.weekPlan = plan
+ plan.slotList.append(slot)
+ }
+ return plan
+ }
+
+ func testMaxPerWeekExceededIsReported() {
+ let tag = Tag(name: "Pasta", color: "#FF0000", maxPerWeek: 1)
+ let dish = Dish(name: "Espaguetis", tagIds: [tag.id])
+ let other = Dish(name: "Macarrones", tagIds: [tag.id])
+ let plan = makePlan(slots: [(0, "dinner", dish.id), (1, "dinner", other.id)])
+ let slot = plan.slotList[0]
+
+ let reasons = AutocompleteEngine.reasons(
+ dish: dish, slot: slot, plan: plan,
+ tagMap: [tag.id: tag], dishMap: [dish.id: dish, other.id: other]
+ )
+ XCTAssertTrue(reasons.contains { $0.kind == .maxPerWeek && $0.limit == 1 },
+ "Two pasta dishes with max 1/week must report maxPerWeek")
+ }
+
+ func testMealTypeRestrictionIsReported() {
+ let tag = Tag(name: "Ligero", color: "#00FF00", mealTypeRestriction: "dinner")
+ let dish = Dish(name: "Ensalada", tagIds: [tag.id])
+ let plan = makePlan(slots: [(0, "lunch", dish.id)])
+
+ let reasons = AutocompleteEngine.reasons(
+ dish: dish, slot: plan.slotList[0], plan: plan,
+ tagMap: [tag.id: tag], dishMap: [dish.id: dish]
+ )
+ XCTAssertTrue(reasons.contains { $0.kind == .mealTypeOnly && $0.restriction == "dinner" },
+ "A dinner-only tag placed at lunch must report mealTypeOnly")
+ }
+
+ func testRepeatedDishInWeekIsReported() {
+ let dish = Dish(name: "Tortilla")
+ let plan = makePlan(slots: [(0, "dinner", dish.id), (3, "dinner", dish.id)])
+
+ let reasons = AutocompleteEngine.reasons(
+ dish: dish, slot: plan.slotList[0], plan: plan,
+ tagMap: [:], dishMap: [dish.id: dish]
+ )
+ XCTAssertTrue(reasons.contains { $0.kind == .repeatedInWeek })
+ }
+
+ func testCompliantDishHasNoReasons() {
+ let tag = Tag(name: "Pescado", color: "#0000FF", maxPerWeek: 2)
+ let dish = Dish(name: "Merluza", tagIds: [tag.id])
+ let plan = makePlan(slots: [(0, "dinner", dish.id)])
+
+ let reasons = AutocompleteEngine.reasons(
+ dish: dish, slot: plan.slotList[0], plan: plan,
+ tagMap: [tag.id: tag], dishMap: [dish.id: dish]
+ )
+ XCTAssertTrue(reasons.isEmpty, "A dish within its limits must not report violations")
+ }
+}
diff --git a/MealMoodTests/DateHelpersTests.swift b/MealMoodTests/DateHelpersTests.swift
index 0c7aaea..9cddb7b 100644
--- a/MealMoodTests/DateHelpersTests.swift
+++ b/MealMoodTests/DateHelpersTests.swift
@@ -105,3 +105,57 @@ final class DateHelpersTests: XCTestCase {
}
}
+
+/// Renders the stats screen with sample data to /tmp so the new sections can
+/// be reviewed as an image.
+final class StatsRenderTests: XCTestCase {
+
+ @MainActor
+ func testRenderStats() throws {
+ let tags = [
+ Tag(name: "Pasta", nameEN: "Pasta", color: "#E8A87C"),
+ Tag(name: "Pescado", nameEN: "Fish", color: "#7CB7E8"),
+ Tag(name: "Verdura", nameEN: "Veggie", color: "#8CD790"),
+ Tag(name: "Carne", nameEN: "Meat", color: "#E87C7C")
+ ]
+ let dishes: [Dish] = [
+ Dish(name: "Espaguetis", tagIds: [tags[0].id]),
+ Dish(name: "Lasaña", tagIds: [tags[0].id, tags[3].id]),
+ Dish(name: "Merluza", tagIds: [tags[1].id]),
+ Dish(name: "Salmón", tagIds: [tags[1].id]),
+ Dish(name: "Ensalada", tagIds: [tags[2].id]),
+ Dish(name: "Pollo asado", tagIds: [tags[3].id]),
+ Dish(name: "Crema de calabaza", tagIds: [tags[2].id])
+ ]
+ var plans: [WeekPlan] = []
+ for week in 0..<3 {
+ let plan = WeekPlan(weekStartDate: Date().startOfWeek().addingDays(-7 * week))
+ var i = week
+ for day in 0...4 {
+ for meal in ["lunch", "dinner"] {
+ let slot = MealSlot(dayOfWeek: day, mealType: meal, dishId: dishes[i % dishes.count].id)
+ slot.weekPlan = plan
+ plan.slotList.append(slot)
+ i += 1
+ }
+ }
+ plans.append(plan)
+ }
+
+ let view = StatsView(weekPlans: plans, allDishes: dishes, allTags: tags, language: .spanish)
+ // Render just the new sections: ImageRenderer can't draw a NavigationStack.
+ let content = VStack(alignment: .leading, spacing: 24) {
+ view.tagByMealSection
+ view.dishesPerTagSection
+ }
+ .padding(16)
+ .frame(width: 390)
+ .background(Color.mealMoodBackground)
+
+ let renderer = ImageRenderer(content: content)
+ renderer.scale = 2
+ let image = try XCTUnwrap(renderer.uiImage)
+ let data = try XCTUnwrap(image.pngData())
+ try data.write(to: URL(fileURLWithPath: "/tmp/stats_render.png"))
+ }
+}
diff --git a/MealMoodUITests/OnboardingFlowUITests.swift b/MealMoodUITests/OnboardingFlowUITests.swift
index 463d560..37e72af 100644
--- a/MealMoodUITests/OnboardingFlowUITests.swift
+++ b/MealMoodUITests/OnboardingFlowUITests.swift
@@ -110,3 +110,4 @@ final class NextWeekNavigationUITests: XCTestCase {
XCTAssertTrue(app.otherElements["home_root"].exists, "Home disappeared after navigating to next week")
}
}
+
diff --git a/MealMoodWatchWidget/Info.plist b/MealMoodWatchWidget/Info.plist
index 19b3d2a..f8407dd 100644
--- a/MealMoodWatchWidget/Info.plist
+++ b/MealMoodWatchWidget/Info.plist
@@ -17,7 +17,7 @@
CFBundleShortVersionString
2.1.1
CFBundleVersion
- 85
+ 86
NSExtension
NSExtensionPointIdentifier
diff --git a/MealMoodWidget/Info.plist b/MealMoodWidget/Info.plist
index 19b3d2a..f8407dd 100644
--- a/MealMoodWidget/Info.plist
+++ b/MealMoodWidget/Info.plist
@@ -17,7 +17,7 @@
CFBundleShortVersionString
2.1.1
CFBundleVersion
- 85
+ 86
NSExtension
NSExtensionPointIdentifier