Keep shown events when a native Calendar load fails
A failed load used to blank calendars and events already on screen (CalendarModel.swift:69), and the menu-bar panel reloads on every open, so it went blank too. Last good data now stays; a banner says it could not refresh and offers Try again. A first-ever failure still shows the full error. Sign out still clears everything. Reviewed by Fable 5.1 on discussion #15. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from fix/r1-native-keep-events-on-error into main. Co-Authored-By: Paul Campbell <paul@tito.io>
4 files changed,
+109
−8
native/apple/Sources/CalendarKit/CalendarMenuView.swift+4 −2native/apple/Sources/CalendarKit/CalendarModel.swift+12 −2native/apple/Sources/CalendarKit/CalendarWorkspace.swift+19 −4native/apple/Tests/CalendarKitTests/LoadFailureTests.swift+74 −0
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... ? amadan.net/oficina/calendar/cmd/calendar [no test files] ok amadan.net/oficina/calendar/internal/calendar 4.498s ? amadan.net/oficina/calendar/internal/calendar/icons [no test files] ? amadan.net/oficina/calendar/internal/calendar/seal [no test files] ok amadan.net/oficina/calendar/internal/calendartest 2.071s ok amadan.net/oficina/calendar/internal/guardstest 0.040s === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/native/apple/Sources/CalendarKit/CalendarMenuView.swift b/native/apple/Sources/CalendarKit/CalendarMenuView.swift| index 29048fa..cf4e7c1 100644 |
| --- a/native/apple/Sources/CalendarKit/CalendarMenuView.swift |
| +++ b/native/apple/Sources/CalendarKit/CalendarMenuView.swift |
| @@ -25,7 +25,7 @@ public struct CalendarMenuView: View { |
| HStack { |
| Text(model.display(model.date, template: "EEEE d MMMM")).font(.headline) |
| Spacer() |
| - if !model.loading && model.error.isEmpty { |
| + if !model.loading && (model.error.isEmpty || model.hasLoaded) { |
| Text("\(model.dayEvents(model.date).count)").foregroundStyle(.secondary) |
| .accessibilityLabel("\(model.dayEvents(model.date).count) events") |
| } |
| @@ -36,9 +36,11 @@ public struct CalendarMenuView: View { |
| Color.clear.frame(height: 0).id("agendaTop") |
| if model.loading { ProgressView("Loading events…") } |
| if !model.error.isEmpty { |
| + if model.hasLoaded { Label("Couldn’t refresh. Showing what was last loaded.", systemImage: "wifi.exclamationmark").font(.callout) } |
| Text(model.error).foregroundStyle(.secondary) |
| Button("Try again") { Task { await model.load(client) } } |
| - } else if model.dayEvents(model.date).isEmpty && !model.loading { |
| + } |
| + if (model.error.isEmpty || model.hasLoaded) && model.dayEvents(model.date).isEmpty && !model.loading { |
| Text("No events on this day").foregroundStyle(.secondary) |
| } |
| ForEach(model.dayEvents(model.date), id: \.identity) { event in |
diff --git a/native/apple/Sources/CalendarKit/CalendarModel.swift b/native/apple/Sources/CalendarKit/CalendarModel.swift| index 4bfc9b5..6b2d0f8 100644 |
| --- a/native/apple/Sources/CalendarKit/CalendarModel.swift |
| +++ b/native/apple/Sources/CalendarKit/CalendarModel.swift |
| @@ -10,6 +10,11 @@ import RastrilloApple |
| @Published public private(set) var zone = "UTC" |
| @Published public private(set) var loading = false |
| @Published public var error = "" |
| + /// True once a load has succeeded in this session. A later failure |
| + /// then keeps what is on screen and reports the error beside it: a |
| + /// network blip must not blank a calendar that was showing a second |
| + /// ago. Only a session that has never loaded shows the error alone. |
| + @Published public private(set) var hasLoaded = false |
| @Published public private(set) var notice = "" |
| private var weekStart = 1 |
| private var defaultCalendar: Int64 = 0 |
| @@ -45,7 +50,7 @@ import RastrilloApple |
| } |
| public func clear() { |
| active = false; quickText = ""; quickSavedNotice = ""; quickSaving = false |
| - generation = UUID(); calendars = []; events = []; notice = ""; error = ""; loading = false |
| + generation = UUID(); calendars = []; events = []; hasLoaded = false; notice = ""; error = ""; loading = false |
| selectedCalendar = nil; date = Date(); zone = fixedZone ?? Self.preferredZone |
| } |
| public func dayEvents(_ day: Date) -> [CalendarEvent] { |
| @@ -66,7 +71,12 @@ import RastrilloApple |
| let response: CalendarEvents = try await client.request("/api/native/events?from=\(from)&to=\(to)&zone=\(zone.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? zone)") |
| guard generation == token else { return } |
| events = response.events |
| - } catch { if generation == token { events = []; self.error = error.localizedDescription } } |
| + hasLoaded = true |
| + } catch { |
| + // Keep the last good calendars and events: they are stale, not |
| + // wrong, and the error says so beside them. |
| + if generation == token { self.error = error.localizedDescription } |
| + } |
| } |
| public func newEvent() -> CalendarEvent { |
| var event = CalendarEvent() |
diff --git a/native/apple/Sources/CalendarKit/CalendarWorkspace.swift b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift| index 9c1a218..d8b5a1a 100644 |
| --- a/native/apple/Sources/CalendarKit/CalendarWorkspace.swift |
| +++ b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift |
| @@ -42,10 +42,25 @@ import SwiftUI |
| if !model.notice.isEmpty { Label(model.notice, systemImage: "info.circle").font(.callout).padding() } |
| if model.loading { ProgressView().padding(8) } |
| if !model.error.isEmpty { |
| - ContentUnavailableView("Couldn’t load your calendar", systemImage: "wifi.exclamationmark", |
| - description: Text(model.error)) |
| - Button("Try again") { Task { await model.load(client) } }.padding() |
| - } else { |
| + if model.hasLoaded { |
| + // Stale, not gone: say so above what was last loaded. |
| + HStack(alignment: .firstTextBaseline, spacing: 10) { |
| + Label { |
| + VStack(alignment: .leading, spacing: 2) { |
| + Text("Couldn’t refresh. Showing what was last loaded.").font(.callout) |
| + Text(model.error).font(.caption).foregroundStyle(.secondary) |
| + } |
| + } icon: { Image(systemName: "wifi.exclamationmark") } |
| + Spacer() |
| + Button("Try again") { Task { await model.load(client) } } |
| + }.padding(.vertical, 6).accessibilityElement(children: .contain).accessibilityIdentifier("staleBanner") |
| + } else { |
| + ContentUnavailableView("Couldn’t load your calendar", systemImage: "wifi.exclamationmark", |
| + description: Text(model.error)) |
| + Button("Try again") { Task { await model.load(client) } }.padding() |
| + } |
| + } |
| + if model.error.isEmpty || model.hasLoaded { |
| Section(model.display(model.date, template: "EEEE d MMMM")) { |
| let events = model.dayEvents(model.date) |
| if events.isEmpty { |
diff --git a/native/apple/Tests/CalendarKitTests/LoadFailureTests.swift b/native/apple/Tests/CalendarKitTests/LoadFailureTests.swift| new file mode 100644 |
| index 0000000..2188767 |
| --- /dev/null |
| +++ b/native/apple/Tests/CalendarKitTests/LoadFailureTests.swift |
| @@ -0,0 +1,74 @@ |
| +import XCTest |
| +import RastrilloApple |
| +@testable import CalendarKit |
| + |
| +// B11 (polish-audit PLAN): a failed load used to run `events = []`, so |
| +// any blip — a dropped connection, a 500, going offline — blanked a |
| +// calendar that was on screen a second earlier, and the menu-bar panel |
| +// shares the model, so opening it offline blanked the window too. |
| +final class LoadFailureTests: XCTestCase { |
| + @MainActor func testFailedReloadKeepsLastGoodEventsAndReportsTheError() async throws { |
| + let process = Process() |
| + process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") |
| + process.arguments = ["-u", "-c", """ |
| +import http.server, json |
| +failing=False |
| +event={'id':7,'calendar_id':1,'status':'confirmed','title':'Standup','location':'','notes':'','all_day':False, |
| + 'start_local':'2030-10-25T09:00','end_local':'2030-10-25T09:30','tz':'Europe/Dublin','start':1919232000,'end':1919233800, |
| + 'recurring':False,'writable':True,'calendar_name':'Personal','occurrence':'','day_start':'2030-10-25','day_end':'2030-10-25'} |
| +class Handler(http.server.BaseHTTPRequestHandler): |
| + def log_message(self,*args): pass |
| + def reply(self,code,value): |
| + data=json.dumps(value).encode(); self.send_response(code); self.send_header('Content-Type','application/json'); self.send_header('Content-Length',str(len(data))); self.end_headers(); self.wfile.write(data) |
| + def do_GET(self): |
| + global failing |
| + if self.path=='/fail': failing=True; self.reply(200,{}); return |
| + if self.path=='/recover': failing=False; self.reply(200,{}); return |
| + if failing: self.reply(500,{'error':'fixture outage'}); return |
| + if self.path.startswith('/api/native/session'): |
| + self.reply(200,{'week_start':1,'zone':'Europe/Dublin','calendars':[{'id':1,'name':'Personal','hue':200,'writable':True}],'default_calendar_id':1}); return |
| + if self.path.startswith('/api/native/events'): self.reply(200,{'events':[event]}); return |
| + self.reply(404,{}) |
| +server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Handler) |
| +print(server.server_port,flush=True); server.serve_forever() |
| +"""] |
| + let output = Pipe(); process.standardOutput = output; process.standardError = FileHandle.nullDevice |
| + try process.run() |
| + defer { process.terminate() } |
| + let data = output.fileHandleForReading.availableData |
| + let port = try XCTUnwrap(String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)) |
| + let client = NativeHTTP(origin: URL(string: "http://127.0.0.1:\(port)")!) |
| + let model = CalendarModel(zone: "Europe/Dublin", date: Date(timeIntervalSince1970: 1919232000)) |
| + |
| + // A first load that FAILS has nothing to keep: the error stands alone. |
| + _ = try await client.response("/fail") |
| + await model.load(client) |
| + XCTAssertFalse(model.error.isEmpty) |
| + XCTAssertFalse(model.hasLoaded) |
| + XCTAssertTrue(model.events.isEmpty) |
| + |
| + // It recovers, and the day shows its event. |
| + _ = try await client.response("/recover") |
| + await model.load(client) |
| + XCTAssertTrue(model.error.isEmpty) |
| + XCTAssertTrue(model.hasLoaded) |
| + XCTAssertEqual(model.events.map(\.title), ["Standup"]) |
| + XCTAssertEqual(model.calendars.map(\.name), ["Personal"]) |
| + |
| + // Then the server goes away. The refresh fails, the error is |
| + // reported, and what was on screen stays on screen. |
| + _ = try await client.response("/fail") |
| + await model.load(client) |
| + XCTAssertFalse(model.error.isEmpty, "a failed refresh must still say it failed") |
| + XCTAssertTrue(model.hasLoaded) |
| + XCTAssertFalse(model.loading) |
| + XCTAssertEqual(model.events.map(\.title), ["Standup"], "a failed refresh blanked the events on screen") |
| + XCTAssertEqual(model.calendars.map(\.name), ["Personal"]) |
| + XCTAssertEqual(model.dayEvents(model.date).map(\.title), ["Standup"]) |
| + |
| + // Signing out is the one thing that does clear it. |
| + model.clear() |
| + XCTAssertFalse(model.hasLoaded) |
| + XCTAssertTrue(model.events.isEmpty) |
| + } |
| +} |