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