2.0: CloudKit private-database sync replaces KV snapshot sync
Foundation for 2.1 family sharing (CKShare needs these models + container). SwiftData cannot share across Apple IDs today, so 2.0 ships true multi-device sync for the same account instead: - Models made CloudKit-compatible: dropped @Attribute(.unique) on all 5, inline defaults on every attribute, WeekPlan.slots stored as optional relationship (name preserved → lightweight migration) with non-optional slotList facade; ~73 call sites renamed. - Container: cloudKitDatabase .automatic, falling back to the local-only store when CloudKit is unavailable; failures recorded to Crashlytics. - Entitlements: iCloud CloudKit service (container already existed); remote-notification background mode for push-driven sync. - Legacy KV snapshot sync (ICloudSyncService) stays inert when CloudKit is active — kept only for 1.x devices. - DeduplicationService collapses cross-device duplicates deterministically on launch (settings singleton, default tags with tagId remapping, same-week plans). - Note: AppSettings.isPremium now syncs across same-Apple-ID devices; StoreKit (PremiumSyncService + Transaction.updates) remains the source of truth and reconciles on every launch/foreground. All 21 unit tests pass, including ICloudSyncPremiumIsolationTests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
This commit is contained in:
@@ -91,6 +91,11 @@ struct ContentView: View {
|
||||
// MARK: - Private
|
||||
|
||||
private func initializeAppIfNeeded() {
|
||||
// CloudKit mirroring can duplicate singletons/default data created on
|
||||
// other devices — collapse them before anything reads the store.
|
||||
if CloudSyncRuntime.isCloudKitActive {
|
||||
DeduplicationService.run(context: context)
|
||||
}
|
||||
if allSettings.isEmpty {
|
||||
DefaultDataService.createDefaultSettings(context: context)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ import FirebaseCrashlytics
|
||||
import GoogleMobileAds
|
||||
#endif
|
||||
|
||||
/// Whether the store is syncing through CloudKit this launch. When true, the
|
||||
/// legacy iCloud KV snapshot sync must stay inert (both would fight).
|
||||
enum CloudSyncRuntime {
|
||||
// Written exactly once during app start (before any concurrency), then
|
||||
// read-only for the rest of the launch.
|
||||
nonisolated(unsafe) static var isCloudKitActive = false
|
||||
}
|
||||
|
||||
@main
|
||||
struct MealMoodApp: App {
|
||||
private let modelContainer: ModelContainer = {
|
||||
@@ -17,6 +25,20 @@ struct MealMoodApp: App {
|
||||
MealSlot.self,
|
||||
ShoppingItem.self
|
||||
])
|
||||
|
||||
// 2.0: CloudKit private-database sync (multi-device, same Apple ID).
|
||||
// Falls back to the local-only store when CloudKit isn't available
|
||||
// (signed-out iCloud, missing entitlement in dev builds, etc.).
|
||||
let cloudConfiguration = ModelConfiguration(cloudKitDatabase: .automatic)
|
||||
do {
|
||||
let container = try ModelContainer(for: schema, configurations: [cloudConfiguration])
|
||||
CloudSyncRuntime.isCloudKitActive = true
|
||||
return container
|
||||
} catch {
|
||||
CrashlyticsService.record(error, context: "cloudkit_container")
|
||||
print("CloudKit container unavailable, using local store: \(error)")
|
||||
}
|
||||
|
||||
let configuration = ModelConfiguration(cloudKitDatabase: .none)
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [configuration])
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
||||
@Model
|
||||
final class AppSettings {
|
||||
var mealWindows: String // "dinnerOnly", "lunchOnly", "both"
|
||||
var mealWindows: String = "dinnerOnly" // legacy: "dinnerOnly", "lunchOnly", "both"
|
||||
var includeWeekends: Bool = true
|
||||
var language: String // "spanish", "english"
|
||||
var language: String = "system"
|
||||
|
||||
/// Comma-separated MealType raw values, in chronological order (2.0).
|
||||
/// nil = derive from the legacy `mealWindows` field (pre-2.0 installs).
|
||||
@@ -14,12 +15,12 @@ final class AppSettings {
|
||||
var calendarId: String?
|
||||
var syncEnabled: Bool = false
|
||||
var syncMode: String? // "weekComplete", "manual"
|
||||
var lunchTime: Date
|
||||
var dinnerTime: Date
|
||||
var lunchTime: Date = AppSettings.defaultTime(hour: 14)
|
||||
var dinnerTime: Date = AppSettings.defaultTime(hour: 21)
|
||||
var breakfastTime: Date? // optional: pre-2.0 stores lack it
|
||||
var snackTime: Date?
|
||||
var eventDuration: Int
|
||||
var eventPrefix: String
|
||||
var eventDuration: Int = 60
|
||||
var eventPrefix: String = "🍽️"
|
||||
var reminderMinutesBefore: Int?
|
||||
var iCloudSyncEnabled: Bool?
|
||||
var weekExportStyle: String?
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
||||
@Model
|
||||
final class Dish {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
var id: UUID = UUID()
|
||||
var name: String = ""
|
||||
var descriptionText: String?
|
||||
var tagIds: [UUID]
|
||||
var createdAt: Date
|
||||
var tagIds: [UUID] = []
|
||||
var createdAt: Date = Date()
|
||||
var isPriority: Bool = false
|
||||
|
||||
/// Optional ingredient lines (one per entry, e.g. "200g spaghetti").
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
||||
@Model
|
||||
final class MealSlot {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var dayOfWeek: Int // 0=Monday, 6=Sunday
|
||||
var mealType: String // "lunch", "dinner"
|
||||
var id: UUID = UUID()
|
||||
var dayOfWeek: Int = 0 // 0=Monday, 6=Sunday
|
||||
var mealType: String = MealType.dinner.rawValue
|
||||
var dishId: UUID?
|
||||
var calendarEventId: String?
|
||||
var isRuleOverridden: Bool = false
|
||||
|
||||
@@ -4,15 +4,16 @@ import SwiftData
|
||||
/// One line of the weekly shopping list. Items are either derived from a
|
||||
/// planned dish's ingredients (`dishId` set) or added free-hand by the user
|
||||
/// (`dishId == nil`). Check-off state persists across app launches.
|
||||
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
||||
@Model
|
||||
final class ShoppingItem {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var weekStartDate: Date
|
||||
var title: String
|
||||
var id: UUID = UUID()
|
||||
var weekStartDate: Date = Date()
|
||||
var title: String = ""
|
||||
var dishId: UUID?
|
||||
var isChecked: Bool = false
|
||||
var sortOrder: Int = 0
|
||||
var createdAt: Date
|
||||
var createdAt: Date = Date()
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere.
|
||||
@Model
|
||||
final class Tag {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
var nameEN: String
|
||||
var color: String
|
||||
var id: UUID = UUID()
|
||||
var name: String = ""
|
||||
var nameEN: String = ""
|
||||
var color: String = ""
|
||||
var maxPerWeek: Int?
|
||||
var noConsecutive: Bool = false
|
||||
var noDuplicateInDay: Bool = false
|
||||
var mealTypeRestriction: String? // "lunch", "dinner", nil
|
||||
var dayRestriction: String? // nil = any day, "weekdays" = Mon-Fri, "weekend" = Sat-Sun
|
||||
var isDefault: Bool = true
|
||||
var sortOrder: Int
|
||||
var sortOrder: Int = 0
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// CloudKit-compatible (2.0): no unique constraints, inline defaults everywhere,
|
||||
// and the relationship stored as optional (CloudKit requires it). `slots` keeps
|
||||
// its name so existing stores lightweight-migrate; use `slotList` in code.
|
||||
@Model
|
||||
final class WeekPlan {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var weekStartDate: Date
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var id: UUID = UUID()
|
||||
var weekStartDate: Date = Date()
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
var userRating: Int = 0 // 0 = unrated, 1 = liked, -1 = disliked
|
||||
|
||||
@Relationship(deleteRule: .cascade)
|
||||
var slots: [MealSlot]
|
||||
var slots: [MealSlot]? = []
|
||||
|
||||
/// Non-optional facade over the CloudKit-required optional relationship.
|
||||
var slotList: [MealSlot] {
|
||||
get { slots ?? [] }
|
||||
set { slots = newValue }
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
<array>
|
||||
<string>iCloud.com.alexandrev.mealmood</string>
|
||||
</array>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
|
||||
@@ -34,7 +34,7 @@ struct AutocompleteEngine {
|
||||
var pendingSlots = emptySlots.filter { !$0.isEatingOut }
|
||||
|
||||
// MRV: at each step pick the slot with fewest valid candidates first.
|
||||
// Assignments update currentPlan.slots in-place, so subsequent candidate
|
||||
// Assignments update currentPlan.slotList in-place, so subsequent candidate
|
||||
// counts automatically reflect the growing set of committed dishes.
|
||||
while !pendingSlots.isEmpty {
|
||||
// Score each remaining slot by strict candidate count
|
||||
@@ -78,7 +78,7 @@ struct AutocompleteEngine {
|
||||
|
||||
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
|
||||
let dishMap = Dictionary(uniqueKeysWithValues: allDishes.map { ($0.id, $0) })
|
||||
return plan.slots.compactMap { slot in
|
||||
return plan.slotList.compactMap { slot in
|
||||
guard let dishId = slot.dishId,
|
||||
let dish = dishMap[dishId],
|
||||
slot.isRuleOverridden,
|
||||
@@ -119,7 +119,7 @@ struct AutocompleteEngine {
|
||||
}
|
||||
|
||||
private static func violatesDefaultNoRepeatRule(dish: Dish, slot: MealSlot, plan: WeekPlan) -> Bool {
|
||||
plan.slots.contains { $0.id != slot.id && $0.dishId == dish.id }
|
||||
plan.slotList.contains { $0.id != slot.id && $0.dishId == dish.id }
|
||||
}
|
||||
|
||||
private static func violatesExplicitRules(
|
||||
@@ -134,7 +134,7 @@ struct AutocompleteEngine {
|
||||
|
||||
if let maxPerWeek = tag.maxPerWeek {
|
||||
var count = 0
|
||||
for s in plan.slots {
|
||||
for s in plan.slotList {
|
||||
guard let did = s.dishId, let d = dishMap[did] else { continue }
|
||||
if d.tagIds.contains(tagId) { count += 1 }
|
||||
}
|
||||
@@ -149,7 +149,7 @@ struct AutocompleteEngine {
|
||||
}
|
||||
|
||||
if tag.noDuplicateInDay {
|
||||
for sdSlot in plan.slots where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
|
||||
for sdSlot in plan.slotList where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
|
||||
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
|
||||
if d.tagIds.contains(tagId) { return true }
|
||||
}
|
||||
@@ -173,11 +173,11 @@ struct AutocompleteEngine {
|
||||
private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] {
|
||||
var result: [MealSlot] = []
|
||||
if slot.dayOfWeek > 0,
|
||||
let prev = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) {
|
||||
let prev = plan.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) {
|
||||
result.append(prev)
|
||||
}
|
||||
if slot.dayOfWeek < 6,
|
||||
let next = plan.slots.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) {
|
||||
let next = plan.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) {
|
||||
result.append(next)
|
||||
}
|
||||
return result
|
||||
@@ -201,7 +201,7 @@ struct AutocompleteEngine {
|
||||
// Week offsets: how many weeks ago did this dish appear?
|
||||
var offsets: [Int] = []
|
||||
for plan in recentPlans {
|
||||
guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue }
|
||||
guard plan.slotList.contains(where: { $0.dishId == dish.id }) else { continue }
|
||||
let diff = calendar.dateComponents([.weekOfYear], from: plan.weekStartDate, to: currentWeekStart).weekOfYear ?? 0
|
||||
if diff > 0 { offsets.append(diff) }
|
||||
}
|
||||
@@ -261,7 +261,7 @@ struct AutocompleteEngine {
|
||||
for dish in dishes {
|
||||
var liked = 0, disliked = 0
|
||||
for plan in ratedPlans {
|
||||
guard plan.slots.contains(where: { $0.dishId == dish.id }) else { continue }
|
||||
guard plan.slotList.contains(where: { $0.dishId == dish.id }) else { continue }
|
||||
if plan.userRating > 0 { liked += 1 } else { disliked += 1 }
|
||||
}
|
||||
guard liked + disliked > 0 else { continue }
|
||||
@@ -283,7 +283,7 @@ struct AutocompleteEngine {
|
||||
) -> 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 usage = plan.slotList.filter { $0.dishId == dish.id }.count
|
||||
let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0
|
||||
let patternScore = historyScores[dish.id] ?? 1.0
|
||||
let ratingScore = ratingScores[dish.id] ?? 1.0
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// CloudKit private-DB sync can materialize duplicates when several devices
|
||||
/// independently create "the same" entity before their first sync converges:
|
||||
/// the AppSettings singleton, the default Tags, or the plan for a given week.
|
||||
/// This runs once per launch and collapses them deterministically, so every
|
||||
/// device deletes the same losers and the graph converges.
|
||||
enum DeduplicationService {
|
||||
|
||||
static func run(context: ModelContext) {
|
||||
dedupeSettings(context: context)
|
||||
dedupeTags(context: context)
|
||||
dedupeWeekPlans(context: context)
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
/// Keep a single AppSettings: prefer one with onboarding completed, then
|
||||
/// premium (never drop an entitlement marker), then lowest id.
|
||||
private static func dedupeSettings(context: ModelContext) {
|
||||
let all = (try? context.fetch(FetchDescriptor<AppSettings>())) ?? []
|
||||
guard all.count > 1 else { return }
|
||||
let keeper = all.sorted { lhs, rhs in
|
||||
if lhs.onboardingCompleted != rhs.onboardingCompleted { return lhs.onboardingCompleted }
|
||||
if lhs.isPremium != rhs.isPremium { return lhs.isPremium }
|
||||
return lhs.persistentModelID.hashValue < rhs.persistentModelID.hashValue
|
||||
}.first!
|
||||
for candidate in all where candidate !== keeper {
|
||||
context.delete(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collapse Tags duplicated by id, and default Tags duplicated by English
|
||||
/// name (two devices seeding the same defaults with different UUIDs).
|
||||
/// Dish.tagIds referencing a removed duplicate are remapped to the keeper.
|
||||
private static func dedupeTags(context: ModelContext) {
|
||||
let all = (try? context.fetch(FetchDescriptor<Tag>())) ?? []
|
||||
guard all.count > 1 else { return }
|
||||
|
||||
var idMap: [UUID: UUID] = [:] // loser id → keeper id
|
||||
var seen: [String: Tag] = [:]
|
||||
for tag in all.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {
|
||||
let key = tag.isDefault
|
||||
? "default|\(tag.nameEN.lowercased())"
|
||||
: "id|\(tag.id.uuidString)"
|
||||
if let keeper = seen[key] {
|
||||
if keeper.id != tag.id { idMap[tag.id] = keeper.id }
|
||||
context.delete(tag)
|
||||
} else {
|
||||
seen[key] = tag
|
||||
}
|
||||
}
|
||||
guard !idMap.isEmpty else { return }
|
||||
|
||||
let dishes = (try? context.fetch(FetchDescriptor<Dish>())) ?? []
|
||||
for dish in dishes {
|
||||
let remapped = dish.tagIds.map { idMap[$0] ?? $0 }
|
||||
let unique = Array(NSOrderedSet(array: remapped)) as? [UUID] ?? remapped
|
||||
if unique != dish.tagIds {
|
||||
dish.tagIds = unique
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep one WeekPlan per weekStartDate: the one with most assigned slots
|
||||
/// (ties: more slots, then lowest id). Losers cascade-delete their slots.
|
||||
private static func dedupeWeekPlans(context: ModelContext) {
|
||||
let all = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
||||
let grouped = Dictionary(grouping: all, by: \.weekStartDate)
|
||||
for (_, plans) in grouped where plans.count > 1 {
|
||||
let keeper = plans.sorted { lhs, rhs in
|
||||
let lhsAssigned = lhs.slotList.filter { $0.dishId != nil }.count
|
||||
let rhsAssigned = rhs.slotList.filter { $0.dishId != nil }.count
|
||||
if lhsAssigned != rhsAssigned { return lhsAssigned > rhsAssigned }
|
||||
if lhs.slotList.count != rhs.slotList.count { return lhs.slotList.count > rhs.slotList.count }
|
||||
return lhs.id.uuidString < rhs.id.uuidString
|
||||
}.first!
|
||||
for plan in plans where plan !== keeper {
|
||||
context.delete(plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ struct DefaultDataService {
|
||||
for mealType in mealTypes {
|
||||
let slot = MealSlot(dayOfWeek: day, mealType: mealType)
|
||||
slot.weekPlan = plan
|
||||
plan.slots.append(slot)
|
||||
plan.slotList.append(slot)
|
||||
context.insert(slot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ final class ICloudSyncService {
|
||||
private init() {}
|
||||
|
||||
func pullRemoteIfNeeded(context: ModelContext) async {
|
||||
// 2.0: CloudKit syncs the store directly — applying legacy KV snapshots
|
||||
// on top would duplicate/fight with it. KV stays for 1.x devices only.
|
||||
guard !CloudSyncRuntime.isCloudKitActive else { return }
|
||||
store.synchronize()
|
||||
|
||||
guard let data = store.data(forKey: payloadKey),
|
||||
@@ -35,6 +38,7 @@ final class ICloudSyncService {
|
||||
}
|
||||
|
||||
func pushLocalSnapshot(context: ModelContext) async {
|
||||
guard !CloudSyncRuntime.isCloudKitActive else { return }
|
||||
if isApplyingRemote { return }
|
||||
guard let snapshot = makeSnapshot(context: context) else { return }
|
||||
guard let data = try? JSONEncoder().encode(snapshot) else { return }
|
||||
@@ -105,7 +109,7 @@ final class ICloudSyncService {
|
||||
weekStartDate: plan.weekStartDate,
|
||||
createdAt: plan.createdAt,
|
||||
updatedAt: plan.updatedAt,
|
||||
slots: plan.slots.map {
|
||||
slots: plan.slotList.map {
|
||||
MealSlotPayload(
|
||||
id: $0.id,
|
||||
dayOfWeek: $0.dayOfWeek,
|
||||
@@ -210,7 +214,7 @@ final class ICloudSyncService {
|
||||
isRuleOverridden: slotPayload.isRuleOverridden
|
||||
)
|
||||
slot.weekPlan = plan
|
||||
plan.slots.append(slot)
|
||||
plan.slotList.append(slot)
|
||||
context.insert(slot)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ final class NotificationService {
|
||||
let center = UNUserNotificationCenter.current()
|
||||
let identifier = "mealmood.next-week-planning"
|
||||
|
||||
let isComplete = nextWeekPlan?.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut } ?? false
|
||||
let isComplete = nextWeekPlan?.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut } ?? false
|
||||
if isComplete {
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
} else {
|
||||
@@ -94,7 +94,7 @@ final class NotificationService {
|
||||
let identifier = "mealmood.sunday-planning"
|
||||
|
||||
// Cancel if next week is already planned
|
||||
let hasAnySlot = nextWeekPlan?.slots.contains { $0.dishId != nil || $0.isEatingOut } ?? false
|
||||
let hasAnySlot = nextWeekPlan?.slotList.contains { $0.dishId != nil || $0.isEatingOut } ?? false
|
||||
if hasAnySlot {
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
return
|
||||
|
||||
@@ -26,7 +26,7 @@ enum WidgetDataStore {
|
||||
let appDayOfWeek = (weekday + 5) % 7
|
||||
|
||||
let meals: [TodayMealData.Meal] = settings.activeMealTypes.map { mealType in
|
||||
let dishId = plan?.slots.first {
|
||||
let dishId = plan?.slotList.first {
|
||||
$0.dayOfWeek == appDayOfWeek && $0.mealType == mealType.rawValue
|
||||
}?.dishId
|
||||
let name = dishId.flatMap { id in dishes.first { $0.id == id }?.name }
|
||||
|
||||
@@ -109,7 +109,7 @@ final class DishViewModel: ObservableObject {
|
||||
|
||||
func canDelete(currentPlan: WeekPlan?) -> Bool {
|
||||
guard let dish = editingDish, let plan = currentPlan else { return true }
|
||||
return !plan.slots.contains { $0.dishId == dish.id }
|
||||
return !plan.slotList.contains { $0.dishId == dish.id }
|
||||
}
|
||||
|
||||
private func isDishAssignedInCurrentWeek(dishId: UUID, context: ModelContext) -> Bool {
|
||||
@@ -124,6 +124,6 @@ final class DishViewModel: ObservableObject {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentPlan.slots.contains { $0.dishId == dishId }
|
||||
return currentPlan.slotList.contains { $0.dishId == dishId }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ final class HomeViewModel: ObservableObject {
|
||||
.sorted { $0.weekStartDate > $1.weekStartDate }
|
||||
.prefix(12)
|
||||
|
||||
let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }
|
||||
let emptySlots = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut }
|
||||
let result = AutocompleteEngine.autocomplete(
|
||||
emptySlots: emptySlots,
|
||||
currentPlan: plan,
|
||||
@@ -176,7 +176,7 @@ final class HomeViewModel: ObservableObject {
|
||||
autoAssignedSlotIds = []
|
||||
captureUndoSnapshot(plan: plan)
|
||||
|
||||
for slot in plan.slots {
|
||||
for slot in plan.slotList {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
slot.calendarEventId = nil
|
||||
@@ -278,13 +278,13 @@ final class HomeViewModel: ObservableObject {
|
||||
captureUndoSnapshot(plan: currentPlan)
|
||||
|
||||
var previousByKey: [SlotKey: MealSlot] = [:]
|
||||
for slot in previousPlan.slots {
|
||||
for slot in previousPlan.slotList {
|
||||
previousByKey[SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)] = slot
|
||||
}
|
||||
|
||||
let dishIds = Set(allDishes.map(\.id))
|
||||
var copiedCount = 0
|
||||
for slot in currentPlan.slots {
|
||||
for slot in currentPlan.slotList {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
slot.calendarEventId = nil
|
||||
@@ -316,7 +316,7 @@ final class HomeViewModel: ObservableObject {
|
||||
guard canUndo(for: plan) else { return }
|
||||
isApplyingUndo = true
|
||||
|
||||
for slot in plan.slots {
|
||||
for slot in plan.slotList {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
}
|
||||
@@ -352,7 +352,7 @@ final class HomeViewModel: ObservableObject {
|
||||
private func captureUndoSnapshot(plan: WeekPlan) {
|
||||
guard !isApplyingUndo else { return }
|
||||
lastWeekStartSnapshot = plan.weekStartDate
|
||||
lastSlotsSnapshot = Dictionary(uniqueKeysWithValues: plan.slots.map { slot in
|
||||
lastSlotsSnapshot = Dictionary(uniqueKeysWithValues: plan.slotList.map { slot in
|
||||
(slot.id, SlotSnapshot(dishId: slot.dishId, isRuleOverridden: slot.isRuleOverridden, isEatingOut: slot.isEatingOut))
|
||||
})
|
||||
hasUndoSnapshot = true
|
||||
@@ -365,7 +365,7 @@ final class HomeViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func firstFreeSlot(in plan: WeekPlan) -> MealSlot? {
|
||||
plan.slots
|
||||
plan.slotList
|
||||
.filter { $0.dishId == nil && !$0.isEatingOut }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.dayOfWeek != rhs.dayOfWeek {
|
||||
@@ -385,7 +385,7 @@ final class HomeViewModel: ObservableObject {
|
||||
|
||||
switch settings.syncModeEnum {
|
||||
case .weekComplete:
|
||||
if plan.slots.allSatisfy({ $0.dishId != nil || $0.isEatingOut }) {
|
||||
if plan.slotList.allSatisfy({ $0.dishId != nil || $0.isEatingOut }) {
|
||||
let descriptor = FetchDescriptor<Dish>()
|
||||
let dishes = (try? plan.modelContext?.fetch(descriptor)) ?? []
|
||||
syncAllAssignedSlotsToCalendar(plan: plan, dishes: dishes, settings: settings)
|
||||
@@ -401,7 +401,7 @@ final class HomeViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func clearCalendarEvents(for plan: WeekPlan) {
|
||||
for slot in plan.slots {
|
||||
for slot in plan.slotList {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
slot.calendarEventId = nil
|
||||
@@ -411,7 +411,7 @@ final class HomeViewModel: ObservableObject {
|
||||
|
||||
private func syncAllAssignedSlotsToCalendar(plan: WeekPlan, dishes: [Dish], settings: AppSettings) {
|
||||
let dishById = Dictionary(uniqueKeysWithValues: dishes.map { ($0.id, $0) })
|
||||
for slot in plan.slots {
|
||||
for slot in plan.slotList {
|
||||
guard let dishId = slot.dishId, let dish = dishById[dishId] else {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
@@ -499,7 +499,7 @@ final class HomeViewModel: ObservableObject {
|
||||
|
||||
// Clean up any legacy duplicates for the same day+mealType key to keep UI mapping stable.
|
||||
var groupedByKey: [SlotKey: [MealSlot]] = [:]
|
||||
for slot in plan.slots {
|
||||
for slot in plan.slotList {
|
||||
let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
|
||||
groupedByKey[key, default: []].append(slot)
|
||||
}
|
||||
@@ -518,7 +518,7 @@ final class HomeViewModel: ObservableObject {
|
||||
CalendarService.shared.deleteEvent(eventId: duplicateEventId)
|
||||
}
|
||||
|
||||
plan.slots.removeAll { $0.id == duplicate.id }
|
||||
plan.slotList.removeAll { $0.id == duplicate.id }
|
||||
context.delete(duplicate)
|
||||
changed = true
|
||||
}
|
||||
@@ -532,21 +532,21 @@ final class HomeViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
var existingByKey: [SlotKey: MealSlot] = [:]
|
||||
for slot in plan.slots {
|
||||
for slot in plan.slotList {
|
||||
let key = SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType)
|
||||
if existingByKey[key] == nil {
|
||||
existingByKey[key] = slot
|
||||
}
|
||||
}
|
||||
|
||||
let toDelete = plan.slots.filter { slot in
|
||||
let toDelete = plan.slotList.filter { slot in
|
||||
!desired.contains(SlotKey(dayOfWeek: slot.dayOfWeek, mealType: slot.mealType))
|
||||
}
|
||||
for slot in toDelete {
|
||||
if let eventId = slot.calendarEventId {
|
||||
CalendarService.shared.deleteEvent(eventId: eventId)
|
||||
}
|
||||
plan.slots.removeAll { $0.id == slot.id }
|
||||
plan.slotList.removeAll { $0.id == slot.id }
|
||||
context.delete(slot)
|
||||
changed = true
|
||||
}
|
||||
@@ -554,7 +554,7 @@ final class HomeViewModel: ObservableObject {
|
||||
for key in desired where existingByKey[key] == nil {
|
||||
let slot = MealSlot(dayOfWeek: key.dayOfWeek, mealType: key.mealType)
|
||||
slot.weekPlan = plan
|
||||
plan.slots.append(slot)
|
||||
plan.slotList.append(slot)
|
||||
context.insert(slot)
|
||||
changed = true
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ final class OnboardingViewModel: ObservableObject {
|
||||
let plans = (try? context.fetch(FetchDescriptor<WeekPlan>())) ?? []
|
||||
guard let plan = plans.first(where: { $0.weekStartDate == weekStart }) else { return 0 }
|
||||
|
||||
let emptySlots = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }
|
||||
let emptySlots = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut }
|
||||
guard !emptySlots.isEmpty else { return 0 }
|
||||
|
||||
let result = AutocompleteEngine.autocomplete(
|
||||
|
||||
@@ -60,7 +60,7 @@ final class SettingsViewModel: ObservableObject {
|
||||
func updateCalendarEvents(settings: AppSettings, plan: WeekPlan?) {
|
||||
guard let plan = plan, settings.syncEnabled else { return }
|
||||
CalendarService.shared.updateEventsTime(
|
||||
slots: plan.slots,
|
||||
slots: plan.slotList,
|
||||
weekStartDate: plan.weekStartDate,
|
||||
settings: settings
|
||||
)
|
||||
|
||||
@@ -149,7 +149,7 @@ struct DishListView: View {
|
||||
|
||||
for index in offsets {
|
||||
let dish = dishes[index]
|
||||
if currentWeekPlan?.slots.contains(where: { $0.dishId == dish.id }) == true {
|
||||
if currentWeekPlan?.slotList.contains(where: { $0.dishId == dish.id }) == true {
|
||||
showDeleteBlockedAlert = true
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ struct HomeView: View {
|
||||
}
|
||||
)
|
||||
|
||||
let filledCount = plan.slots.filter { $0.dishId != nil || $0.isEatingOut }.count
|
||||
let filledCount = plan.slotList.filter { $0.dishId != nil || $0.isEatingOut }.count
|
||||
if filledCount > 0 {
|
||||
exportCallout(plan: plan, settings: settings)
|
||||
}
|
||||
@@ -273,7 +273,7 @@ struct HomeView: View {
|
||||
weekRatingRow(plan: plan)
|
||||
}
|
||||
|
||||
let emptyCount = plan.slots.filter { $0.dishId == nil && !$0.isEatingOut }.count
|
||||
let emptyCount = plan.slotList.filter { $0.dishId == nil && !$0.isEatingOut }.count
|
||||
if viewModel.canEditCurrentWeek && !dishes.isEmpty && emptyCount > 0 {
|
||||
autoAssignBanner(emptyCount: emptyCount, plan: plan, settings: settings)
|
||||
}
|
||||
@@ -410,12 +410,12 @@ struct HomeView: View {
|
||||
)
|
||||
) {
|
||||
if let slotId = selectedEmptySlotId,
|
||||
plan.slots.contains(where: { $0.id == slotId }) {
|
||||
plan.slotList.contains(where: { $0.id == slotId }) {
|
||||
SlotDishPickerSheet(
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
onPickDish: { dish in
|
||||
guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else {
|
||||
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
|
||||
selectedEmptySlotId = nil
|
||||
return
|
||||
}
|
||||
@@ -440,7 +440,7 @@ struct HomeView: View {
|
||||
}
|
||||
},
|
||||
onEatingOut: {
|
||||
guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else {
|
||||
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
|
||||
selectedEmptySlotId = nil
|
||||
return
|
||||
}
|
||||
@@ -459,12 +459,12 @@ struct HomeView: View {
|
||||
)
|
||||
) {
|
||||
if let slotId = selectedFilledSlotId,
|
||||
plan.slots.contains(where: { $0.id == slotId }) {
|
||||
plan.slotList.contains(where: { $0.id == slotId }) {
|
||||
SlotDishPickerSheet(
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
onPickDish: { dish in
|
||||
guard let freshSlot = plan.slots.first(where: { $0.id == slotId }) else {
|
||||
guard let freshSlot = plan.slotList.first(where: { $0.id == slotId }) else {
|
||||
selectedFilledSlotId = nil
|
||||
return
|
||||
}
|
||||
@@ -714,7 +714,7 @@ struct HomeView: View {
|
||||
dishes: dishes,
|
||||
tags: tags,
|
||||
language: settings.languageEnum.resolved(),
|
||||
usedDishIds: Set(plan.slots.compactMap(\.dishId)),
|
||||
usedDishIds: Set(plan.slotList.compactMap(\.dishId)),
|
||||
usageRanking: dishUsageCounts,
|
||||
onAddDish: { viewModel.showDishForm = true },
|
||||
onQuickAssignDish: { dish in
|
||||
@@ -730,7 +730,7 @@ struct HomeView: View {
|
||||
editingDish = dish
|
||||
},
|
||||
onDeleteDish: { dish in
|
||||
let isAssignedInCurrentWeek = plan.slots.contains { $0.dishId == dish.id }
|
||||
let isAssignedInCurrentWeek = plan.slotList.contains { $0.dishId == dish.id }
|
||||
if isAssignedInCurrentWeek {
|
||||
viewModel.toastMessage = localizedString("dish_delete_blocked_message", language: settings.languageEnum.resolved())
|
||||
viewModel.showToast = true
|
||||
@@ -775,13 +775,13 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private func isWeekComplete(plan: WeekPlan) -> Bool {
|
||||
plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut }
|
||||
plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut }
|
||||
}
|
||||
|
||||
private var dishUsageCounts: [UUID: Int] {
|
||||
var counts: [UUID: Int] = [:]
|
||||
for plan in weekPlans {
|
||||
for slot in plan.slots {
|
||||
for slot in plan.slotList {
|
||||
if let dishId = slot.dishId {
|
||||
counts[dishId, default: 0] += 1
|
||||
}
|
||||
@@ -910,14 +910,14 @@ struct HomeView: View {
|
||||
|
||||
private func previousWeekHasMenu() -> Bool {
|
||||
guard let previous = fetchWeekPlan(for: viewModel.currentWeekStart.addingDays(-7)) else { return false }
|
||||
return previous.slots.contains { $0.dishId != nil }
|
||||
return previous.slotList.contains { $0.dishId != nil }
|
||||
}
|
||||
|
||||
/// Copies the previous week, asking to confirm first only when the current
|
||||
/// week already has dishes that would be overwritten.
|
||||
private func requestCopyPreviousWeek(plan: WeekPlan, settings: AppSettings, source: String) {
|
||||
copyPreviousSource = source
|
||||
if plan.slots.contains(where: { $0.dishId != nil }) {
|
||||
if plan.slotList.contains(where: { $0.dishId != nil }) {
|
||||
showCopyPreviousConfirm = true
|
||||
} else {
|
||||
copyFromPreviousWeek(currentPlan: plan, settings: settings)
|
||||
@@ -994,10 +994,10 @@ struct HomeView: View {
|
||||
|
||||
private func preferredWeekPlan(from plans: [WeekPlan]) -> WeekPlan? {
|
||||
plans.max { lhs, rhs in
|
||||
let lhsAssigned = lhs.slots.filter { $0.dishId != nil }.count
|
||||
let rhsAssigned = rhs.slots.filter { $0.dishId != nil }.count
|
||||
let lhsAssigned = lhs.slotList.filter { $0.dishId != nil }.count
|
||||
let rhsAssigned = rhs.slotList.filter { $0.dishId != nil }.count
|
||||
if lhsAssigned != rhsAssigned { return lhsAssigned < rhsAssigned }
|
||||
if lhs.slots.count != rhs.slots.count { return lhs.slots.count < rhs.slots.count }
|
||||
if lhs.slotList.count != rhs.slotList.count { return lhs.slotList.count < rhs.slotList.count }
|
||||
return lhs.updatedAt < rhs.updatedAt
|
||||
}
|
||||
}
|
||||
@@ -1078,7 +1078,7 @@ struct HomeView: View {
|
||||
private func evaluateReviewPrompt() {
|
||||
let descriptor = FetchDescriptor<WeekPlan>()
|
||||
guard let plans = try? context.fetch(descriptor) else { return }
|
||||
let completedWeeks = plans.filter { !$0.slots.isEmpty && $0.slots.allSatisfy { $0.dishId != nil } }.count
|
||||
let completedWeeks = plans.filter { !$0.slotList.isEmpty && $0.slotList.allSatisfy { $0.dishId != nil } }.count
|
||||
guard ReviewPromptService.shared.shouldShowFunnelAfterWeekCompletion(completedWeeks: completedWeeks) else { return }
|
||||
ReviewPromptService.shared.markFunnelShown(completedWeeks: completedWeeks)
|
||||
showReviewSentimentPrompt = true
|
||||
@@ -1092,7 +1092,7 @@ struct HomeView: View {
|
||||
// instead of being asked. Reuses the home's autoComplete choreography.
|
||||
if UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey) {
|
||||
UserDefaults.standard.set(false, forKey: OnboardingViewModel.pendingAutoFillOnLaunchKey)
|
||||
if !dishes.isEmpty && plan.slots.contains(where: { $0.dishId == nil && !$0.isEatingOut }) {
|
||||
if !dishes.isEmpty && plan.slotList.contains(where: { $0.dishId == nil && !$0.isEatingOut }) {
|
||||
viewModel.autoComplete(plan: plan, dishes: dishes, tags: tags, settings: settings, allPlans: weekPlans)
|
||||
}
|
||||
schedulePostOnboardingPremiumPromptIfNeeded(settings: settings)
|
||||
@@ -1100,7 +1100,7 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
let shouldAskAutoAssign = UserDefaults.standard.bool(forKey: OnboardingViewModel.pendingAutoAssignPromptKey)
|
||||
if shouldAskAutoAssign && plan.slots.contains(where: { $0.dishId == nil }) {
|
||||
if shouldAskAutoAssign && plan.slotList.contains(where: { $0.dishId == nil }) {
|
||||
showPostOnboardingAutoAssignPrompt = true
|
||||
return
|
||||
}
|
||||
@@ -1162,7 +1162,7 @@ private struct RuleViolationsPanelSheet: View {
|
||||
} else {
|
||||
List {
|
||||
ForEach(violations, id: \.slotId) { violation in
|
||||
if let slot = plan.slots.first(where: { $0.id == violation.slotId }) {
|
||||
if let slot = plan.slotList.first(where: { $0.id == violation.slotId }) {
|
||||
ViolationRow(
|
||||
violation: violation,
|
||||
settings: settings,
|
||||
@@ -1496,7 +1496,7 @@ private struct MonthlyHistoryView: View {
|
||||
Text(weekLabel(for: plan.weekStartDate))
|
||||
.font(.mealMoodBodyBold)
|
||||
.foregroundColor(.mealMoodTextPrimary)
|
||||
Text(plan.slots.allSatisfy { $0.dishId != nil } ? String(localized: "history_week_complete") : String(localized: "history_week_incomplete"))
|
||||
Text(plan.slotList.allSatisfy { $0.dishId != nil } ? String(localized: "history_week_complete") : String(localized: "history_week_incomplete"))
|
||||
.font(.mealMoodCaption)
|
||||
.foregroundColor(.mealMoodTextSecondary)
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ struct WeekCalendarView: View {
|
||||
if let cached = displaySlotsByKey[key] {
|
||||
return cached
|
||||
}
|
||||
let matching = plan.slots.filter { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
||||
let matching = plan.slotList.filter { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
||||
guard !matching.isEmpty else { return nil }
|
||||
let preferred = MealSlot.preferredForDuplicateResolution(matching)
|
||||
return DisplaySlot(
|
||||
@@ -246,7 +246,7 @@ struct WeekCalendarView: View {
|
||||
viewModel.confirmInvalidDrop(dish, to: targetSlot, plan: plan, settings: settings)
|
||||
return true
|
||||
case .slot(let sourceSlotId):
|
||||
guard let sourceSlot = plan.slots.first(where: { $0.id == sourceSlotId }) else { return false }
|
||||
guard let sourceSlot = plan.slotList.first(where: { $0.id == sourceSlotId }) else { return false }
|
||||
viewModel.moveOrSwapDish(
|
||||
from: sourceSlot,
|
||||
to: targetSlot,
|
||||
@@ -358,7 +358,7 @@ struct WeekCalendarView: View {
|
||||
|
||||
private func refreshDisplaySlots() {
|
||||
var resolved: [String: DisplaySlot] = [:]
|
||||
let grouped = Dictionary(grouping: plan.slots, by: { slotKey(day: $0.dayOfWeek, mealType: $0.mealType) })
|
||||
let grouped = Dictionary(grouping: plan.slotList, by: { slotKey(day: $0.dayOfWeek, mealType: $0.mealType) })
|
||||
for (key, candidates) in grouped {
|
||||
guard !candidates.isEmpty else { continue }
|
||||
let preferred = MealSlot.preferredForDuplicateResolution(candidates)
|
||||
@@ -387,10 +387,10 @@ struct WeekCalendarView: View {
|
||||
}
|
||||
|
||||
private func liveSlot(for display: DisplaySlot) -> MealSlot? {
|
||||
if let exact = plan.slots.first(where: { $0.id == display.slotId }) {
|
||||
if let exact = plan.slotList.first(where: { $0.id == display.slotId }) {
|
||||
return exact
|
||||
}
|
||||
let matching = plan.slots.filter { $0.dayOfWeek == display.dayOfWeek && $0.mealType == display.mealType }
|
||||
let matching = plan.slotList.filter { $0.dayOfWeek == display.dayOfWeek && $0.mealType == display.mealType }
|
||||
guard !matching.isEmpty else { return nil }
|
||||
return MealSlot.preferredForDuplicateResolution(matching)
|
||||
}
|
||||
|
||||
@@ -507,7 +507,7 @@ struct WeekPlanShareView: View {
|
||||
}
|
||||
|
||||
private func dishName(day: Int, mealType: MealType) -> String {
|
||||
let slot = plan.slots.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
||||
let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
|
||||
guard let slot else { return String(localized: "share_slot_empty") }
|
||||
if slot.isEatingOut { return String(localized: "slot_eating_out") }
|
||||
guard let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else {
|
||||
|
||||
@@ -29,7 +29,7 @@ struct ShoppingListView: View {
|
||||
private var plannedDishes: [Dish] {
|
||||
var seen = Set<UUID>()
|
||||
var result: [Dish] = []
|
||||
let sortedSlots = plan.slots.sorted {
|
||||
let sortedSlots = plan.slotList.sorted {
|
||||
($0.dayOfWeek, $0.mealType) < ($1.dayOfWeek, $1.mealType)
|
||||
}
|
||||
for slot in sortedSlots {
|
||||
|
||||
@@ -130,14 +130,14 @@ struct StatsView: View {
|
||||
// MARK: - Computed stats
|
||||
|
||||
private var plannedWeeks: [WeekPlan] {
|
||||
weekPlans.filter { plan in plan.slots.contains { $0.dishId != nil } }
|
||||
weekPlans.filter { plan in plan.slotList.contains { $0.dishId != nil } }
|
||||
}
|
||||
|
||||
private var weeksWithAnyDish: Int { plannedWeeks.count }
|
||||
|
||||
private var completeWeeks: Int {
|
||||
weekPlans.filter { plan in
|
||||
!plan.slots.isEmpty && plan.slots.allSatisfy { $0.dishId != nil || $0.isEatingOut }
|
||||
!plan.slotList.isEmpty && plan.slotList.allSatisfy { $0.dishId != nil || $0.isEatingOut }
|
||||
}.count
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ struct StatsView: View {
|
||||
private var dishUsage: [UUID: Int] {
|
||||
var counts: [UUID: Int] = [:]
|
||||
for plan in weekPlans {
|
||||
for slot in plan.slots {
|
||||
for slot in plan.slotList {
|
||||
if let id = slot.dishId { counts[id, default: 0] += 1 }
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ struct StatsView: View {
|
||||
let tagMap = Dictionary(uniqueKeysWithValues: allTags.map { ($0.id, $0) })
|
||||
var counts: [UUID: Int] = [:]
|
||||
for plan in weekPlans {
|
||||
for slot in plan.slots {
|
||||
for slot in plan.slotList {
|
||||
guard let dishId = slot.dishId, let dish = dishMap[dishId] else { continue }
|
||||
for tagId in dish.tagIds { counts[tagId, default: 0] += 1 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user