oficina / calendar Public

Native: a failed load keeps the events on screen (B11)

CalendarModel.load ran `events = []` on any error, so a dropped
connection or a 500 blanked a calendar that was showing a second
earlier; the Mac menu-bar panel shares the model, so opening it
offline blanked the window as well.

A failed load now keeps the last good calendars and events and sets
the error. `hasLoaded` distinguishes "never loaded" (the full-screen
error, as before) from "stale": the window shows a one-line banner
with the error and Try again above the day's events, and the menu
panel shows the same line above its agenda. clear() still wipes
everything on sign-out.

LoadFailureTests drives a python fixture server through fail /
recover / fail and asserts the events survive the second failure;
restoring `events = []` in the catch fails it at two assertions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev b99d284cffef1734080777267bfb6d99fb2c373a parent 9ebcd95
4 files changed, +109 −8
  • native/apple/Sources/CalendarKit/CalendarMenuView.swift +4 −2
  • native/apple/Sources/CalendarKit/CalendarModel.swift +12 −2
  • native/apple/Sources/CalendarKit/CalendarWorkspace.swift +19 −4
  • native/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.453s
?   	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	1.893s
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)
+ }
+}