Preserve Calendar status and confirm partial reminder saves
Co-Authored-By: Codex <codex~paul@keymail.dev>
6 files changed,
+100
−5
internal/calendar/native.go+3 −2internal/calendar/native_write_test.go+78 −0native/REVIEW.md+8 −0native/apple/Sources/CalendarKit/CalendarModel.swift+3 −1native/apple/Sources/CalendarKit/CalendarModels.swift+2 −1native/apple/Sources/CalendarKit/CalendarWorkspace.swift+6 −1
diff --git a/internal/calendar/native.go b/internal/calendar/native.go| index 2c2c807..31d64cd 100644 |
| --- a/internal/calendar/native.go |
| +++ b/internal/calendar/native.go |
| @@ -74,6 +74,7 @@ type nativeEvent struct { |
| Occurrence string `json:"occurrence"` |
| DayStart string `json:"day_start"` |
| DayEnd string `json:"day_end"` |
| + Status string `json:"status"` |
| } |
| func (h *handlers) nativeEvents(w http.ResponseWriter, r *http.Request) { |
| @@ -114,7 +115,7 @@ func (h *handlers) nativeEvents(w http.ResponseWriter, r *http.Request) { |
| continue |
| } |
| recurring := ev.RRule != "" || ev.SeriesID != nil |
| - out = append(out, nativeEvent{ev.ID, ev.CalendarID, ev.Title, ev.Location, ev.Notes, ev.AllDay, ev.StartLocal, ev.EndLocal, ev.TZ, ev.OccStartUTC, ev.OccEndUTC, recurring, ev.Writable() && !recurring && !booked, ev.CalendarName, ev.OccurrenceLocal, dayStart, dayEnd}) |
| + out = append(out, nativeEvent{ev.ID, ev.CalendarID, ev.Title, ev.Location, ev.Notes, ev.AllDay, ev.StartLocal, ev.EndLocal, ev.TZ, ev.OccStartUTC, ev.OccEndUTC, recurring, ev.Writable() && !recurring && !booked, ev.CalendarName, ev.OccurrenceLocal, dayStart, dayEnd, ev.Status}) |
| } |
| nativeJSON(w, map[string]any{"events": out}) |
| } |
| @@ -167,7 +168,7 @@ func (h *handlers) nativeSave(w http.ResponseWriter, r *http.Request) { |
| } |
| id = ev.ID |
| if err := h.store.EnsureDefaultReminder(r.Context(), p, ev); err != nil { |
| - http.Error(w, "Event saved, but its reminder could not be scheduled. Refresh before retrying.", 500) |
| + nativeJSON(w, map[string]any{"id": id, "warning": "Event saved, but its reminder could not be scheduled."}) |
| return |
| } |
| } |
diff --git a/internal/calendar/native_write_test.go b/internal/calendar/native_write_test.go| new file mode 100644 |
| index 0000000..36a1885 |
| --- /dev/null |
| +++ b/internal/calendar/native_write_test.go |
| @@ -0,0 +1,78 @@ |
| +package calendar |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "net/http/httptest" |
| + "testing" |
| +) |
| + |
| +func TestNativePreservesStatusAndGapWallTime(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + p, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cal, err := f.app.store.DefaultCalendarID(t.Context(), p.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, status := range []string{StatusCancelled, StatusTentative} { |
| + _, err = f.app.store.CreateEvent(t.Context(), p.ID, EventInput{CalendarID: cal, Title: "Gap meeting", TZ: "Europe/Dublin", StartLocal: "2026-03-29T01:30", EndLocal: "2026-03-29T03:30", Kind: KindMeeting, Status: status}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + w := f.do("GET", "/api/native/events?from=2026-03-29&to=2026-03-30", "") |
| + var got struct{ Events []nativeEvent } |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got.Events) != 2 { |
| + t.Fatal("missing status events") |
| + } |
| + statuses := map[string]bool{} |
| + for _, ev := range got.Events { |
| + statuses[ev.Status] = true |
| + if ev.StartLocal != "2026-03-29T01:30" || ev.EndLocal != "2026-03-29T03:30" { |
| + t.Fatal("gap wall time changed") |
| + } |
| + } |
| + if !statuses[StatusCancelled] || !statuses[StatusTentative] { |
| + t.Fatal("native API dropped cancellation or tentative state") |
| + } |
| +} |
| + |
| +func TestNativeSavedEventSurvivesReminderFailure(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + p, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cal, err := f.app.store.DefaultCalendarID(t.Context(), p.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = f.app.db.Writer().Exec("CREATE TRIGGER native_reminder_failure BEFORE INSERT ON reminders BEGIN SELECT RAISE(FAIL, 'fixture failure'); END"); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, _ := json.Marshal(nativeEvent{CalendarID: cal, Title: "Saved event", TZ: "Europe/Dublin", StartLocal: "2026-10-24T09:00", EndLocal: "2026-10-24T10:00"}) |
| + r := httptest.NewRequest("POST", homeTestOrigin+"/api/native/events", bytes.NewReader(body)) |
| + r.Header.Set("Origin", homeTestOrigin) |
| + r.Header.Set("Content-Type", "application/json") |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.app.mux.ServeHTTP(w, r) |
| + var got struct { |
| + ID int64 |
| + Warning string |
| + } |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || got.ID == 0 || got.Warning == "" { |
| + t.Fatal("saved event was not confirmed separately from failed reminder", w.Code) |
| + } |
| + ev, err := f.app.store.Event(t.Context(), p.ID, got.ID) |
| + if err != nil || ev.Title != "Saved event" { |
| + t.Fatal("confirmed event was not stored") |
| + } |
| +} |
diff --git a/native/REVIEW.md b/native/REVIEW.md| index d2408c1..ab3772f 100644 |
| --- a/native/REVIEW.md |
| +++ b/native/REVIEW.md |
| @@ -17,3 +17,11 @@ Mac and iPhone/iPad screens were rendered from the actual Swift clients against |
| an authenticated Go fixture. Native approval/exchange creates the fixture client |
| credential. Memoria's available Apple Foundation Model generated a response on |
| this Mac. Calendar's three Swift civil-date tests and both Go CI suites pass. |
| + |
| +Final review was separately dispatched as `gpt-6-astra` with high reasoning, |
| +without claiming independent evidence of runtime model identity. It found |
| +cancelled/tentative status missing from Calendar's DTO and UI. The follow-up |
| +carries status and labels it explicitly; regression tests pin both states. |
| +A confirmed event with a failed reminder now returns its saved id and a warning, |
| +so the native editor closes successfully instead of inviting a duplicate retry. |
| +A database-trigger failure test verifies that persisted event and warning. |
diff --git a/native/apple/Sources/CalendarKit/CalendarModel.swift b/native/apple/Sources/CalendarKit/CalendarModel.swift| index 19321bb..1308aa6 100644 |
| --- a/native/apple/Sources/CalendarKit/CalendarModel.swift |
| +++ b/native/apple/Sources/CalendarKit/CalendarModel.swift |
| @@ -10,6 +10,7 @@ import RastrilloApple |
| @Published public private(set) var zone = "UTC" |
| @Published public private(set) var loading = false |
| @Published public var error = "" |
| + @Published public private(set) var notice = "" |
| private var weekStart = 1 |
| private var defaultCalendar: Int64 = 0 |
| private var generation = UUID() |
| @@ -60,8 +61,9 @@ import RastrilloApple |
| return event |
| } |
| public func save(_ event: CalendarEvent, client: NativeHTTP) async throws { |
| - let _: SavedEvent = try await client.request(event.id == 0 ? "/api/native/events" : "/api/native/events/\(event.id)", method: "POST", body: JSONEncoder().encode(event)) |
| + let saved: SavedEvent = try await client.request(event.id == 0 ? "/api/native/events" : "/api/native/events/\(event.id)", method: "POST", body: JSONEncoder().encode(event)) |
| await load(client) |
| + notice = saved.warning ?? "" |
| } |
| public func format(_ date: Date, allDay: Bool, zone: String? = nil) -> String { |
| let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX") |
diff --git a/native/apple/Sources/CalendarKit/CalendarModels.swift b/native/apple/Sources/CalendarKit/CalendarModels.swift| index 1723839..b635e4c 100644 |
| --- a/native/apple/Sources/CalendarKit/CalendarModels.swift |
| +++ b/native/apple/Sources/CalendarKit/CalendarModels.swift |
| @@ -15,6 +15,7 @@ struct CalendarSession: Decodable { |
| public struct CalendarEvent: Codable, Identifiable { |
| public var id: Int64 = 0 |
| public var calendar_id: Int64 = 0 |
| + public var status = "confirmed" |
| public var title = "" |
| public var location = "" |
| public var notes = "" |
| @@ -45,4 +46,4 @@ public struct CalendarEvent: Codable, Identifiable { |
| public init() {} |
| } |
| struct CalendarEvents: Decodable { let events: [CalendarEvent] } |
| -struct SavedEvent: Decodable { let id: Int64 } |
| +struct SavedEvent: Decodable { let id: Int64; let warning: String? } |
diff --git a/native/apple/Sources/CalendarKit/CalendarWorkspace.swift b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift| index 0e2819b..fac4088 100644 |
| --- a/native/apple/Sources/CalendarKit/CalendarWorkspace.swift |
| +++ b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift |
| @@ -33,6 +33,7 @@ import SwiftUI |
| VStack(spacing: 0) { |
| monthHeader.padding() |
| monthGrid.padding(.horizontal) |
| + if !model.notice.isEmpty { Label(model.notice, systemImage: "bell.badge").font(.callout).padding() } |
| if model.loading { ProgressView().padding(8) } |
| if !model.error.isEmpty { |
| ContentUnavailableView("Couldn’t load your calendar", systemImage: "wifi.exclamationmark", |
| @@ -53,7 +54,9 @@ import SwiftUI |
| HStack(alignment: .top, spacing: 14) { |
| RoundedRectangle(cornerRadius: 2).fill(.tint).frame(width: 4) |
| VStack(alignment: .leading, spacing: 5) { |
| - Text(event.title).font(.headline).foregroundStyle(.primary) |
| + Text(event.title).font(.headline).foregroundStyle(.primary).strikethrough(event.status == "cancelled") |
| + if event.status == "cancelled" { Label("Cancelled", systemImage: "xmark.circle").font(.caption).foregroundStyle(.secondary) } |
| + if event.status == "tentative" { Label("Tentative", systemImage: "questionmark.circle").font(.caption).foregroundStyle(.secondary) } |
| Text(event.calendar_name).font(.caption).foregroundStyle(.secondary) |
| if !event.location.isEmpty { Label(event.location, systemImage: "mappin").font(.callout).foregroundStyle(.secondary) } |
| } |
| @@ -85,6 +88,8 @@ import SwiftUI |
| NavigationStack { |
| Form { |
| Section { Text(event.title).font(.title2.bold()); Text(event.calendar_name).foregroundStyle(.secondary) } |
| + if event.status == "cancelled" { Section { Label("Cancelled", systemImage: "xmark.circle") } } |
| + if event.status == "tentative" { Section { Label("Tentative", systemImage: "questionmark.circle") } } |
| Section("When") { |
| LabeledContent("Starts", value: event.all_day ? event.day_start : model.display(Date(timeIntervalSince1970: Double(event.start)), template: event.all_day ? "d MMM yyyy" : "d MMM yyyy jm", zone: event.tz)) |
| LabeledContent("Ends", value: event.all_day ? event.day_end : model.display(Date(timeIntervalSince1970: Double(event.end)), template: event.all_day ? "d MMM yyyy" : "d MMM yyyy jm", zone: event.tz)) |