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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-03 20:06:11 +02:00
parent 8ed474663e
commit 1cbab3f3ec
3 changed files with 33 additions and 10 deletions

View File

@@ -511,7 +511,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.8; MARKETING_VERSION = 2.9;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS"; PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -555,7 +555,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.8; MARKETING_VERSION = 2.9;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
PRODUCT_NAME = "Calendarr iOS"; PRODUCT_NAME = "Calendarr iOS";
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;

View File

@@ -25,16 +25,18 @@ struct EventPerson: Hashable {
/// succeeded, just not every source within it. /// succeeded, just not every source within it.
struct SyncError: Hashable { struct SyncError: Hashable {
let source: String let source: String
let calendarId: String? // nil on older server responses without calendar_id
let name: String let name: String
let message: String let message: String
static func from(json: [String: Any]) -> SyncError? { static func from(json: [String: Any]) -> SyncError? {
guard guard
let source = json["source"] as? String, let source = json["source"] as? String,
let name = json["name"] as? String, let name = json["name"] as? String,
let message = json["message"] as? String let message = json["message"] as? String
else { return nil } 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)
} }
} }

View File

@@ -349,7 +349,8 @@ class CalendarStore {
do { do {
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end) let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
syncErrors = errors syncErrors = errors
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) mergeIntoCache(fetched, rangeStart: start, rangeEnd: end,
keepKeysInRange: failedCalendarKeys(from: errors))
refreshFromCache(start: start, end: end) refreshFromCache(start: start, end: end)
} catch { } catch {
// Hard failure leave `syncErrors` as-is; it reflects the last // Hard failure leave `syncErrors` as-is; it reflects the last
@@ -409,7 +410,8 @@ class CalendarStore {
do { do {
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end) let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
syncErrors = errors 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 // Refresh visible range from newly expanded cache
let (vs, ve) = rangeForCurrentView() let (vs, ve) = rangeForCurrentView()
refreshFromCache(start: vs, end: ve) refreshFromCache(start: vs, end: ve)
@@ -428,10 +430,29 @@ class CalendarStore {
allCachedEvents = [] allCachedEvents = []
} }
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) { /// Calendar keys (source:id) for calendars that reported a per-source sync
// Remove old events that overlap with the newly fetched range (avoid duplicates) /// 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<String> {
var keys = Set<String>()
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<String> = []) {
// 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 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 allCachedEvents = retained + newEvents