Files
FamilyMealPlanner/MealMood/Views/Stats/StatsView.swift
T
alexandrev-tibco 718eef16bf 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
2026-09-10 21:06:42 +02:00

459 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct StatsView: View {
let weekPlans: [WeekPlan]
let allDishes: [Dish]
let allTags: [Tag]
let language: AppLanguage
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
summaryGrid
topDishesSection
tagBreakdownSection
tagByMealSection
dishesPerTagSection
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
}
.background(Color.mealMoodBackground.ignoresSafeArea())
.onAppear { AnalyticsService.logStatsViewed() }
.navigationTitle("stats_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("tag_selector_done") { dismiss() }
}
}
}
}
// MARK: - Summary grid
private var summaryGrid: some View {
VStack(spacing: 12) {
HStack(spacing: 12) {
StatCard(
icon: "flame.fill",
iconColor: .mealMoodCoral,
value: "\(streak)",
label: "stats_streak_label"
)
StatCard(
icon: "calendar",
iconColor: .mealMoodMint,
value: "\(weeksWithAnyDish)",
label: "stats_weeks_planned_label"
)
}
HStack(spacing: 12) {
StatCard(
icon: "checkmark.circle.fill",
iconColor: .mealMoodSuccess,
value: "\(completeWeeks)",
label: "stats_complete_weeks_label"
)
StatCard(
icon: "chart.bar.fill",
iconColor: .mealMoodWarning,
value: completionRateText,
label: "stats_completion_rate_label"
)
}
}
}
// MARK: - Top dishes
private var topDishesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("stats_top_dishes_title")
.font(.mealMoodH3)
.foregroundColor(.mealMoodTextPrimary)
if topDishes.isEmpty {
Text("stats_no_data")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
} else {
VStack(spacing: 0) {
ForEach(Array(topDishes.enumerated()), id: \.element.dish.id) { index, entry in
TopDishRow(
rank: index + 1,
dishName: entry.dish.name,
count: entry.count,
maxCount: topDishes.first?.count ?? 1
)
if index < topDishes.count - 1 {
Divider().padding(.leading, 44)
}
}
}
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Tag breakdown
private var tagBreakdownSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("stats_tags_title")
.font(.mealMoodH3)
.foregroundColor(.mealMoodTextPrimary)
if tagUsage.isEmpty {
Text("stats_no_data")
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
} else {
VStack(spacing: 10) {
ForEach(tagUsage, id: \.tag.id) { entry in
TagBarRow(tagName: entry.tag.localizedName(language: language), tagColor: entry.tag.color, count: entry.count, maxCount: tagUsage.first?.count ?? 1)
}
}
.padding(14)
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Computed stats
private var plannedWeeks: [WeekPlan] {
weekPlans.filter { plan in plan.slotList.contains { $0.dishId != nil } }
}
private var weeksWithAnyDish: Int { plannedWeeks.count }
private var completeWeeks: Int {
weekPlans.filter { plan in
!plan.slotList.isEmpty && plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut || $0.isSkipped }
}.count
}
private var completionRateText: String {
guard weeksWithAnyDish > 0 else { return "" }
let rate = Int(Double(completeWeeks) / Double(weeksWithAnyDish) * 100)
return "\(rate)%"
}
private var streak: Int {
let sorted = plannedWeeks
.map { $0.weekStartDate.startOfWeek() }
.sorted(by: >)
guard !sorted.isEmpty else { return 0 }
let calendar = Calendar.current
var count = 1
var previous = sorted[0]
for date in sorted.dropFirst() {
let diff = calendar.dateComponents([.day], from: date, to: previous).day ?? 0
if diff <= 7 {
count += 1
previous = date
} else {
break
}
}
return count
}
private var dishUsage: [UUID: Int] {
var counts: [UUID: Int] = [:]
for plan in weekPlans {
for slot in plan.slotList {
if let id = slot.dishId { counts[id, default: 0] += 1 }
if let id = slot.secondaryDishId { counts[id, default: 0] += 1 }
}
}
return counts
}
private struct DishEntry { let dish: Dish; let count: Int }
private var topDishes: [DishEntry] {
let usage = dishUsage
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
return usage
.compactMap { id, count in dishMap[id].map { DishEntry(dish: $0, count: count) } }
.sorted { $0.count > $1.count }
.prefix(8)
.map { $0 }
}
struct TagEntry { let tag: Tag; let count: Int }
private var tagUsage: [TagEntry] {
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
var counts: [UUID: Int] = [:]
for plan in weekPlans {
for slot in plan.slotList {
for dishId in [slot.dishId, slot.secondaryDishId].compactMap({ $0 }) {
guard let dish = dishMap[dishId] else { continue }
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 }
}
}
}
return counts
.compactMap { id, count in tagMap[id].map { TagEntry(tag: $0, count: count) } }
.sorted { $0.count > $1.count }
.prefix(6)
.map { $0 }
}
}
// 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 {
let icon: String
let iconColor: Color
let value: String
let label: LocalizedStringKey
var body: some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 24))
.foregroundColor(iconColor)
Text(value)
.font(.mealMoodH2)
.foregroundColor(.mealMoodTextPrimary)
Text(label)
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(Color.mealMoodSurface)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
private struct TopDishRow: View {
let rank: Int
let dishName: String
let count: Int
let maxCount: Int
var body: some View {
HStack(spacing: 12) {
Text("\(rank)")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.frame(width: 20, alignment: .center)
VStack(alignment: .leading, spacing: 4) {
Text(dishName)
.font(.mealMoodBody)
.foregroundColor(.mealMoodTextPrimary)
.lineLimit(1)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 3)
.fill(Color.mealMoodCoral.opacity(0.25))
.frame(width: geo.size.width, height: 4)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color.mealMoodCoral)
.frame(width: geo.size.width * CGFloat(count) / CGFloat(maxCount), height: 4)
}
}
.frame(height: 4)
}
Text("\(count)×")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.frame(width: 28, alignment: .trailing)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
}
}
private struct TagBarRow: View {
let tagName: String
let tagColor: String
let count: Int
let maxCount: Int
var body: some View {
HStack(spacing: 10) {
Circle()
.fill(Color(hex: tagColor))
.frame(width: 10, height: 10)
Text(tagName)
.font(.mealMoodSmall)
.foregroundColor(.mealMoodTextPrimary)
.frame(width: 90, alignment: .leading)
.lineLimit(1)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: tagColor).opacity(0.2))
.frame(width: geo.size.width, height: 8)
.overlay(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: tagColor))
.frame(width: geo.size.width * CGFloat(count) / CGFloat(maxCount), height: 8)
}
}
.frame(height: 8)
Text("\(count)")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
.frame(width: 28, alignment: .trailing)
}
}
}