diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index e608fa6..985770b 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ FB0011111111111111111106 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB0011111111111111111107 /* AnalyticsService.swift */; }; FB0011111111111111111108 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = FB001111111111111111110B /* FirebaseCrashlytics */; }; FB0011111111111111111109 /* CrashlyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB001111111111111111110A /* CrashlyticsService.swift */; }; + 443CC3997A9A4818B51066FB /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E8AD94FC874467D863D464C /* StatsView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -198,6 +199,7 @@ FD621CE9B44512AE8894D92E /* OnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewModel.swift; sourceTree = ""; }; FE73D5B7AF8B5E2D63105977 /* Font+MealMood.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Font+MealMood.swift"; sourceTree = ""; }; FF2DE77AD57DB6DAB188CD9E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 1E8AD94FC874467D863D464C /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -357,10 +359,19 @@ 35F15C6817E504B1114420CD /* Premium */, 4B80B7CABE3B737897455A26 /* Settings */, CE41931687CCC60F76A2B6D9 /* Tags */, + 0D63499EC79D43728D1CA8C7 /* Stats */, ); path = Views; sourceTree = ""; }; + 0D63499EC79D43728D1CA8C7 /* Stats */ = { + isa = PBXGroup; + children = ( + 1E8AD94FC874467D863D464C /* StatsView.swift */, + ); + path = Stats; + sourceTree = ""; + }; 8BE925662F55707DF083677A /* Dishes */ = { isa = PBXGroup; children = ( @@ -685,6 +696,7 @@ 233B9C1A5CA7E938318491C0 /* SocialShareSheet.swift in Sources */, 25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */, FB0011111111111111111109 /* CrashlyticsService.swift in Sources */, + 443CC3997A9A4818B51066FB /* StatsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MealMood/Resources/Info.plist b/MealMood/Resources/Info.plist index 6d1ad53..194532b 100644 --- a/MealMood/Resources/Info.plist +++ b/MealMood/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1.2 + 1.1.3 CFBundleVersion - 47 + 48 GADApplicationIdentifier ca-app-pub-1549720748100858~9985112590 ITSAppUsesNonExemptEncryption diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 06a548c..6c08bd6 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -180,7 +180,7 @@ "premium_yearly" = "Jährlich"; "premium_save" = "44 % sparen"; "premium_month" = "Monat"; -"premium_price_note" = "2,99 EUR pro Monat"; +"premium_price_note" = "Jederzeit über dein Apple-Konto kündbar."; "premium_terms_note" = "Automatisch verlängerbares Abo. Jederzeit in deinen Apple-Kontoeinstellungen kündbar."; "premium_active" = "Premium ist aktiv"; "premium_subscribe" = "Abonnieren"; @@ -324,3 +324,4 @@ "home_auto_assign_cta_button" = "Auto-ausfüllen"; "notification_sunday_title" = "Plane deine nächste Woche"; "notification_sunday_body" = "Es ist Sonntag — nimm dir 2 Minuten, um die Mahlzeiten der nächsten Woche zu planen."; + diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 97e360c..e0ce673 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -180,7 +180,7 @@ "premium_yearly" = "Yearly"; "premium_save" = "Save 44%"; "premium_month" = "month"; -"premium_price_note" = "2.99 EUR per month"; +"premium_price_note" = "Cancel anytime from your Apple account settings."; "premium_terms_note" = "Auto-renewing subscription. Cancel anytime from your Apple account settings."; "premium_active" = "Premium is active"; "premium_subscribe" = "Subscribe"; @@ -324,3 +324,4 @@ "home_auto_assign_cta_button" = "Auto-assign"; "notification_sunday_title" = "Plan your next week"; "notification_sunday_body" = "Sunday is here — take 2 minutes to plan next week's meals."; + diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index fd3fb89..7755f55 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -180,7 +180,7 @@ "premium_yearly" = "Anual"; "premium_save" = "Ahorra 44%"; "premium_month" = "mes"; -"premium_price_note" = "2,99 EUR al mes"; +"premium_price_note" = "Cancela cuando quieras desde tu cuenta de Apple."; "premium_terms_note" = "Suscripción renovable automáticamente. Cancela cuando quieras desde los ajustes de Apple."; "premium_active" = "Premium activo"; "premium_subscribe" = "Suscribirse"; @@ -324,3 +324,4 @@ "home_auto_assign_cta_button" = "Auto-rellenar"; "notification_sunday_title" = "Planifica la semana que viene"; "notification_sunday_body" = "Es domingo — tómate 2 minutos para planificar las comidas de la próxima semana."; + diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 59f81e3..a77947f 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -180,7 +180,7 @@ "premium_yearly" = "Annuel"; "premium_save" = "Économisez 44 %"; "premium_month" = "mois"; -"premium_price_note" = "2,99 EUR par mois"; +"premium_price_note" = "Annulez à tout moment depuis votre compte Apple."; "premium_terms_note" = "Abonnement à renouvellement automatique. Annulez à tout moment dans les réglages de votre compte Apple."; "premium_active" = "Premium est actif"; "premium_subscribe" = "S'abonner"; @@ -324,3 +324,4 @@ "home_auto_assign_cta_button" = "Auto-remplir"; "notification_sunday_title" = "Planifiez votre prochaine semaine"; "notification_sunday_body" = "C'est dimanche — prenez 2 minutes pour planifier vos repas de la semaine prochaine."; + diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index e7bfdf9..c6858c4 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -180,7 +180,7 @@ "premium_yearly" = "Annuale"; "premium_save" = "Risparmia il 44%"; "premium_month" = "mese"; -"premium_price_note" = "2,99 EUR al mese"; +"premium_price_note" = "Annulla quando vuoi dalle impostazioni del tuo account Apple."; "premium_terms_note" = "Abbonamento con rinnovo automatico. Annulla quando vuoi dalle impostazioni del tuo account Apple."; "premium_active" = "Premium è attivo"; "premium_subscribe" = "Abbonati"; @@ -324,3 +324,4 @@ "home_auto_assign_cta_button" = "Auto-assegna"; "notification_sunday_title" = "Pianifica la prossima settimana"; "notification_sunday_body" = "È domenica — prenditi 2 minuti per pianificare i pasti della prossima settimana."; + diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index 5a332ae..31328df 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -180,7 +180,7 @@ "premium_yearly" = "Anual"; "premium_save" = "Economize 44%"; "premium_month" = "mês"; -"premium_price_note" = "2,99 EUR por mês"; +"premium_price_note" = "Cancele quando quiser nas configurações da sua conta Apple."; "premium_terms_note" = "Assinatura com renovação automática. Cancele a qualquer momento nos ajustes da sua conta Apple."; "premium_active" = "O Premium está ativo"; "premium_subscribe" = "Assinar"; @@ -324,3 +324,4 @@ "home_auto_assign_cta_button" = "Auto-atribuir"; "notification_sunday_title" = "Planeje a próxima semana"; "notification_sunday_body" = "É domingo — reserve 2 minutos para planejar as refeições da próxima semana."; + diff --git a/MealMood/Services/AutocompleteEngine.swift b/MealMood/Services/AutocompleteEngine.swift index a425d6f..a65e78d 100644 --- a/MealMood/Services/AutocompleteEngine.swift +++ b/MealMood/Services/AutocompleteEngine.swift @@ -15,44 +15,44 @@ struct AutocompleteEngine { let dishName: String } + // recentPlans: up to 4 previous weeks (sorted newest-first) for historical scoring. static func autocomplete( emptySlots: [MealSlot], currentPlan: WeekPlan, allDishes: [Dish], - allTags: [Tag] + allTags: [Tag], + recentPlans: [WeekPlan] = [] ) -> AutocompleteResult { let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) }) let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) }) + let historyScores = historicalScores(dishes: allDishes, recentPlans: recentPlans) + var filledSlots: [(slotId: UUID, dishId: UUID)] = [] var unfilledCount = 0 + var pendingSlots = emptySlots.filter { !$0.isEatingOut } - let sortedEmpty = emptySlots - .filter { !$0.isEatingOut } - .sorted { ($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType) } + // MRV: at each step pick the slot with fewest valid candidates first. + // Assignments update currentPlan.slots in-place, so subsequent candidate + // counts automatically reflect the growing set of committed dishes. + while !pendingSlots.isEmpty { + // Score each remaining slot by strict candidate count + let ranked = pendingSlots + .map { slot -> (slot: MealSlot, strict: [Dish]) in + let strict = allDishes.filter { + !violatesRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap) + } + return (slot, strict) + } + .sorted { $0.strict.count < $1.strict.count } - for slot in sortedEmpty { - let strictCandidates = allDishes.filter { dish in - !violatesRules( - dish: dish, - slot: slot, - plan: currentPlan, - tagMap: tagMap, - dishMap: dishMap - ) - } + let (slot, strictCandidates) = ranked[0] + pendingSlots.removeAll { $0.id == slot.id } - // Fallback: if strict rules cannot fill a slot, allow repeating dishes - // that only fail the implicit "no repeat this week" rule. + // Fallback: relax the implicit no-repeat rule if strict set is empty let candidates: [Dish] if strictCandidates.isEmpty { - candidates = allDishes.filter { dish in - !violatesExplicitRules( - dish: dish, - slot: slot, - plan: currentPlan, - tagMap: tagMap, - dishMap: dishMap - ) + candidates = allDishes.filter { + !violatesExplicitRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap) } } else { candidates = strictCandidates @@ -63,7 +63,7 @@ struct AutocompleteEngine { continue } - if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan) { + if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan, historyScores: historyScores) { slot.dishId = picked.id filledSlots.append((slotId: slot.id, dishId: picked.id)) } else { @@ -103,6 +103,8 @@ struct AutocompleteEngine { return !violatesRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap) } + // MARK: - Rule checks + private static func violatesRules( dish: Dish, slot: MealSlot, @@ -110,20 +112,12 @@ struct AutocompleteEngine { tagMap: [UUID: Tag], dishMap: [UUID: Dish] ) -> Bool { - violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan, tagMap: tagMap) || + violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan) || violatesExplicitRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap) } - private static func violatesDefaultNoRepeatRule( - dish: Dish, - slot: MealSlot, - plan: WeekPlan, - tagMap: [UUID: Tag] - ) -> Bool { - // Default behavior: avoid assigning the same dish twice in the same week, - // regardless of whether it has explicit tag rules. The fallback path in - // autocomplete() still allows repeating when no alternative exists. - return plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id } + private static func violatesDefaultNoRepeatRule(dish: Dish, slot: MealSlot, plan: WeekPlan) -> Bool { + plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id } } private static func violatesExplicitRules( @@ -136,7 +130,6 @@ struct AutocompleteEngine { for tagId in dish.tagIds { guard let tag = tagMap[tagId] else { continue } - // Max per week if let maxPerWeek = tag.maxPerWeek { var count = 0 for s in plan.slots { @@ -146,7 +139,6 @@ struct AutocompleteEngine { if count >= maxPerWeek { return true } } - // No consecutive if tag.noConsecutive { for adjSlot in adjacentSlots(of: slot, in: plan) { guard let did = adjSlot.dishId, let d = dishMap[did] else { continue } @@ -154,7 +146,6 @@ struct AutocompleteEngine { } } - // No duplicate in day if tag.noDuplicateInDay { for sdSlot in plan.slots where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id { guard let did = sdSlot.dishId, let d = dishMap[did] else { continue } @@ -162,12 +153,10 @@ struct AutocompleteEngine { } } - // Meal type restriction if let restriction = tag.mealTypeRestriction, slot.mealType != restriction { return true } - // Day restriction if let dayRestriction = tag.dayRestriction { switch dayRestriction { case "weekdays" where slot.dayOfWeek > 4: return true @@ -192,16 +181,50 @@ struct AutocompleteEngine { return result } - private static func weightedRandomPick(candidates: [Dish], plan: WeekPlan) -> Dish? { + // MARK: - Historical scoring + + // Returns a recency-penalty multiplier [0.0, 1.0] per dish. + // Dishes used more recently get a lower multiplier (deprioritised). + private static func historicalScores(dishes: [Dish], recentPlans: [WeekPlan]) -> [UUID: Double] { + guard !recentPlans.isEmpty else { return [:] } + let sorted = recentPlans.sorted { $0.weekStartDate > $1.weekStartDate } + var scores: [UUID: Double] = [:] + for dish in dishes { + for (weekIndex, plan) in sorted.enumerated() { + guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue } + let multiplier: Double + switch weekIndex { + case 0: multiplier = 0.20 // used last week — strongly deprioritise + case 1: multiplier = 0.50 // 2 weeks ago + case 2: multiplier = 0.75 // 3 weeks ago + default: multiplier = 0.90 + } + scores[dish.id] = multiplier + break // only the most recent appearance matters + } + } + return scores + } + + // MARK: - Weighted pick + + private static func weightedRandomPick( + candidates: [Dish], + plan: WeekPlan, + historyScores: [UUID: Double] = [:] + ) -> Dish? { guard !candidates.isEmpty else { return nil } let weights: [Double] = candidates.map { dish in let usage = plan.slots.filter { $0.dishId == dish.id }.count let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0 + let recencyMultiplier = historyScores[dish.id] ?? 1.0 + let baseWeight: Double switch usage { - case 0: return 3.0 * priorityBoost - case 1: return 2.0 * priorityBoost - default: return 1.0 * priorityBoost + case 0: baseWeight = 3.0 + case 1: baseWeight = 2.0 + default: baseWeight = 1.0 } + return baseWeight * priorityBoost * recencyMultiplier } let total = weights.reduce(0, +) var r = Double.random(in: 0.. $1.weekStartDate } + .prefix(4) + let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut } let result = AutocompleteEngine.autocomplete( emptySlots: emptySlots, currentPlan: plan, allDishes: dishes, - allTags: tags + allTags: tags, + recentPlans: Array(recentPlans) ) plan.updatedAt = Date() diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 2aa0130..d94b7a4 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -14,6 +14,7 @@ struct HomeView: View { @State private var editingDish: Dish? @State private var showPremiumFromExport: Bool = false @State private var showMonthlyHistory: Bool = false + @State private var showStats: Bool = false @State private var showWeekLimitUpsell: Bool = false @State private var wasWeekComplete: Bool = false @State private var selectedEmptySlotId: UUID? @@ -62,6 +63,12 @@ struct HomeView: View { } if settings?.isPremium == true { + Button { + showStats = true + } label: { + Image(systemName: "chart.bar.xaxis") + .foregroundColor(.mealMoodTextPrimary) + } Button { showMonthlyHistory = true } label: { @@ -121,7 +128,7 @@ struct HomeView: View { let plan = fetchWeekPlan(for: viewModel.currentWeekStart) { if viewModel.canEditCurrentWeek { Button { - viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings) + viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans) } label: { Image(systemName: "wand.and.stars") .foregroundColor(.mealMoodTextPrimary) @@ -287,7 +294,7 @@ struct HomeView: View { } .alert("onboarding_auto_assign_title", isPresented: $showPostOnboardingAutoAssignPrompt) { Button("onboarding_auto_assign_yes") { - viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings) + viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans) UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoAssignPromptKey) schedulePostOnboardingPremiumPromptIfNeeded(settings: settings) } @@ -495,6 +502,9 @@ struct HomeView: View { showMonthlyHistory = false } } + .sheet(isPresented: $showStats) { + StatsView(weekPlans: weekPlans, allDishes: dishes, allTags: tags, language: settings.languageEnum.resolved()) + } .task { await NotificationService.shared.requestPermissionIfNeeded() let nextPlan = fetchWeekPlan(for: Date().startOfWeek().addingDays(7)) @@ -717,7 +727,7 @@ struct HomeView: View { } Spacer() Button { - viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings) + viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans) } label: { HStack(spacing: 6) { Image(systemName: "wand.and.stars") diff --git a/MealMood/Views/Stats/StatsView.swift b/MealMood/Views/Stats/StatsView.swift new file mode 100644 index 0000000..3aad906 --- /dev/null +++ b/MealMood/Views/Stats/StatsView.swift @@ -0,0 +1,317 @@ +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 + } + .padding(.horizontal, 16) + .padding(.vertical, 20) + } + .background(Color.mealMoodBackground.ignoresSafeArea()) + .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.slots.contains { $0.dishId != nil } } + } + + private var weeksWithAnyDish: Int { plannedWeeks.count } + + private var completeWeeks: Int { + weekPlans.filter { plan in + !plan.slots.isEmpty && plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut } + }.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.slots { + if let id = slot.dishId { 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(uniqueKeysWithValues: allDishes.map { ($0.id, $0) }) + return usage + .compactMap { id, count in dishMap[id].map { DishEntry(dish: $0, count: count) } } + .sorted { $0.count > $1.count } + .prefix(8) + .map { $0 } + } + + private struct TagEntry { let tag: Tag; let count: Int } + + private var tagUsage: [TagEntry] { + let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) }) + let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) }) + var counts: [UUID: Int] = [:] + for plan in weekPlans { + for slot in plan.slots { + guard let dishId = slot.dishId, 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 } + } +} + +// 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) + } + } +}