718eef16bf
- 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
459 lines
16 KiB
Swift
459 lines
16 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|