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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user