Autocomplete feedback loop: implicit rejection + weekly rating

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 <noreply@anthropic.com>
This commit is contained in:
alexandrev-tibco
2026-05-27 18:09:15 +02:00
parent e13771fa06
commit 84b74f9ea4
12 changed files with 195 additions and 6 deletions
+2
View File
@@ -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
}
}
@@ -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?";
@@ -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?";
@@ -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?";
@@ -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 ?";
@@ -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?";
@@ -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?";
+33 -5
View File
@@ -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..<total)
+28
View File
@@ -0,0 +1,28 @@
import Foundation
struct FeedbackStore {
private static let rejectionKey = "com.mealmood.dishRejections"
static func rejectionCounts() -> [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)
}
}
}
+16 -1
View File
@@ -16,6 +16,7 @@ final class HomeViewModel: ObservableObject {
}
@Published var currentWeekStart: Date
private var autoAssignedSlotIds: Set<UUID> = []
@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 {
+34
View File
@@ -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,