fix: surface per-calendar sync errors from server

/api/caldav/events now returns an "errors" array alongside "events" for
calendars that failed to sync (e.g. expired credentials) while still
reporting success overall. Previously such failures were silently
swallowed, so a calendar could appear enabled/visible while showing zero
events with no explanation.

- CalendarRepository.fetchEvents now returns EventsResult(events, errors),
  parsing the new "errors" array the same way importIcs's Triple return
  handles multi-value results.
- CalendarUiState gains syncErrors, populated in loadRange's onSuccess.
- CalendarScreen shows a second error-style banner for sync errors,
  additive to the existing network-failure banner.
- CalendarFilterSheet shows a warning icon next to calendar rows whose
  source+name matches a sync error (best-effort suffix match, since the
  server's error name is "<account> – <calendar name>").
This commit is contained in:
Guido Schmit
2026-07-02 18:27:00 +02:00
parent d2d5e2b19c
commit 3137abcef2
5 changed files with 83 additions and 8 deletions

View File

@@ -35,6 +35,12 @@ data class LoginResult(val token: String, val username: String, val isAdmin: Boo
data class TotpSetup(val secret: String, val qrUrl: String)
/** A single calendar's sync failure, surfaced alongside a (still-successful) events fetch. */
data class SyncError(val source: String, val name: String, val message: String)
/** Result of [CalendarRepository.fetchEvents]: the merged events plus any per-calendar sync failures. */
data class EventsResult(val events: List<CalEvent>, val errors: List<SyncError>)
/**
* Single entry point for all server interaction. Wraps [com.scarriffle.calendarr.data.remote.CalendarrApi],
* converts HTTP failures into [ApiException]s carrying the server's `detail`
@@ -297,18 +303,30 @@ class CalendarRepository @Inject constructor(
// ---- Events ----
suspend fun fetchEvents(start: Instant, end: Instant): List<CalEvent> = withContext(Dispatchers.IO) {
suspend fun fetchEvents(start: Instant, end: Instant): EventsResult = withContext(Dispatchers.IO) {
val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end))
resp.ensureSuccess()
val raw = resp.body()?.string() ?: return@withContext emptyList()
val raw = resp.body()?.string() ?: return@withContext EventsResult(emptyList(), emptyList())
val root = JSONObject(raw)
val arr = root.optJSONArray("events") ?: return@withContext emptyList()
buildList {
for (i in 0 until arr.length()) {
val arr = root.optJSONArray("events")
val events = buildList {
if (arr != null) for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
CalEvent.fromJson(obj)?.let { add(it) }
}
}
val errArr = root.optJSONArray("errors")
val errors = buildList {
if (errArr != null) for (i in 0 until errArr.length()) {
val obj = errArr.optJSONObject(i) ?: continue
add(SyncError(
source = obj.optString("source"),
name = obj.optString("name"),
message = obj.optString("message"),
))
}
}
EventsResult(events, errors)
}
suspend fun createLocalEvent(

View File

@@ -122,6 +122,7 @@ object L10n {
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
"filter.button" to "Kalender ein-/ausblenden",
"filter.sync_error" to "Synchronisierung fehlgeschlagen",
"caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL",
"caldav.username" to "Benutzername", "caldav.password" to "Passwort",
"caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto",
@@ -260,6 +261,7 @@ object L10n {
"filter.title" to "Calendars", "filter.empty" to "No calendars available",
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
"filter.button" to "Show/hide calendars",
"filter.sync_error" to "Sync failed",
"caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL",
"caldav.username" to "Username", "caldav.password" to "Password",
"caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account",

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -83,11 +84,24 @@ fun CalendarFilterSheet(
}
rows.forEach { entry ->
val visible = entry.key !in hiddenSet
// Best-effort match: server error `name` is "<account> <calendar name>",
// so a suffix match on this calendar's own name is reliable enough.
val hasSyncError = !groupMode && state.syncErrors.any { err ->
err.source == entry.source && err.name.endsWith(entry.name)
}
Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color)))
if (hasSyncError) {
Icon(
Icons.Filled.WarningAmber,
contentDescription = tr("filter.sync_error"),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 8.dp).size(16.dp),
)
}
Text(
entry.name,
modifier = Modifier.weight(1f).padding(start = 12.dp),

View File

@@ -172,6 +172,9 @@ fun CalendarScreen(
state.error?.let { err ->
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
}
if (state.syncErrors.isNotEmpty()) {
SyncErrorBanner(state.syncErrors, onDismiss = vm::clearSyncErrors)
}
state.activeGroup?.let { g ->
GroupBanner(group = g, onExit = { vm.switchGroup(null) })
}
@@ -344,6 +347,33 @@ private fun ErrorBanner(message: String, onRetry: () -> Unit, onDismiss: () -> U
}
}
/**
* Additive to [ErrorBanner]: the fetch as a whole succeeded, but one or more
* enabled calendars failed to sync (e.g. expired credentials) and are showing
* zero events with no other indication. Same visual language, no retry button
* (retrying the whole range wouldn't target just the broken calendar).
*/
@Composable
private fun SyncErrorBanner(errors: List<com.scarriffle.calendarr.data.SyncError>, onDismiss: () -> Unit) {
androidx.compose.material3.Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.padding(12.dp)) {
errors.forEach { err ->
Text(
"${err.name}: ${err.message}",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
)
}
androidx.compose.foundation.layout.Row {
TextButton(onClick = onDismiss) { Text(tr("common.close")) }
}
}
}
}
@Composable
private fun loadingPlaceholder() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.data.SyncError
import com.scarriffle.calendarr.domain.model.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.Group
@@ -36,6 +37,10 @@ data class CalendarUiState(
val isLoading: Boolean = false,
val isBackgroundCaching: Boolean = false,
val error: String? = null,
// Per-calendar sync failures from the last successful /events fetch (e.g.
// expired CalDAV credentials) — the fetch as a whole succeeded, but one or
// more enabled calendars silently returned nothing. Additive to `error`.
val syncErrors: List<SyncError> = emptyList(),
val weekStartsOnMonday: Boolean = true,
val writableCalendars: List<WritableCalendar> = emptyList(),
val hiddenKeys: Set<String> = emptySet(),
@@ -232,10 +237,14 @@ class CalendarViewModel @Inject constructor(
val flag = if (background) "bg" else "fg"
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
runCatching {
if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end))
else repository.fetchEvents(start, end)
if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) to emptyList<SyncError>()
else repository.fetchEvents(start, end).let { it.events to it.errors }
}
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
.onSuccess { (events, errors) ->
mergeIntoCache(events, start, end)
refreshFromCache()
_state.update { it.copy(syncErrors = errors) }
}
.onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
_state.update { it.copy(isLoading = false, isBackgroundCaching = false) }
}
@@ -319,6 +328,8 @@ class CalendarViewModel @Inject constructor(
fun clearError() = _state.update { it.copy(error = null) }
fun clearSyncErrors() = _state.update { it.copy(syncErrors = emptyList()) }
// ---- Visibility filters ----
fun setCalendarHidden(key: String, hidden: Boolean) {