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) 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], * 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` * converts HTTP failures into [ApiException]s carrying the server's `detail`
@@ -297,18 +303,30 @@ class CalendarRepository @Inject constructor(
// ---- Events ---- // ---- 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)) val resp = api.fetchEvents(Dates.isoUtc(start), Dates.isoUtc(end))
resp.ensureSuccess() 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 root = JSONObject(raw)
val arr = root.optJSONArray("events") ?: return@withContext emptyList() val arr = root.optJSONArray("events")
buildList { val events = buildList {
for (i in 0 until arr.length()) { if (arr != null) for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue val obj = arr.optJSONObject(i) ?: continue
CalEvent.fromJson(obj)?.let { add(it) } 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( suspend fun createLocalEvent(

View File

@@ -122,6 +122,7 @@ object L10n {
"filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden", "filter.title" to "Kalender", "filter.empty" to "Keine Kalender vorhanden",
"filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden", "filter.show_all" to "Alle anzeigen", "filter.hide_all" to "Alle ausblenden",
"filter.button" to "Kalender ein-/ausblenden", "filter.button" to "Kalender ein-/ausblenden",
"filter.sync_error" to "Synchronisierung fehlgeschlagen",
"caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL", "caldav.display_name" to "Anzeigename", "caldav.url" to "CalDAV-URL",
"caldav.username" to "Benutzername", "caldav.password" to "Passwort", "caldav.username" to "Benutzername", "caldav.password" to "Passwort",
"caldav.color" to "Farbe", "caldav.connect" to "Verbinden", "caldav.title" to "CalDAV-Konto", "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.title" to "Calendars", "filter.empty" to "No calendars available",
"filter.show_all" to "Show all", "filter.hide_all" to "Hide all", "filter.show_all" to "Show all", "filter.hide_all" to "Hide all",
"filter.button" to "Show/hide calendars", "filter.button" to "Show/hide calendars",
"filter.sync_error" to "Sync failed",
"caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL", "caldav.display_name" to "Display name", "caldav.url" to "CalDAV URL",
"caldav.username" to "Username", "caldav.password" to "Password", "caldav.username" to "Username", "caldav.password" to "Password",
"caldav.color" to "Color", "caldav.connect" to "Connect", "caldav.title" to "CalDAV account", "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.Icons
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -83,11 +84,24 @@ fun CalendarFilterSheet(
} }
rows.forEach { entry -> rows.forEach { entry ->
val visible = entry.key !in hiddenSet 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( Row(
Modifier.fillMaxWidth().padding(vertical = 8.dp), Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box(Modifier.size(14.dp).clip(CircleShape).background(colorFromHex(entry.color))) 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( Text(
entry.name, entry.name,
modifier = Modifier.weight(1f).padding(start = 12.dp), modifier = Modifier.weight(1f).padding(start = 12.dp),

View File

@@ -172,6 +172,9 @@ fun CalendarScreen(
state.error?.let { err -> state.error?.let { err ->
ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError) ErrorBanner(err, onRetry = { vm.loadVisible(force = true) }, onDismiss = vm::clearError)
} }
if (state.syncErrors.isNotEmpty()) {
SyncErrorBanner(state.syncErrors, onDismiss = vm::clearSyncErrors)
}
state.activeGroup?.let { g -> state.activeGroup?.let { g ->
GroupBanner(group = g, onExit = { vm.switchGroup(null) }) 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 @Composable
private fun loadingPlaceholder() { private fun loadingPlaceholder() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.SettingsStore 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.CalEvent
import com.scarriffle.calendarr.domain.model.CalViewType import com.scarriffle.calendarr.domain.model.CalViewType
import com.scarriffle.calendarr.domain.model.Group import com.scarriffle.calendarr.domain.model.Group
@@ -36,6 +37,10 @@ data class CalendarUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isBackgroundCaching: Boolean = false, val isBackgroundCaching: Boolean = false,
val error: String? = null, 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 weekStartsOnMonday: Boolean = true,
val writableCalendars: List<WritableCalendar> = emptyList(), val writableCalendars: List<WritableCalendar> = emptyList(),
val hiddenKeys: Set<String> = emptySet(), val hiddenKeys: Set<String> = emptySet(),
@@ -232,10 +237,14 @@ class CalendarViewModel @Inject constructor(
val flag = if (background) "bg" else "fg" val flag = if (background) "bg" else "fg"
_state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) } _state.update { if (flag == "bg") it.copy(isBackgroundCaching = true) else it.copy(isLoading = true, error = null) }
runCatching { runCatching {
if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) if (group != null) decorateGroup(repository.fetchGroupCombined(group.id, start, end)) to emptyList<SyncError>()
else repository.fetchEvents(start, end) else repository.fetchEvents(start, end).let { it.events to it.errors }
}
.onSuccess { (events, errors) ->
mergeIntoCache(events, start, end)
refreshFromCache()
_state.update { it.copy(syncErrors = errors) }
} }
.onSuccess { mergeIntoCache(it, start, end); refreshFromCache() }
.onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } } .onFailure { e -> if (!background) _state.update { it.copy(error = e.message) } }
_state.update { it.copy(isLoading = false, isBackgroundCaching = false) } _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 clearError() = _state.update { it.copy(error = null) }
fun clearSyncErrors() = _state.update { it.copy(syncErrors = emptyList()) }
// ---- Visibility filters ---- // ---- Visibility filters ----
fun setCalendarHidden(key: String, hidden: Boolean) { fun setCalendarHidden(key: String, hidden: Boolean) {