From 8ed474663e4d3b35a208f59d9e4bd6b443b0ce0c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 2 Jul 2026 18:25:47 +0200 Subject: [PATCH] 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. --- Calendarr iOS/Models/CalEvent.swift | 20 ++++++++++++++++ Calendarr iOS/Models/CalendarStore.swift | 22 ++++++++++++++---- Calendarr iOS/Services/CalendarrAPI.swift | 11 +++++++-- .../Views/Calendar/CalendarHostView.swift | 23 ++++++++++++++++++- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index 3120d11..b3669d8 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -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 { let id: String let url: String diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index ed2592d..a9e7468 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -58,6 +58,13 @@ class CalendarStore { var isLoading = false var isCachingBackground = false 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 writableCalendars: [WritableCalendar] = [] // When set, the calendar shows the group's combined overlay instead of the @@ -340,10 +347,14 @@ class CalendarStore { lastError = nil defer { isLoading = false } 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) refreshFromCache(start: start, end: end) } 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 } } @@ -351,10 +362,12 @@ class CalendarStore { /// Fetch events for the current mode (personal vs. group overlay). Group /// 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). - 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 { 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) } @@ -394,7 +407,8 @@ class CalendarStore { isCachingBackground = true defer { isCachingBackground = false } 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) // Refresh visible range from newly expanded cache let (vs, ve) = rangeForCurrentView() diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index 8ffdc22..6f82672 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -206,7 +206,12 @@ class CalendarrAPI { // 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 let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] @@ -224,7 +229,9 @@ class CalendarrAPI { let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data" 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, diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index f6401e2..36915d0 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -284,10 +284,14 @@ struct CalendarHostView: View { .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 { if let err = store.lastError { errorBannerView(err) } + if !store.syncErrors.isEmpty { syncErrorBannerView(store.syncErrors) } } private func errorBannerView(_ err: String) -> some View { @@ -303,6 +307,23 @@ struct CalendarHostView: View { .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) @ViewBuilder