feat: surface per-calendar sync errors from /api/caldav/events
The server now reports per-calendar sync failures (e.g. expired credentials) alongside an otherwise-successful events response, via an "errors" array. Previously such failures were silently swallowed, so a calendar could appear enabled while showing zero events with no explanation. - Decode the new "errors" field into SyncError (source/name/message), following the existing JSONSerialization + from(json:) convention. - CalendarStore.syncErrors captures these on successful personal fetches (loadEvents, prefetchBackground); left untouched on a hard fetch failure so it isn't conflated with lastError. Group overlay fetches never populate it (the endpoint doesn't apply there). - CalendarHostView shows a second red warning banner alongside the existing lastError banner, reusing the same visual style.
This commit is contained in:
@@ -18,6 +18,26 @@ struct EventPerson: Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A partial-sync failure reported alongside an otherwise-successful
|
||||||
|
/// `/api/caldav/events` response: one specific calendar didn't sync (e.g.
|
||||||
|
/// expired credentials) even though it's still enabled. Distinct from a
|
||||||
|
/// hard fetch failure (`CalendarStore.lastError`) — the request itself
|
||||||
|
/// succeeded, just not every source within it.
|
||||||
|
struct SyncError: Hashable {
|
||||||
|
let source: String
|
||||||
|
let name: String
|
||||||
|
let message: String
|
||||||
|
|
||||||
|
static func from(json: [String: Any]) -> SyncError? {
|
||||||
|
guard
|
||||||
|
let source = json["source"] as? String,
|
||||||
|
let name = json["name"] as? String,
|
||||||
|
let message = json["message"] as? String
|
||||||
|
else { return nil }
|
||||||
|
return SyncError(source: source, name: name, message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CalEvent: Identifiable, Hashable {
|
struct CalEvent: Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let url: String
|
let url: String
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ class CalendarStore {
|
|||||||
var isLoading = false
|
var isLoading = false
|
||||||
var isCachingBackground = false
|
var isCachingBackground = false
|
||||||
var lastError: String? = nil
|
var lastError: String? = nil
|
||||||
|
/// Per-calendar sync failures reported alongside the last successful
|
||||||
|
/// personal `/events` fetch (e.g. expired CalDAV credentials on one
|
||||||
|
/// account) — distinct from `lastError`, which means the whole fetch
|
||||||
|
/// failed. Not populated in group-overlay mode. Left untouched on a hard
|
||||||
|
/// fetch failure (see `loadEvents`), so a stale-but-real warning doesn't
|
||||||
|
/// get wiped by an unrelated network hiccup.
|
||||||
|
var syncErrors: [SyncError] = []
|
||||||
var weekStartsOnMonday = true
|
var weekStartsOnMonday = true
|
||||||
var writableCalendars: [WritableCalendar] = []
|
var writableCalendars: [WritableCalendar] = []
|
||||||
// When set, the calendar shows the group's combined overlay instead of the
|
// When set, the calendar shows the group's combined overlay instead of the
|
||||||
@@ -340,10 +347,14 @@ class CalendarStore {
|
|||||||
lastError = nil
|
lastError = nil
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
let fetched = try await fetchForMode(api: api, start: start, end: end)
|
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
|
||||||
|
syncErrors = errors
|
||||||
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
||||||
refreshFromCache(start: start, end: end)
|
refreshFromCache(start: start, end: end)
|
||||||
} catch {
|
} catch {
|
||||||
|
// Hard failure – leave `syncErrors` as-is; it reflects the last
|
||||||
|
// *successful* fetch and shouldn't be wiped by an unrelated
|
||||||
|
// network error on this attempt.
|
||||||
lastError = error.localizedDescription
|
lastError = error.localizedDescription
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,10 +362,12 @@ class CalendarStore {
|
|||||||
/// Fetch events for the current mode (personal vs. group overlay). Group
|
/// Fetch events for the current mode (personal vs. group overlay). Group
|
||||||
/// events go through the same cache/prefetch/refresh path as personal ones,
|
/// events go through the same cache/prefetch/refresh path as personal ones,
|
||||||
/// so the whole visible grid is covered (no "only the middle weeks" gaps).
|
/// so the whole visible grid is covered (no "only the middle weeks" gaps).
|
||||||
private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> [CalEvent] {
|
/// Per-calendar sync errors are only reported by the personal `/events`
|
||||||
|
/// endpoint; group overlays always report none.
|
||||||
|
private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> (events: [CalEvent], errors: [SyncError]) {
|
||||||
if let g = activeGroup {
|
if let g = activeGroup {
|
||||||
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
|
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
|
||||||
return combined.map { decorateGroupEvent($0) }
|
return (combined.map { decorateGroupEvent($0) }, [])
|
||||||
}
|
}
|
||||||
return try await api.fetchEvents(start: start, end: end)
|
return try await api.fetchEvents(start: start, end: end)
|
||||||
}
|
}
|
||||||
@@ -394,7 +407,8 @@ class CalendarStore {
|
|||||||
isCachingBackground = true
|
isCachingBackground = true
|
||||||
defer { isCachingBackground = false }
|
defer { isCachingBackground = false }
|
||||||
do {
|
do {
|
||||||
let fetched = try await fetchForMode(api: api, start: start, end: end)
|
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end)
|
||||||
|
syncErrors = errors
|
||||||
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
||||||
// Refresh visible range from newly expanded cache
|
// Refresh visible range from newly expanded cache
|
||||||
let (vs, ve) = rangeForCurrentView()
|
let (vs, ve) = rangeForCurrentView()
|
||||||
|
|||||||
@@ -206,7 +206,12 @@ class CalendarrAPI {
|
|||||||
|
|
||||||
// MARK: – Events
|
// MARK: – Events
|
||||||
|
|
||||||
func fetchEvents(start: Date, end: Date) async throws -> [CalEvent] {
|
/// Fetches events for the personal calendar view. The server also reports
|
||||||
|
/// per-calendar sync failures (e.g. expired credentials) alongside an
|
||||||
|
/// otherwise-successful response, via the `errors` array — surfaced
|
||||||
|
/// separately from a hard fetch failure so the UI can distinguish
|
||||||
|
/// "everything failed" from "fetched fine, but calendar X didn't sync".
|
||||||
|
func fetchEvents(start: Date, end: Date) async throws -> (events: [CalEvent], errors: [SyncError]) {
|
||||||
// Use UTC with Z suffix – avoids '+' character which breaks URL query params
|
// Use UTC with Z suffix – avoids '+' character which breaks URL query params
|
||||||
let iso = ISO8601DateFormatter()
|
let iso = ISO8601DateFormatter()
|
||||||
iso.formatOptions = [.withInternetDateTime]
|
iso.formatOptions = [.withInternetDateTime]
|
||||||
@@ -224,7 +229,9 @@ class CalendarrAPI {
|
|||||||
let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data"
|
let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data"
|
||||||
throw APIError.serverError("Unerwartete Antwort: \(preview)")
|
throw APIError.serverError("Unerwartete Antwort: \(preview)")
|
||||||
}
|
}
|
||||||
return arr.compactMap { CalEvent.from(json: $0) }
|
let events = arr.compactMap { CalEvent.from(json: $0) }
|
||||||
|
let errors = (root["errors"] as? [[String: Any]])?.compactMap { SyncError.from(json: $0) } ?? []
|
||||||
|
return (events, errors)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||||
|
|||||||
@@ -284,10 +284,14 @@ struct CalendarHostView: View {
|
|||||||
.accessibilityLabel(L10n.t("nav.menu", appLang))
|
.accessibilityLabel(L10n.t("nav.menu", appLang))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Error banner
|
// MARK: – Error banners
|
||||||
|
|
||||||
|
// `lastError` (whole fetch failed) and `syncErrors` (fetch succeeded, but
|
||||||
|
// individual calendars didn't sync) are independent conditions and can
|
||||||
|
// both be shown at once.
|
||||||
@ViewBuilder private var errorBanner: some View {
|
@ViewBuilder private var errorBanner: some View {
|
||||||
if let err = store.lastError { errorBannerView(err) }
|
if let err = store.lastError { errorBannerView(err) }
|
||||||
|
if !store.syncErrors.isEmpty { syncErrorBannerView(store.syncErrors) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func errorBannerView(_ err: String) -> some View {
|
private func errorBannerView(_ err: String) -> some View {
|
||||||
@@ -303,6 +307,23 @@ struct CalendarHostView: View {
|
|||||||
.background(Color.red.opacity(0.85))
|
.background(Color.red.opacity(0.85))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One or more calendars didn't sync on the last fetch (e.g. expired
|
||||||
|
/// credentials) even though they're still enabled. Same visual language
|
||||||
|
/// as `errorBannerView`, joined into a single compact banner.
|
||||||
|
private func syncErrorBannerView(_ errors: [SyncError]) -> some View {
|
||||||
|
let text = errors.map { "\($0.source) (\($0.name)): \($0.message)" }.joined(separator: "\n")
|
||||||
|
return HStack(alignment: .top, spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
|
||||||
|
Text(text).font(.caption).foregroundStyle(.white).lineLimit(errors.count + 1)
|
||||||
|
Spacer()
|
||||||
|
Button { Task { await onNavigate() } } label: {
|
||||||
|
Image(systemName: "arrow.clockwise").foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||||
|
.background(Color.red.opacity(0.85))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Calendar content (with swipe)
|
// MARK: – Calendar content (with swipe)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|||||||
Reference in New Issue
Block a user