From 24a6743ed22fb306320b6fb10d71d6d8b08e6a50 Mon Sep 17 00:00:00 2001 From: alexandrev-tibco Date: Thu, 10 Sep 2026 21:54:37 +0200 Subject: [PATCH] reglas: los avisos se calculan del plan actual, no de una marca guardada Un plato aparecia en la lista de incumplimientos sin explicacion (captura del usuario: "Ensalada campera"): findViolations listaba los slots con isRuleOverridden, una marca puesta al asignar que quedaba obsoleta cuando el conflicto ya se habia resuelto. Y al reves, editar una regla despues de planificar no detectaba nada. - findViolations recorre el plan y reporta solo lo que incumple AHORA, siempre con sus razones (nunca una entrada vacia) - MealSlot.isRuleIgnored: "Ignorar" pasa a ser persistente, porque si no el aviso recalculado volveria enseguida; se resetea al cambiar o quitar el plato del hueco - El triangulo del calendario usa tambien el estado real - Campo en el payload KV (opcional, retrocompatible) y en el esquema CloudKit de Development - 3 tests: marca obsoleta no se lista, regla endurecida despues se detecta, y lo ignorado no reaparece Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG --- MealMood/Models/MealSlot.swift | 8 +++- MealMood/Services/AutocompleteEngine.swift | 14 ++++-- MealMood/Services/ICloudSyncService.swift | 7 ++- MealMood/ViewModels/HomeViewModel.swift | 3 ++ MealMood/Views/Home/WeekCalendarView.swift | 7 ++- MealMoodTests/AutocompleteEngineTests.swift | 49 +++++++++++++++++++++ MealMoodWatchWidget/Info.plist | 2 +- MealMoodWidget/Info.plist | 2 +- 8 files changed, 83 insertions(+), 9 deletions(-) diff --git a/MealMood/Models/MealSlot.swift b/MealMood/Models/MealSlot.swift index 4b79a52..df2f25b 100644 --- a/MealMood/Models/MealSlot.swift +++ b/MealMood/Models/MealSlot.swift @@ -17,6 +17,10 @@ final class MealSlot { /// "Don't plan this meal": counts as resolved (like eating out) but shows /// as intentionally blank — autocomplete leaves it alone. var isSkipped: Bool = false + /// The user dismissed the rule warning for this assignment. Violations are + /// recomputed from the current plan, so without this the warning would + /// come straight back after being ignored. + var isRuleIgnored: Bool = false var weekPlan: WeekPlan? @@ -29,7 +33,8 @@ final class MealSlot { calendarEventId: String? = nil, isRuleOverridden: Bool = false, isEatingOut: Bool = false, - isSkipped: Bool = false + isSkipped: Bool = false, + isRuleIgnored: Bool = false ) { self.id = id self.dayOfWeek = dayOfWeek @@ -40,6 +45,7 @@ final class MealSlot { self.isRuleOverridden = isRuleOverridden self.isEatingOut = isEatingOut self.isSkipped = isSkipped + self.isRuleIgnored = isRuleIgnored } var mealTypeEnum: MealType { diff --git a/MealMood/Services/AutocompleteEngine.swift b/MealMood/Services/AutocompleteEngine.swift index b48a356..d7f1b94 100644 --- a/MealMood/Services/AutocompleteEngine.swift +++ b/MealMood/Services/AutocompleteEngine.swift @@ -117,18 +117,26 @@ struct AutocompleteEngine { static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] { let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + // Computed from the current plan instead of the stored + // `isRuleOverridden` flag: that flag went stale (it listed meals whose + // conflict had already been resolved, with nothing to explain) and it + // missed violations that only appear when a rule is edited after + // planning. Slots the user explicitly dismissed stay hidden. return plan.slotList.compactMap { slot in guard let dishId = slot.dishId, let dish = dishMap[dishId], - slot.isRuleOverridden, - !slot.isEatingOut else { return nil } + !slot.isEatingOut, + !slot.isSkipped, + !slot.isRuleIgnored else { return nil } + let found = reasons(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap) + guard !found.isEmpty else { return nil } return RuleViolation( slotId: slot.id, dayOfWeek: slot.dayOfWeek, mealType: slot.mealType, dishId: dishId, dishName: dish.name, - reasons: reasons(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap) + reasons: found ) } } diff --git a/MealMood/Services/ICloudSyncService.swift b/MealMood/Services/ICloudSyncService.swift index 106f42f..f5bb940 100644 --- a/MealMood/Services/ICloudSyncService.swift +++ b/MealMood/Services/ICloudSyncService.swift @@ -119,7 +119,8 @@ final class ICloudSyncService { calendarEventId: $0.calendarEventId, isRuleOverridden: $0.isRuleOverridden, isEatingOut: $0.isEatingOut, - isSkipped: $0.isSkipped + isSkipped: $0.isSkipped, + isRuleIgnored: $0.isRuleIgnored ) } ) @@ -217,7 +218,8 @@ final class ICloudSyncService { calendarEventId: slotPayload.calendarEventId, isRuleOverridden: slotPayload.isRuleOverridden, isEatingOut: slotPayload.isEatingOut ?? false, - isSkipped: slotPayload.isSkipped ?? false + isSkipped: slotPayload.isSkipped ?? false, + isRuleIgnored: slotPayload.isRuleIgnored ?? false ) slot.weekPlan = plan plan.slotList.append(slot) @@ -301,4 +303,5 @@ private struct MealSlotPayload: Codable { let isRuleOverridden: Bool var isEatingOut: Bool? var isSkipped: Bool? + var isRuleIgnored: Bool? } diff --git a/MealMood/ViewModels/HomeViewModel.swift b/MealMood/ViewModels/HomeViewModel.swift index f9bafd7..ad6de65 100644 --- a/MealMood/ViewModels/HomeViewModel.swift +++ b/MealMood/ViewModels/HomeViewModel.swift @@ -98,6 +98,7 @@ final class HomeViewModel: ObservableObject { slot.dishId = dish.id slot.secondaryDishId = nil slot.isRuleOverridden = isOverride + slot.isRuleIgnored = false plan.updatedAt = Date() applyCalendarSyncPolicy(plan: plan, settings: settings) @@ -136,6 +137,7 @@ final class HomeViewModel: ObservableObject { slot.dishId = nil slot.secondaryDishId = nil slot.isRuleOverridden = false + slot.isRuleIgnored = false plan.updatedAt = Date() applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false) AnalyticsService.logMealCleared() @@ -544,6 +546,7 @@ final class HomeViewModel: ObservableObject { func acknowledgeViolation(slot: MealSlot, plan: WeekPlan) { slot.isRuleOverridden = false + slot.isRuleIgnored = true plan.updatedAt = Date() } diff --git a/MealMood/Views/Home/WeekCalendarView.swift b/MealMood/Views/Home/WeekCalendarView.swift index 2f52567..b29b2c8 100644 --- a/MealMood/Views/Home/WeekCalendarView.swift +++ b/MealMood/Views/Home/WeekCalendarView.swift @@ -201,6 +201,11 @@ struct WeekCalendarView: View { .cornerRadius(10) } + /// Slots breaking rules right now — the stored flag could be stale. + private var violatingSlotIds: Set { + Set(AutocompleteEngine.findViolations(plan: plan, allDishes: dishes, allTags: tags).map(\.slotId)) + } + private func displayedSlot(day: Int, mealType: MealType) -> DisplaySlot? { let key = slotKey(day: day, mealType: mealType.rawValue) if let cached = displaySlotsByKey[key] { @@ -272,7 +277,7 @@ struct WeekCalendarView: View { dishDescription: dishData.descriptionText, tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) }, mealType: mealType, - showsRuleWarning: slot.isRuleOverridden, + showsRuleWarning: violatingSlotIds.contains(slot.slotId), photoData: settings.showDishPhotosInPlannerResolved ? dishData.photoData : nil, onRemove: viewModel.canEditCurrentWeek ? { guard let live = liveSlot(for: slot) else { return } diff --git a/MealMoodTests/AutocompleteEngineTests.swift b/MealMoodTests/AutocompleteEngineTests.swift index 863d8c8..6de3cfa 100644 --- a/MealMoodTests/AutocompleteEngineTests.swift +++ b/MealMoodTests/AutocompleteEngineTests.swift @@ -147,3 +147,52 @@ final class ViolationReasonsTests: XCTestCase { XCTAssertTrue(reasons.isEmpty, "A dish within its limits must not report violations") } } + +/// Violations come from the current plan, not from a stored flag. +final class ViolationFreshnessTests: XCTestCase { + + private func plan(_ slots: [MealSlot]) -> WeekPlan { + let p = WeekPlan(weekStartDate: Date().startOfWeek()) + slots.forEach { $0.weekPlan = p; p.slotList.append($0) } + return p + } + + /// The bug from the screenshot: a meal flagged long ago, whose conflict is + /// already gone, was still listed — with nothing to explain. + func testStaleFlagIsNotReported() { + let dish = Dish(name: "Ensalada campera") + let slot = MealSlot(dayOfWeek: 0, mealType: "dinner", dishId: dish.id) + slot.isRuleOverridden = true // marca antigua + let week = plan([slot]) + + let violations = AutocompleteEngine.findViolations(plan: week, allDishes: [dish], allTags: []) + XCTAssertTrue(violations.isEmpty, "A stale flag with no current conflict must not be listed") + } + + /// And the opposite: a rule tightened after planning must be caught even + /// though no flag was ever set. + func testRuleEditedAfterPlanningIsCaught() { + let tag = Tag(name: "Pasta", color: "#FF0000", maxPerWeek: 1) + let a = Dish(name: "Espaguetis", tagIds: [tag.id]) + let b = Dish(name: "Macarrones", tagIds: [tag.id]) + let week = plan([ + MealSlot(dayOfWeek: 0, mealType: "dinner", dishId: a.id), + MealSlot(dayOfWeek: 1, mealType: "dinner", dishId: b.id) + ]) + + let violations = AutocompleteEngine.findViolations(plan: week, allDishes: [a, b], allTags: [tag]) + XCTAssertEqual(violations.count, 2, "Both pasta meals exceed max 1/week") + XCTAssertTrue(violations.allSatisfy { !$0.reasons.isEmpty }, "Every listed violation must explain itself") + } + + func testIgnoredSlotStaysHidden() { + let dish = Dish(name: "Tortilla") + let s1 = MealSlot(dayOfWeek: 0, mealType: "dinner", dishId: dish.id) + let s2 = MealSlot(dayOfWeek: 3, mealType: "dinner", dishId: dish.id) + s1.isRuleIgnored = true + let week = plan([s1, s2]) + + let violations = AutocompleteEngine.findViolations(plan: week, allDishes: [dish], allTags: []) + XCTAssertEqual(violations.map(\.slotId), [s2.id], "The dismissed slot must not come back") + } +} diff --git a/MealMoodWatchWidget/Info.plist b/MealMoodWatchWidget/Info.plist index 4d14ec2..e61396b 100644 --- a/MealMoodWatchWidget/Info.plist +++ b/MealMoodWatchWidget/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString 2.1.1 CFBundleVersion - 87 + 88 NSExtension NSExtensionPointIdentifier diff --git a/MealMoodWidget/Info.plist b/MealMoodWidget/Info.plist index 4d14ec2..e61396b 100644 --- a/MealMoodWidget/Info.plist +++ b/MealMoodWidget/Info.plist @@ -17,7 +17,7 @@ CFBundleShortVersionString 2.1.1 CFBundleVersion - 87 + 88 NSExtension NSExtensionPointIdentifier