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:
@@ -17,6 +17,10 @@ final class MealSlot {
|
|||||||
/// "Don't plan this meal": counts as resolved (like eating out) but shows
|
/// "Don't plan this meal": counts as resolved (like eating out) but shows
|
||||||
/// as intentionally blank — autocomplete leaves it alone.
|
/// as intentionally blank — autocomplete leaves it alone.
|
||||||
var isSkipped: Bool = false
|
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?
|
var weekPlan: WeekPlan?
|
||||||
|
|
||||||
@@ -29,7 +33,8 @@ final class MealSlot {
|
|||||||
calendarEventId: String? = nil,
|
calendarEventId: String? = nil,
|
||||||
isRuleOverridden: Bool = false,
|
isRuleOverridden: Bool = false,
|
||||||
isEatingOut: Bool = false,
|
isEatingOut: Bool = false,
|
||||||
isSkipped: Bool = false
|
isSkipped: Bool = false,
|
||||||
|
isRuleIgnored: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.dayOfWeek = dayOfWeek
|
self.dayOfWeek = dayOfWeek
|
||||||
@@ -40,6 +45,7 @@ final class MealSlot {
|
|||||||
self.isRuleOverridden = isRuleOverridden
|
self.isRuleOverridden = isRuleOverridden
|
||||||
self.isEatingOut = isEatingOut
|
self.isEatingOut = isEatingOut
|
||||||
self.isSkipped = isSkipped
|
self.isSkipped = isSkipped
|
||||||
|
self.isRuleIgnored = isRuleIgnored
|
||||||
}
|
}
|
||||||
|
|
||||||
var mealTypeEnum: MealType {
|
var mealTypeEnum: MealType {
|
||||||
|
|||||||
@@ -117,18 +117,26 @@ struct AutocompleteEngine {
|
|||||||
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
|
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
|
||||||
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
|
||||||
let tagMap = Dictionary(allTags.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
|
return plan.slotList.compactMap { slot in
|
||||||
guard let dishId = slot.dishId,
|
guard let dishId = slot.dishId,
|
||||||
let dish = dishMap[dishId],
|
let dish = dishMap[dishId],
|
||||||
slot.isRuleOverridden,
|
!slot.isEatingOut,
|
||||||
!slot.isEatingOut else { return nil }
|
!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(
|
return RuleViolation(
|
||||||
slotId: slot.id,
|
slotId: slot.id,
|
||||||
dayOfWeek: slot.dayOfWeek,
|
dayOfWeek: slot.dayOfWeek,
|
||||||
mealType: slot.mealType,
|
mealType: slot.mealType,
|
||||||
dishId: dishId,
|
dishId: dishId,
|
||||||
dishName: dish.name,
|
dishName: dish.name,
|
||||||
reasons: reasons(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
|
reasons: found
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ final class ICloudSyncService {
|
|||||||
calendarEventId: $0.calendarEventId,
|
calendarEventId: $0.calendarEventId,
|
||||||
isRuleOverridden: $0.isRuleOverridden,
|
isRuleOverridden: $0.isRuleOverridden,
|
||||||
isEatingOut: $0.isEatingOut,
|
isEatingOut: $0.isEatingOut,
|
||||||
isSkipped: $0.isSkipped
|
isSkipped: $0.isSkipped,
|
||||||
|
isRuleIgnored: $0.isRuleIgnored
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -217,7 +218,8 @@ final class ICloudSyncService {
|
|||||||
calendarEventId: slotPayload.calendarEventId,
|
calendarEventId: slotPayload.calendarEventId,
|
||||||
isRuleOverridden: slotPayload.isRuleOverridden,
|
isRuleOverridden: slotPayload.isRuleOverridden,
|
||||||
isEatingOut: slotPayload.isEatingOut ?? false,
|
isEatingOut: slotPayload.isEatingOut ?? false,
|
||||||
isSkipped: slotPayload.isSkipped ?? false
|
isSkipped: slotPayload.isSkipped ?? false,
|
||||||
|
isRuleIgnored: slotPayload.isRuleIgnored ?? false
|
||||||
)
|
)
|
||||||
slot.weekPlan = plan
|
slot.weekPlan = plan
|
||||||
plan.slotList.append(slot)
|
plan.slotList.append(slot)
|
||||||
@@ -301,4 +303,5 @@ private struct MealSlotPayload: Codable {
|
|||||||
let isRuleOverridden: Bool
|
let isRuleOverridden: Bool
|
||||||
var isEatingOut: Bool?
|
var isEatingOut: Bool?
|
||||||
var isSkipped: Bool?
|
var isSkipped: Bool?
|
||||||
|
var isRuleIgnored: Bool?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
slot.dishId = dish.id
|
slot.dishId = dish.id
|
||||||
slot.secondaryDishId = nil
|
slot.secondaryDishId = nil
|
||||||
slot.isRuleOverridden = isOverride
|
slot.isRuleOverridden = isOverride
|
||||||
|
slot.isRuleIgnored = false
|
||||||
plan.updatedAt = Date()
|
plan.updatedAt = Date()
|
||||||
|
|
||||||
applyCalendarSyncPolicy(plan: plan, settings: settings)
|
applyCalendarSyncPolicy(plan: plan, settings: settings)
|
||||||
@@ -136,6 +137,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
slot.dishId = nil
|
slot.dishId = nil
|
||||||
slot.secondaryDishId = nil
|
slot.secondaryDishId = nil
|
||||||
slot.isRuleOverridden = false
|
slot.isRuleOverridden = false
|
||||||
|
slot.isRuleIgnored = false
|
||||||
plan.updatedAt = Date()
|
plan.updatedAt = Date()
|
||||||
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
|
applyCalendarSyncPolicy(plan: plan, settings: settings, shouldNotify: false)
|
||||||
AnalyticsService.logMealCleared()
|
AnalyticsService.logMealCleared()
|
||||||
@@ -544,6 +546,7 @@ final class HomeViewModel: ObservableObject {
|
|||||||
|
|
||||||
func acknowledgeViolation(slot: MealSlot, plan: WeekPlan) {
|
func acknowledgeViolation(slot: MealSlot, plan: WeekPlan) {
|
||||||
slot.isRuleOverridden = false
|
slot.isRuleOverridden = false
|
||||||
|
slot.isRuleIgnored = true
|
||||||
plan.updatedAt = Date()
|
plan.updatedAt = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,6 +201,11 @@ struct WeekCalendarView: View {
|
|||||||
.cornerRadius(10)
|
.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? {
|
private func displayedSlot(day: Int, mealType: MealType) -> DisplaySlot? {
|
||||||
let key = slotKey(day: day, mealType: mealType.rawValue)
|
let key = slotKey(day: day, mealType: mealType.rawValue)
|
||||||
if let cached = displaySlotsByKey[key] {
|
if let cached = displaySlotsByKey[key] {
|
||||||
@@ -272,7 +277,7 @@ struct WeekCalendarView: View {
|
|||||||
dishDescription: dishData.descriptionText,
|
dishDescription: dishData.descriptionText,
|
||||||
tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) },
|
tags: dishTags.map { (name: $0.localizedName(language: settings.languageEnum.resolved()), color: $0.color) },
|
||||||
mealType: mealType,
|
mealType: mealType,
|
||||||
showsRuleWarning: slot.isRuleOverridden,
|
showsRuleWarning: violatingSlotIds.contains(slot.slotId),
|
||||||
photoData: settings.showDishPhotosInPlannerResolved ? dishData.photoData : nil,
|
photoData: settings.showDishPhotosInPlannerResolved ? dishData.photoData : nil,
|
||||||
onRemove: viewModel.canEditCurrentWeek ? {
|
onRemove: viewModel.canEditCurrentWeek ? {
|
||||||
guard let live = liveSlot(for: slot) else { return }
|
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")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.1.1</string>
|
<string>2.1.1</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>87</string>
|
<string>88</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.1.1</string>
|
<string>2.1.1</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>87</string>
|
<string>88</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
Reference in New Issue
Block a user