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:
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,3 +110,4 @@ final class NextWeekNavigationUITests: XCTestCase {
|
||||
XCTAssertTrue(app.otherElements["home_root"].exists, "Home disappeared after navigating to next week")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.1.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>85</string>
|
||||
<string>86</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.1.1</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>85</string>
|
||||
<string>86</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
Reference in New Issue
Block a user