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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
This commit is contained in:
alexandrev-tibco
2026-09-10 21:54:37 +02:00
parent 7b340be305
commit 24a6743ed2
8 changed files with 83 additions and 9 deletions
+7 -1
View File
@@ -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 {
+11 -3
View File
@@ -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
)
}
}
+5 -2
View File
@@ -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?
}
+3
View File
@@ -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()
}
+6 -1
View File
@@ -201,6 +201,11 @@ struct WeekCalendarView: View {
.cornerRadius(10)
}
/// Slots breaking rules right now the stored flag could be stale.
private var violatingSlotIds: Set<UUID> {
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 }
@@ -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")
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
<key>CFBundleShortVersionString</key>
<string>2.1.1</string>
<key>CFBundleVersion</key>
<string>87</string>
<string>88</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
+1 -1
View File
@@ -17,7 +17,7 @@
<key>CFBundleShortVersionString</key>
<string>2.1.1</string>
<key>CFBundleVersion</key>
<string>87</string>
<string>88</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>