From 84b74f9ea4156955ecd09af22c94cce37d94b159 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Wed, 27 May 2026 18:09:15 +0200 Subject: [PATCH] Autocomplete feedback loop: implicit rejection + weekly rating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A) Implicit: HomeViewModel tracks auto-assigned slot IDs after each autocomplete run. When the user manually replaces one of those dishes, the rejected dish ID is recorded in FeedbackStore (UserDefaults). AutocompleteEngine applies a 25% penalty per rejection (floor 0.30). B) Explicit: WeekPlan gains userRating (0/1/-1). Past weeks with dishes show a compact 👍/👎 row (Premium only). Liked weeks boost their dishes (+0.20 each, max +0.50); disliked weeks penalise them (-0.25 each, max -0.50). Signal is intentionally modest so the period cycle score remains dominant. Also adds Stats + week-rating localization strings to all 6 languages (were missing from the previous commit). Co-Authored-By: Claude Sonnet 4.6 --- MealMood.xcodeproj/project.pbxproj | 4 ++ MealMood/Models/WeekPlan.swift | 2 + .../Resources/de.lproj/Localizable.strings | 13 +++++++ .../Resources/en.lproj/Localizable.strings | 13 +++++++ .../Resources/es.lproj/Localizable.strings | 13 +++++++ .../Resources/fr.lproj/Localizable.strings | 13 +++++++ .../Resources/it.lproj/Localizable.strings | 13 +++++++ .../Resources/pt-BR.lproj/Localizable.strings | 13 +++++++ MealMood/Services/AutocompleteEngine.swift | 38 ++++++++++++++++--- MealMood/Services/FeedbackStore.swift | 28 ++++++++++++++ MealMood/ViewModels/HomeViewModel.swift | 17 ++++++++- MealMood/Views/Home/HomeView.swift | 34 +++++++++++++++++ 12 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 MealMood/Services/FeedbackStore.swift diff --git a/MealMood.xcodeproj/project.pbxproj b/MealMood.xcodeproj/project.pbxproj index 985770b..066a000 100644 --- a/MealMood.xcodeproj/project.pbxproj +++ b/MealMood.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ 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 */; }; + B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -200,6 +201,7 @@ 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 = ""; }; + A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -487,6 +489,7 @@ 103374A7D91B4F884278DA72 /* StoreManager.swift */, A6FC2153490D53B80DBDC034 /* WidgetDataStore.swift */, 928E6D4F6D78AD1840245FB4 /* PremiumSyncService.swift */, + A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */, ); path = Services; sourceTree = ""; @@ -697,6 +700,7 @@ 25B3C328E088A403CABAF20A /* PremiumSyncService.swift in Sources */, FB0011111111111111111109 /* CrashlyticsService.swift in Sources */, 443CC3997A9A4818B51066FB /* StatsView.swift in Sources */, + B4D6F8012C3E5F7A91B3D5F7 /* FeedbackStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MealMood/Models/WeekPlan.swift b/MealMood/Models/WeekPlan.swift index ebd26ef..2d2b794 100644 --- a/MealMood/Models/WeekPlan.swift +++ b/MealMood/Models/WeekPlan.swift @@ -7,6 +7,7 @@ final class WeekPlan { var weekStartDate: Date var createdAt: Date var updatedAt: Date + var userRating: Int = 0 // 0 = unrated, 1 = liked, -1 = disliked @Relationship(deleteRule: .cascade) var slots: [MealSlot] @@ -23,5 +24,6 @@ final class WeekPlan { self.slots = slots self.createdAt = createdAt self.updatedAt = updatedAt + self.userRating = 0 } } diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index 6c08bd6..33977e7 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -325,3 +325,16 @@ "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."; +/* Stats */ +"stats_title" = "Statistiken"; +"stats_streak_label" = "Wochen-Serie"; +"stats_weeks_planned_label" = "Geplante Wochen"; +"stats_complete_weeks_label" = "Vollständige Wochen"; +"stats_completion_rate_label" = "Abschlussrate"; +"stats_top_dishes_title" = "Meistgenutzte Gerichte"; +"stats_tags_title" = "Nach Kategorie"; +"stats_no_data" = "Noch keine Daten"; + +/* Week rating */ +"week_rating_prompt" = "Wie war diese Woche?"; + diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index e0ce673..c2e0599 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -325,3 +325,16 @@ "notification_sunday_title" = "Plan your next week"; "notification_sunday_body" = "Sunday is here — take 2 minutes to plan next week's meals."; +/* Stats */ +"stats_title" = "Statistics"; +"stats_streak_label" = "Week streak"; +"stats_weeks_planned_label" = "Weeks planned"; +"stats_complete_weeks_label" = "Complete weeks"; +"stats_completion_rate_label" = "Completion rate"; +"stats_top_dishes_title" = "Top dishes"; +"stats_tags_title" = "By category"; +"stats_no_data" = "No data yet"; + +/* Week rating */ +"week_rating_prompt" = "How was this week?"; + diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 7755f55..966dbcb 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -325,3 +325,16 @@ "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."; +/* Stats */ +"stats_title" = "Estadísticas"; +"stats_streak_label" = "Racha de semanas"; +"stats_weeks_planned_label" = "Semanas planificadas"; +"stats_complete_weeks_label" = "Semanas completas"; +"stats_completion_rate_label" = "Tasa de completado"; +"stats_top_dishes_title" = "Platos más usados"; +"stats_tags_title" = "Por categoría"; +"stats_no_data" = "Sin datos todavía"; + +/* Week rating */ +"week_rating_prompt" = "¿Qué tal fue la semana?"; + diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index a77947f..839b200 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -325,3 +325,16 @@ "notification_sunday_title" = "Planifiez votre prochaine semaine"; "notification_sunday_body" = "C'est dimanche — prenez 2 minutes pour planifier vos repas de la semaine prochaine."; +/* Stats */ +"stats_title" = "Statistiques"; +"stats_streak_label" = "Série de semaines"; +"stats_weeks_planned_label" = "Semaines planifiées"; +"stats_complete_weeks_label" = "Semaines complètes"; +"stats_completion_rate_label" = "Taux de complétion"; +"stats_top_dishes_title" = "Plats les plus utilisés"; +"stats_tags_title" = "Par catégorie"; +"stats_no_data" = "Pas encore de données"; + +/* Week rating */ +"week_rating_prompt" = "Comment s'est passée cette semaine ?"; + diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index c6858c4..fc295a3 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -325,3 +325,16 @@ "notification_sunday_title" = "Pianifica la prossima settimana"; "notification_sunday_body" = "È domenica — prenditi 2 minuti per pianificare i pasti della prossima settimana."; +/* Stats */ +"stats_title" = "Statistiche"; +"stats_streak_label" = "Serie di settimane"; +"stats_weeks_planned_label" = "Settimane pianificate"; +"stats_complete_weeks_label" = "Settimane complete"; +"stats_completion_rate_label" = "Tasso di completamento"; +"stats_top_dishes_title" = "Piatti più utilizzati"; +"stats_tags_title" = "Per categoria"; +"stats_no_data" = "Nessun dato ancora"; + +/* Week rating */ +"week_rating_prompt" = "Com'è andata questa settimana?"; + diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index 31328df..a767e7b 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -325,3 +325,16 @@ "notification_sunday_title" = "Planeje a próxima semana"; "notification_sunday_body" = "É domingo — reserve 2 minutos para planejar as refeições da próxima semana."; +/* Stats */ +"stats_title" = "Estatísticas"; +"stats_streak_label" = "Sequência de semanas"; +"stats_weeks_planned_label" = "Semanas planejadas"; +"stats_complete_weeks_label" = "Semanas completas"; +"stats_completion_rate_label" = "Taxa de conclusão"; +"stats_top_dishes_title" = "Pratos mais usados"; +"stats_tags_title" = "Por categoria"; +"stats_no_data" = "Sem dados ainda"; + +/* Week rating */ +"week_rating_prompt" = "Como foi esta semana?"; + diff --git a/MealMood/Services/AutocompleteEngine.swift b/MealMood/Services/AutocompleteEngine.swift index 2c8b149..108eb3f 100644 --- a/MealMood/Services/AutocompleteEngine.swift +++ b/MealMood/Services/AutocompleteEngine.swift @@ -21,11 +21,13 @@ struct AutocompleteEngine { currentPlan: WeekPlan, allDishes: [Dish], allTags: [Tag], - recentPlans: [WeekPlan] = [] + recentPlans: [WeekPlan] = [], + rejectionCounts: [UUID: Int] = [:] ) -> AutocompleteResult { let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) }) let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) }) let historyScores = periodScores(dishes: allDishes, recentPlans: recentPlans, currentWeekStart: currentPlan.weekStartDate) + let ratingScores = Self.ratingScores(dishes: allDishes, recentPlans: recentPlans) var filledSlots: [(slotId: UUID, dishId: UUID)] = [] var unfilledCount = 0 @@ -63,7 +65,7 @@ struct AutocompleteEngine { continue } - if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan, historyScores: historyScores) { + if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan, historyScores: historyScores, ratingScores: ratingScores, rejectionCounts: rejectionCounts) { slot.dishId = picked.id filledSlots.append((slotId: slot.id, dishId: picked.id)) } else { @@ -248,25 +250,51 @@ struct AutocompleteEngine { return n % 2 == 1 ? Double(s[n / 2]) : Double(s[n / 2 - 1] + s[n / 2]) / 2.0 } + // MARK: - Rating-based scoring + + // Boost dishes that appeared in liked weeks; penalise those from disliked weeks. + // Signal is intentionally modest (±0.5 max) so period scoring dominates. + private static func ratingScores(dishes: [Dish], recentPlans: [WeekPlan]) -> [UUID: Double] { + let ratedPlans = recentPlans.filter { $0.userRating != 0 } + guard !ratedPlans.isEmpty else { return [:] } + var scores: [UUID: Double] = [:] + for dish in dishes { + var liked = 0, disliked = 0 + for plan in ratedPlans { + guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue } + if plan.userRating > 0 { liked += 1 } else { disliked += 1 } + } + guard liked + disliked > 0 else { continue } + let boost = min(0.50, Double(liked) * 0.20) + let penalty = min(0.50, Double(disliked) * 0.25) + scores[dish.id] = max(0.50, 1.0 + boost - penalty) + } + return scores + } + // MARK: - Weighted pick private static func weightedRandomPick( candidates: [Dish], plan: WeekPlan, - historyScores: [UUID: Double] = [:] + historyScores: [UUID: Double] = [:], + ratingScores: [UUID: Double] = [:], + rejectionCounts: [UUID: Int] = [:] ) -> 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 patternScore = historyScores[dish.id] ?? 1.0 + let ratingScore = ratingScores[dish.id] ?? 1.0 + let rejectionScore = rejectionCounts[dish.id].map { max(0.30, 1.0 - 0.25 * Double($0)) } ?? 1.0 let baseWeight: Double switch usage { case 0: baseWeight = 3.0 case 1: baseWeight = 2.0 default: baseWeight = 1.0 } - return baseWeight * priorityBoost * recencyMultiplier + return baseWeight * priorityBoost * patternScore * ratingScore * rejectionScore } let total = weights.reduce(0, +) var r = Double.random(in: 0.. [UUID: Int] { + guard let data = UserDefaults.standard.data(forKey: rejectionKey), + let raw = try? JSONDecoder().decode([String: Int].self, from: data) + else { return [:] } + return Dictionary(uniqueKeysWithValues: raw.compactMap { k, v in + UUID(uuidString: k).map { ($0, v) } + }) + } + + static func recordRejection(for dishId: UUID) { + var raw: [String: Int] + if let data = UserDefaults.standard.data(forKey: rejectionKey), + let decoded = try? JSONDecoder().decode([String: Int].self, from: data) { + raw = decoded + } else { + raw = [:] + } + raw[dishId.uuidString, default: 0] += 1 + if let encoded = try? JSONEncoder().encode(raw) { + UserDefaults.standard.set(encoded, forKey: rejectionKey) + } + } +} diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index 19c2e9a..04767b7 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -16,6 +16,7 @@ final class HomeViewModel: ObservableObject { } @Published var currentWeekStart: Date + private var autoAssignedSlotIds: Set = [] @Published var isAutoCompleting: Bool = false @Published var showConfetti: Bool = false @Published var showToast: Bool = false @@ -80,6 +81,11 @@ final class HomeViewModel: ObservableObject { } func assignDish(_ dish: Dish, to slot: MealSlot, plan: WeekPlan, settings: AppSettings, isOverride: Bool = false) { + if autoAssignedSlotIds.contains(slot.id), + let previous = slot.dishId, previous != dish.id { + FeedbackStore.recordRejection(for: previous) + autoAssignedSlotIds.remove(slot.id) + } captureUndoSnapshot(plan: plan) if let oldEventId = slot.calendarEventId { @@ -128,8 +134,10 @@ final class HomeViewModel: ObservableObject { currentPlan: plan, allDishes: dishes, allTags: tags, - recentPlans: Array(recentPlans) + recentPlans: Array(recentPlans), + rejectionCounts: FeedbackStore.rejectionCounts() ) + autoAssignedSlotIds = Set(result.filledSlots.map(\.slotId)) plan.updatedAt = Date() applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false) @@ -157,7 +165,14 @@ final class HomeViewModel: ObservableObject { } } + func rateWeek(plan: WeekPlan, rating: Int) { + plan.userRating = rating + plan.updatedAt = Date() + HapticManager.shared.impact(style: .light) + } + func resetWeek(plan: WeekPlan, settings: AppSettings) { + autoAssignedSlotIds = [] captureUndoSnapshot(plan: plan) for slot in plan.slots { diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index d94b7a4..cf1cc06 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -244,6 +244,10 @@ struct HomeView: View { exportCallout(plan: plan, settings: settings) } + if !viewModel.canEditCurrentWeek && filledCount > 0 && settings.isPremium { + weekRatingRow(plan: plan) + } + let emptyCount = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }.count if viewModel.canEditCurrentWeek && !dishes.isEmpty && emptyCount > 0 { autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings) @@ -635,6 +639,36 @@ struct HomeView: View { } } + private func weekRatingRow(plan: WeekPlan) -> some View { + HStack(spacing: 12) { + Text("week_rating_prompt") + .font(.mealMoodSmall) + .foregroundColor(.mealMoodTextSecondary) + + Spacer() + + Button { + viewModel.rateWeek(plan: plan, rating: plan.userRating == 1 ? 0 : 1) + } label: { + Image(systemName: plan.userRating == 1 ? "hand.thumbsup.fill" : "hand.thumbsup") + .font(.system(size: 17)) + .foregroundColor(plan.userRating == 1 ? .mealMoodSuccess : .mealMoodTextSecondary) + } + + Button { + viewModel.rateWeek(plan: plan, rating: plan.userRating == -1 ? 0 : -1) + } label: { + Image(systemName: plan.userRating == -1 ? "hand.thumbsdown.fill" : "hand.thumbsdown") + .font(.system(size: 17)) + .foregroundColor(plan.userRating == -1 ? .mealMoodCoral : .mealMoodTextSecondary) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.mealMoodSurface) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + private func dishDrawer(plan: WeekPlan, settings: AppSettings) -> some View { DishDrawerView( dishes: dishes,