fix(cache): preserve events from calendars/sources that failed to sync
mergeIntoCache() unconditionally evicted all cached events in the fetched range, so a transient sync failure (expired CalDAV credentials, an HA/Google token-refresh failure) wiped the affected calendar until the next clean fetch. Home Assistant was the most visible victim: a token failure aborts the whole account fetch and the error arrives without a calendar_id. Now sync errors are split into per-calendar keys (error carries a calendar_id) and whole sources (account-level error without one). mergeIntoCache() keeps matching cached events, and only extends the cached range on a fully clean fetch so failed calendars are retried on the next load. Mirrors the iOS fix. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -243,7 +243,8 @@ class CalendarViewModel @Inject constructor(
|
|||||||
else repository.fetchEvents(start, end).let { it.events to it.errors }
|
else repository.fetchEvents(start, end).let { it.events to it.errors }
|
||||||
}
|
}
|
||||||
.onSuccess { (events, errors) ->
|
.onSuccess { (events, errors) ->
|
||||||
mergeIntoCache(events, start, end)
|
val (keepKeys, keepSources) = failedCalendarKeys(errors)
|
||||||
|
mergeIntoCache(events, start, end, keepKeys, keepSources)
|
||||||
refreshFromCache()
|
refreshFromCache()
|
||||||
_state.update { it.copy(syncErrors = errors) }
|
_state.update { it.copy(syncErrors = errors) }
|
||||||
}
|
}
|
||||||
@@ -281,13 +282,51 @@ class CalendarViewModel @Inject constructor(
|
|||||||
return !cs.isAfter(start) && !ce.isBefore(end)
|
return !cs.isAfter(start) && !ce.isBefore(end)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeIntoCache(newEvents: List<CalEvent>, rangeStart: Instant, rangeEnd: Instant) {
|
/**
|
||||||
val retained = allCachedEvents.filter {
|
* Split sync errors into per-calendar keys and whole sources whose cached
|
||||||
!it.startDate.isBefore(rangeEnd) || !it.endDate.isAfter(rangeStart)
|
* events must NOT be evicted on a partial sync — stale data beats an empty
|
||||||
|
* calendar. An error carrying a calendar_id protects just that calendar; an
|
||||||
|
* account-level error without one (e.g. an HA / Google token-refresh
|
||||||
|
* failure, which aborts the whole account fetch) protects every cached
|
||||||
|
* calendar of that source.
|
||||||
|
*/
|
||||||
|
private fun failedCalendarKeys(errors: List<SyncError>): Pair<Set<String>, Set<String>> {
|
||||||
|
val keys = mutableSetOf<String>()
|
||||||
|
val sources = mutableSetOf<String>()
|
||||||
|
for (err in errors) {
|
||||||
|
val cid = err.calendarId
|
||||||
|
if (cid != null) keys.add(calendarKey(err.source, cid.toString()))
|
||||||
|
else sources.add(err.source)
|
||||||
|
}
|
||||||
|
return keys to sources
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mergeIntoCache(
|
||||||
|
newEvents: List<CalEvent>, rangeStart: Instant, rangeEnd: Instant,
|
||||||
|
keepKeysInRange: Set<String> = emptySet(),
|
||||||
|
keepSourcesInRange: Set<String> = emptySet(),
|
||||||
|
) {
|
||||||
|
// Remove old events in the fetched range to avoid duplicates — but
|
||||||
|
// PRESERVE events from calendars / sources that had sync errors so a
|
||||||
|
// transient CalDAV / Google / HA failure doesn't wipe the visible calendar.
|
||||||
|
val retained = allCachedEvents.filter { ev ->
|
||||||
|
val outsideRange = !ev.startDate.isBefore(rangeEnd) || !ev.endDate.isAfter(rangeStart)
|
||||||
|
when {
|
||||||
|
outsideRange -> true
|
||||||
|
ev.source in keepSourcesInRange -> true
|
||||||
|
keepKeysInRange.isEmpty() -> false
|
||||||
|
else -> calendarKey(ev.source, ev.calendarId) in keepKeysInRange
|
||||||
|
}
|
||||||
}
|
}
|
||||||
allCachedEvents = retained + newEvents
|
allCachedEvents = retained + newEvents
|
||||||
cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart
|
// Only extend the cached range on a fully clean fetch. When some
|
||||||
cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd
|
// calendars/sources failed, leave cachedStart/End unchanged so
|
||||||
|
// isCached() stays false and they're retried on the next load rather
|
||||||
|
// than being silently treated as "done" with empty data.
|
||||||
|
if (keepKeysInRange.isEmpty() && keepSourcesInRange.isEmpty()) {
|
||||||
|
cachedStart = cachedStart?.let { minOf(it, rangeStart) } ?: rangeStart
|
||||||
|
cachedEnd = cachedEnd?.let { maxOf(it, rangeEnd) } ?: rangeEnd
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshFromCache() {
|
private fun refreshFromCache() {
|
||||||
|
|||||||
Reference in New Issue
Block a user