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