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:
@@ -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 = "<group>"; };
|
||||
FF2DE77AD57DB6DAB188CD9E /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1E8AD94FC874467D863D464C /* StatsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsView.swift; sourceTree = "<group>"; };
|
||||
A3C5F7891B2D4E6F80A2C4E6 /* FeedbackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackStore.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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?";
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user