From 1cbab3f3ec8dbcf797057cf2f422fead67b65f92 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 3 Jul 2026 20:06:11 +0200 Subject: [PATCH] fix: preserve cached events when a calendar has a per-source sync error Root cause: mergeIntoCache() unconditionally evicted every event in the fetched date range, even for calendars whose sync failed. Those calendars' events were removed and never restored, so the calendar appeared to vanish. Fix: - SyncError now decodes calendarId from the server's "calendar_id" field (added server-side in e0ea16f but not yet read by iOS). - mergeIntoCache() gains a keepKeysInRange parameter: events from failed calendars are retained (not evicted) even within the fetch window. - loadEvents() and prefetchBackground() compute the failed keys from the syncErrors list and pass them to mergeIntoCache. Result: when CalDAV / Google / HA sync fails for a specific calendar, the user sees stale-but-correct events alongside the existing error banner, instead of a completely empty calendar with no explanation. Co-Authored-By: Claude Sonnet 4.6 --- Calendarr iOS.xcodeproj/project.pbxproj | 4 +-- Calendarr iOS/Models/CalEvent.swift | 8 +++--- Calendarr iOS/Models/CalendarStore.swift | 31 ++++++++++++++++++++---- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 64b476b..ef2eb75 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -511,7 +511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8; + MARKETING_VERSION = 2.9; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -555,7 +555,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8; + MARKETING_VERSION = 2.9; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index b3669d8..fb820ea 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -25,16 +25,18 @@ struct EventPerson: Hashable { /// succeeded, just not every source within it. struct SyncError: Hashable { let source: String + let calendarId: String? // nil on older server responses without calendar_id let name: String let message: String static func from(json: [String: Any]) -> SyncError? { guard - let source = json["source"] as? String, - let name = json["name"] as? String, + let source = json["source"] as? String, + let name = json["name"] as? String, let message = json["message"] as? String else { return nil } - return SyncError(source: source, name: name, message: message) + let calendarId = json["calendar_id"].map { "\($0)" } + return SyncError(source: source, calendarId: calendarId, name: name, message: message) } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index a9e7468..6edde83 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -349,7 +349,8 @@ class CalendarStore { do { let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end) syncErrors = errors - mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) + mergeIntoCache(fetched, rangeStart: start, rangeEnd: end, + keepKeysInRange: failedCalendarKeys(from: errors)) refreshFromCache(start: start, end: end) } catch { // Hard failure – leave `syncErrors` as-is; it reflects the last @@ -409,7 +410,8 @@ class CalendarStore { do { let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end) syncErrors = errors - mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) + mergeIntoCache(fetched, rangeStart: start, rangeEnd: end, + keepKeysInRange: failedCalendarKeys(from: errors)) // Refresh visible range from newly expanded cache let (vs, ve) = rangeForCurrentView() refreshFromCache(start: vs, end: ve) @@ -428,10 +430,29 @@ class CalendarStore { allCachedEvents = [] } - private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) { - // Remove old events that overlap with the newly fetched range (avoid duplicates) + /// Calendar keys (source:id) for calendars that reported a per-source sync + /// error. Events from these calendars must NOT be evicted from the cache on + /// a partial sync — stale data is better than a completely empty calendar. + private func failedCalendarKeys(from errors: [SyncError]) -> Set { + var keys = Set() + for err in errors { + guard let cid = err.calendarId else { continue } + keys.insert(Self.calendarKey(source: err.source, calendarId: cid)) + } + return keys + } + + private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date, + keepKeysInRange: Set = []) { + // Remove old events in the fetched range to avoid duplicates — but + // PRESERVE events from calendars that had sync errors so that a + // transient CalDAV / Google failure doesn't wipe the visible calendar. let retained = allCachedEvents.filter { ev in - ev.startDate >= rangeEnd || ev.endDate <= rangeStart + let outsideRange = ev.startDate >= rangeEnd || ev.endDate <= rangeStart + if outsideRange { return true } + guard !keepKeysInRange.isEmpty else { return false } + let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) + return keepKeysInRange.contains(key) } allCachedEvents = retained + newEvents