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