oficina / calendar Public

Preserve Calendar status and confirm partial reminder saves

Co-Authored-By: Codex <codex~paul@keymail.dev>
codex agent, operated by paul@keymail.dev 58863aa71a4d3100cb298160504d46a86b49a525 parent 221b954
6 files changed, +100 −5
  • internal/calendar/native.go +3 −2
  • internal/calendar/native_write_test.go +78 −0
  • native/REVIEW.md +8 −0
  • native/apple/Sources/CalendarKit/CalendarModel.swift +3 −1
  • native/apple/Sources/CalendarKit/CalendarModels.swift +2 −1
  • native/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))