Add native quick-add, persistent menu calendar and system time zones
Co-Authored-By: Codex <codex~paul@keymail.dev>
15 files changed,
+728
−71
internal/calendar/native.go+7 −2internal/calendar/native_quick.go+140 −0internal/calendar/native_quick_test.go+133 −0internal/calendar/routes.go+2 −0native/README.md+36 −0native/apple/Sources/CalendarApp/CalendarApp.swift+17 −2native/apple/Sources/CalendarKit/CalendarMenuView.swift+59 −0native/apple/Sources/CalendarKit/CalendarModel.swift+36 −3native/apple/Sources/CalendarKit/CalendarMonthView.swift+73 −0native/apple/Sources/CalendarKit/CalendarSettingsView.swift+29 −0native/apple/Sources/CalendarKit/CalendarWorkspace.swift+22 −62native/apple/Sources/CalendarKit/QuickAddView.swift+81 −0native/apple/Sources/CalendarReview/Review.swift+6 −2native/apple/Tests/CalendarKitTests/SessionLifecycleTests.swift+73 −0native/ios/UITests/NativeUITests.swift+14 −0
diff --git a/internal/calendar/native.go b/internal/calendar/native.go| index 31d64cd..2606d8d 100644 |
| --- a/internal/calendar/native.go |
| +++ b/internal/calendar/native.go |
| @@ -79,8 +79,13 @@ type nativeEvent struct { |
| func (h *handlers) nativeEvents(w http.ResponseWriter, r *http.Request) { |
| p, _ := CurrentPerson(r) |
| - from, e := ParseDate(r.URL.Query().Get("from"), p.Zone) |
| - to, e2 := ParseDate(r.URL.Query().Get("to"), p.Zone) |
| + zone, ok := nativeViewZone(r.URL.Query().Get("zone"), personZone(p)) |
| + if !ok { |
| + http.Error(w, "Choose a valid time zone", 422) |
| + return |
| + } |
| + from, e := ParseDate(r.URL.Query().Get("from"), zone) |
| + to, e2 := ParseDate(r.URL.Query().Get("to"), zone) |
| if e != nil || e2 != nil || !to.After(from) || to.After(from.AddDate(0, 0, 62)) { |
| http.Error(w, "Choose a range of at most 62 days", 400) |
| return |
diff --git a/internal/calendar/native_quick.go b/internal/calendar/native_quick.go| new file mode 100644 |
| index 0000000..a174ff6 |
| --- /dev/null |
| +++ b/internal/calendar/native_quick.go |
| @@ -0,0 +1,140 @@ |
| +package calendar |
| + |
| +import ( |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "strings" |
| +) |
| + |
| +// Native quick-add uses the same pure parser as the web. The preview's concrete |
| +// civil dates are saved verbatim, rather than reinterpreting “tomorrow” later. |
| +type nativeQuickDraft struct { |
| + CalendarID int64 `json:"calendar_id"` |
| + Title string `json:"title"` |
| + Location string `json:"location"` |
| + StartLocal string `json:"start_local"` |
| + EndLocal string `json:"end_local"` |
| + TZ string `json:"tz"` |
| + AllDay bool `json:"all_day"` |
| + Repeat string `json:"repeat"` |
| + Attendees []string `json:"attendees"` |
| +} |
| +type nativeQuickPreview struct { |
| + Draft nativeQuickDraft `json:"draft"` |
| + When string `json:"when"` |
| + RepeatLabel string `json:"repeat_label"` |
| + CalendarName string `json:"calendar_name"` |
| + Assumptions []string `json:"assumptions"` |
| +} |
| + |
| +func decodeNativeQuick(w http.ResponseWriter, r *http.Request, value any) bool { |
| + r.Body = http.MaxBytesReader(w, r.Body, 32<<10) |
| + d := json.NewDecoder(r.Body) |
| + d.DisallowUnknownFields() |
| + if d.Decode(value) != nil || d.Decode(new(any)) != io.EOF { |
| + http.Error(w, "Invalid quick-add", 400) |
| + return false |
| + } |
| + return true |
| +} |
| +func (h *handlers) nativeQuickParse(w http.ResponseWriter, r *http.Request) { |
| + var in struct { |
| + Text string `json:"text"` |
| + Zone string `json:"zone"` |
| + } |
| + if !decodeNativeQuick(w, r, &in) { |
| + return |
| + } |
| + text := strings.TrimSpace(in.Text) |
| + if text == "" || len(text) > 4096 { |
| + http.Error(w, "Enter an event in no more than 4096 bytes", 422) |
| + return |
| + } |
| + p, _ := CurrentPerson(r) |
| + zone, ok := nativeViewZone(in.Zone, personZone(p)) |
| + if !ok { |
| + http.Error(w, "Choose a valid time zone", 422) |
| + return |
| + } |
| + q := ParseQuick(text, nowIn(zone), zone) |
| + id, err := h.store.DefaultCalendarID(r.Context(), p.ID) |
| + if err != nil || id == 0 { |
| + http.Error(w, "Cannot load default calendar", 500) |
| + return |
| + } |
| + calendars, err := h.store.Calendars(r.Context(), p.ID) |
| + if err != nil { |
| + http.Error(w, "Cannot load calendars", 500) |
| + return |
| + } |
| + name := "" |
| + for _, c := range calendars { |
| + if c.ID == id { |
| + name = c.Name |
| + } |
| + } |
| + title := q.Title |
| + if title == "" { |
| + title = "Untitled" |
| + } |
| + attendees := q.Attendees |
| + if attendees == nil { |
| + attendees = []string{} |
| + } |
| + notes := q.Notes |
| + if notes == nil { |
| + notes = []string{} |
| + } |
| + pane := quickPaneFor(q, zone) |
| + nativeJSON(w, nativeQuickPreview{nativeQuickDraft{id, title, q.Location, q.StartLocal, q.EndLocal, zone, q.AllDay, q.Repeat, attendees}, pane.When, pane.Repeat, name, notes}) |
| +} |
| +func (h *handlers) nativeQuickSave(w http.ResponseWriter, r *http.Request) { |
| + var in nativeQuickDraft |
| + if !decodeNativeQuick(w, r, &in) { |
| + return |
| + } |
| + switch in.Repeat { |
| + case "", RepeatNone, RepeatDaily, RepeatWeekdays, RepeatWeekly, RepeatFortnight, RepeatMonthly, RepeatYearly: |
| + default: |
| + http.Error(w, "Unsupported repeat choice", 422) |
| + return |
| + } |
| + if len(in.Attendees) > 100 { |
| + http.Error(w, "Too many attendees", 422) |
| + return |
| + } |
| + p, _ := CurrentPerson(r) |
| + // CreateEvent remains the grant/validation authority. No calendar ownership, |
| + // author identity, status or raw recurrence rule is accepted from the client. |
| + ev, err := h.store.CreateEvent(r.Context(), p.ID, EventInput{CalendarID: in.CalendarID, Title: in.Title, Location: in.Location, StartLocal: in.StartLocal, EndLocal: in.EndLocal, TZ: in.TZ, AllDay: in.AllDay, Kind: KindMeeting, Status: StatusConfirmed, RRule: RuleFromRepeat(in.Repeat, "", in.StartLocal, in.TZ, in.AllDay)}) |
| + if err != nil { |
| + nativeEventError(w, r, err) |
| + return |
| + } |
| + warnings := []string{} |
| + if len(in.Attendees) > 0 { |
| + if err := h.store.SetAttendees(r.Context(), ev.ID, in.Attendees); err != nil { |
| + warnings = append(warnings, "Attendees could not be recorded.") |
| + } |
| + } |
| + if err := h.store.EnsureDefaultReminder(r.Context(), p, ev); err != nil { |
| + warnings = append(warnings, "Its reminder could not be scheduled.") |
| + } |
| + result := map[string]any{"id": ev.ID} |
| + if len(warnings) > 0 { |
| + result["warning"] = "Event saved. " + strings.Join(warnings, " ") |
| + } |
| + nativeJSON(w, result) |
| +} |
| + |
| +func nativeViewZone(requested, fallback string) (string, bool) { |
| + if requested == "" { |
| + requested = fallback |
| + } |
| + if len(requested) > 128 { |
| + return "", false |
| + } |
| + _, err := Zone(requested) |
| + return requested, err == nil |
| +} |
diff --git a/internal/calendar/native_quick_test.go b/internal/calendar/native_quick_test.go| new file mode 100644 |
| index 0000000..49d330a |
| --- /dev/null |
| +++ b/internal/calendar/native_quick_test.go |
| @@ -0,0 +1,133 @@ |
| +package calendar |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestNativeQuickPreviewAndSave(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + call := func(path string, value any) *httptest.ResponseRecorder { |
| + body, _ := json.Marshal(value) |
| + r := httptest.NewRequest("POST", homeTestOrigin+path, 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) |
| + return w |
| + } |
| + input := map[string]string{"text": "Lunch tomorrow at 1pm for an hour with bob@example.test", "zone": "Pacific/Kiritimati"} |
| + if call("/api/native/quick/parse", input).Code == 200 { |
| + t.Fatal("anonymous preview") |
| + } |
| + if call("/api/native/quick", nativeQuickDraft{}).Code == 200 { |
| + t.Fatal("anonymous save") |
| + } |
| + f.local(t) |
| + f.cookies = nativeClientCookies(t, f.app.mux, f.cookies) |
| + var preview nativeQuickPreview |
| + w := call("/api/native/quick/parse", input) |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &preview) != nil { |
| + t.Fatal("preview failed", w.Code) |
| + } |
| + loc, _ := Zone(input["zone"]) |
| + expected := ParseQuick(input["text"], time.Now().In(loc), input["zone"]) |
| + if preview.Draft.StartLocal != expected.StartLocal || preview.Draft.TZ != input["zone"] || preview.Draft.Title != expected.Title || len(preview.Draft.Attendees) != 1 { |
| + t.Fatal("preview diverged from existing parser or requested zone") |
| + } |
| + p, err := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + personal, _ := f.app.store.DefaultCalendarID(t.Context(), p.ID) |
| + if preview.Draft.CalendarID != personal || preview.CalendarName == "" || preview.When == "" { |
| + t.Fatal("preview missing personal calendar or civil time") |
| + } |
| + // Save concrete dates from the preview, not a later interpretation of tomorrow. |
| + preview.Draft.StartLocal = "2026-10-25T09:00" |
| + preview.Draft.EndLocal = "2026-10-25T10:00" |
| + preview.Draft.Repeat = RepeatWeekly |
| + w = call("/api/native/quick", preview.Draft) |
| + var saved struct{ ID int64 } |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &saved) != nil || saved.ID == 0 { |
| + t.Fatal("save failed", w.Code) |
| + } |
| + ev, err := f.app.store.Event(t.Context(), p.ID, saved.ID) |
| + if err != nil || ev.StartLocal != preview.Draft.StartLocal || ev.TZ != preview.Draft.TZ || ev.RRule != "FREQ=WEEKLY" { |
| + t.Fatal("saved meaning changed") |
| + } |
| + attendees, err := f.app.store.AttendeesFor(t.Context(), saved.ID) |
| + if err != nil || len(attendees) != 1 || attendees[0] != "bob@example.test" { |
| + t.Fatal("attendees not recorded") |
| + } |
| + preview.Draft.CalendarID = 999999 |
| + if call("/api/native/quick", preview.Draft).Code == 200 { |
| + t.Fatal("unauthorized calendar accepted") |
| + } |
| + preview.Draft.CalendarID = personal |
| + preview.Draft.Repeat = "unsupported" |
| + if call("/api/native/quick", preview.Draft).Code != 422 { |
| + t.Fatal("unsupported repeat silently lost") |
| + } |
| + input["zone"] = "invalid/not-a-zone" |
| + if call("/api/native/quick/parse", input).Code != 422 { |
| + t.Fatal("invalid zone accepted") |
| + } |
| +} |
| + |
| +func TestNativeViewingZoneUsesRequestedCivilDay(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + p, _ := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test") |
| + cal, _ := f.app.store.DefaultCalendarID(t.Context(), p.ID) |
| + _, err := f.app.store.CreateEvent(t.Context(), p.ID, EventInput{CalendarID: cal, Title: "Across midnight", StartLocal: "2026-10-24T12:00", EndLocal: "2026-10-24T13:00", TZ: "UTC", Kind: KindMeeting, Status: StatusConfirmed}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tc := range []struct { |
| + zone string |
| + count int |
| + }{{"Pacific/Kiritimati", 1}, {"America/Los_Angeles", 0}} { |
| + w := f.do("GET", "/api/native/events?from=2026-10-25&to=2026-10-26&zone="+tc.zone, "") |
| + var response struct{ Events []nativeEvent } |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &response) != nil || len(response.Events) != tc.count { |
| + t.Fatalf("civil query wrong for %s", tc.zone) |
| + } |
| + } |
| +} |
| + |
| +func TestNativeQuickConfirmsSavedEventWithReminderWarning(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + f.local(t) |
| + p, _ := f.app.store.EnsurePersonSetup(t.Context(), "alice@example.test") |
| + cal, _ := f.app.store.DefaultCalendarID(t.Context(), p.ID) |
| + if _, err := f.app.db.Writer().Exec("CREATE TRIGGER native_quick_reminder_failure BEFORE INSERT ON reminders BEGIN SELECT RAISE(FAIL, 'fixture'); END"); err != nil { |
| + t.Fatal(err) |
| + } |
| + draft := nativeQuickDraft{CalendarID: cal, Title: "Confirmed quick event", StartLocal: "2026-10-25T09:00", EndLocal: "2026-10-25T10:00", TZ: "Europe/Dublin"} |
| + body, _ := json.Marshal(draft) |
| + r := httptest.NewRequest("POST", homeTestOrigin+"/api/native/quick", bytes.NewReader(body)) |
| + r.Header.Set("Origin", homeTestOrigin) |
| + for _, c := range f.cookies { |
| + r.AddCookie(c) |
| + } |
| + w := httptest.NewRecorder() |
| + f.app.mux.ServeHTTP(w, r) |
| + var result struct { |
| + ID int64 |
| + Warning string |
| + } |
| + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.ID == 0 || result.Warning == "" { |
| + t.Fatal("saved quick event reported as failure", w.Code) |
| + } |
| + event, err := f.app.store.Event(t.Context(), p.ID, result.ID) |
| + if err != nil || event.Title != draft.Title { |
| + t.Fatal("confirmed event not persisted") |
| + } |
| +} |
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go| index d466295..a77c479 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -109,6 +109,8 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.Use(a.requirePerson) |
| r.Use(h.drainOpportunistically) |
| + r.Post("/api/native/quick/parse", h.nativeQuickParse) |
| + r.Post("/api/native/quick", h.nativeQuickSave) |
| r.Get("/api/native/session", h.nativeSession) |
| r.Get("/api/native/events", h.nativeEvents) |
| r.Post("/api/native/events", h.nativeSave) |
diff --git a/native/README.md b/native/README.md| index 7954173..b19efc7 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -39,3 +39,39 @@ build/install the separate ReviewPhone target. The UI tests create and inspect |
| fixture events and never connect to a real account. Xcode result bundles |
| are under `native/build/ios/Logs/Test`. Review credentials enter only the isolated |
| review app's Documents directory and are removed when the fixture exits. |
| + |
| +## Quick-add, menu bar and time zones |
| + |
| +Use Quick add (Command-K on Mac) and type a sentence such as “Lunch tomorrow at |
| +1pm for an hour”. Review the understood date, time, calendar, recurrence and |
| +assumptions, then choose Add event (Command-Return on Mac). The server's existing |
| +Go parser is the only natural-language implementation. Saving uses the concrete |
| +preview dates, so midnight cannot reinterpret tomorrow. Attendees are recorded; |
| +no invitations are sent. Saved-event warnings stay visible instead of inviting |
| +a duplicate retry. |
| + |
| +The Mac menu-bar calendar stays available after the main window closes. It |
| +shares the selected date and filter, shows a compact month and day agenda, and |
| +contains quick-add. Open Calendar returns to the full window; Quit Calendar |
| +ends the app. It does not register itself to launch at login. |
| + |
| +Native Calendar follows this device's system time zone by default. Calendar |
| +settings offers Use system time zone or an explicit local override. On Mac use |
| +the app's Settings menu or the gear in the menu-bar panel; on iPhone use Settings |
| +in the calendar sidebar. The choice controls viewing ranges, new events and |
| +quick-add interpretation. Existing events retain their stored wall clock/IANA |
| +zone, and all-day entries remain civil dates. The server account's web preference |
| +is not rewritten by a device setting. |
| + |
| +The feature adds protected POST `/api/native/quick/parse` and |
| +POST `/api/native/quick`; GET `/api/native/events` accepts an optional viewing |
| +`zone`. Clients without that query retain the previous server-preference behavior. |
| +Preview bodies are bounded; create uses the existing grant checks and event store. |
| + |
| +Review: `/root/canary_release_review`, dispatched gpt-6-astra/high (runtime |
| +identity not independently verified), approved source after delayed-save |
| +sign-out, lost-confirmation and zone-encoding findings were fixed. Its isolated |
| +post-response-guard mutation failed four Swift assertions and was restored; |
| +all five Swift tests and the Go quick-add/viewing-zone tests passed. iPhone |
| +XCTest created an event through the actual parser/preview/save flow. The menu |
| +content was rendered against an authenticated fixture and inspected. |
diff --git a/native/apple/Sources/CalendarApp/CalendarApp.swift b/native/apple/Sources/CalendarApp/CalendarApp.swift| index d8754a8..6f89fa7 100644 |
| --- a/native/apple/Sources/CalendarApp/CalendarApp.swift |
| +++ b/native/apple/Sources/CalendarApp/CalendarApp.swift |
| @@ -1,14 +1,18 @@ |
| import CalendarKit |
| import RastrilloApple |
| import SwiftUI |
| +#if os(macOS) |
| +import AppKit |
| +#endif |
| @main struct CalendarApp: App { |
| @StateObject private var connection = NativeConnection(defaultServer: "https://calendar.oficina.works") |
| + @State private var model = CalendarModel() |
| var body: some Scene { |
| - WindowGroup { |
| + WindowGroup("Calendar", id: "calendar") { |
| Group { |
| if let client = connection.client { |
| - CalendarWorkspace(client: client, signOut: connection.signOut) |
| + CalendarWorkspace(client: client, model: model, signOut: { model.clear(); connection.signOut(); model = CalendarModel() }) |
| } else { |
| NativeSignInView("Oficina Calendar", systemImage: "calendar", connection: connection) |
| } |
| @@ -20,5 +24,16 @@ import SwiftUI |
| #if os(macOS) |
| .defaultSize(width: 1080, height: 800) |
| #endif |
| + #if os(macOS) |
| + Settings { CalendarSettingsView() } |
| + MenuBarExtra("Oficina Calendar", systemImage: "calendar") { |
| + if let client = connection.client { |
| + CalendarMenuView(client: client, model: model).tint(Color(red: 0.78, green: 0.22, blue: 0.29)) |
| + } else { |
| + NativeSignInView("Oficina Calendar", systemImage: "calendar", connection: connection) |
| + .frame(width: 380, height: 480) |
| + } |
| + }.menuBarExtraStyle(.window) |
| + #endif |
| } |
| } |
diff --git a/native/apple/Sources/CalendarKit/CalendarMenuView.swift b/native/apple/Sources/CalendarKit/CalendarMenuView.swift| new file mode 100644 |
| index 0000000..bbac6a4 |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarKit/CalendarMenuView.swift |
| @@ -0,0 +1,59 @@ |
| +#if os(macOS) |
| +import AppKit |
| +import RastrilloApple |
| +import SwiftUI |
| + |
| +public struct CalendarMenuView: View { |
| + public let client: NativeHTTP |
| + @ObservedObject var model: CalendarModel |
| + @Environment(\.openWindow) private var openWindow |
| + @Environment(\.openSettings) private var openSettings |
| + public init(client: NativeHTTP, model: CalendarModel) { self.client = client; self.model = model } |
| + public var body: some View { |
| + VStack(spacing: 0) { |
| + HStack { |
| + Text(model.calendars.first(where: { $0.id == model.selectedCalendar })?.name ?? "All calendars").font(.headline) |
| + Spacer() |
| + Button("Open Calendar", systemImage: "arrow.up.forward.app") { openWindow(id: "calendar"); NSApplication.shared.activate(ignoringOtherApps: true) }.labelStyle(.iconOnly) |
| + Button("Settings", systemImage: "gear") { openSettings(); NSApplication.shared.activate(ignoringOtherApps: true) }.labelStyle(.iconOnly) |
| + }.padding() |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 12) { |
| + CalendarMonthView(model: model).controlSize(.small) |
| + Text(model.display(model.date, template: "EEEE d MMMM")).font(.headline) |
| + if model.loading { ProgressView("Loading events…") } |
| + if !model.error.isEmpty { |
| + Text(model.error).foregroundStyle(.secondary) |
| + Button("Try again") { Task { await model.load(client) } } |
| + } else if model.dayEvents(model.date).isEmpty && !model.loading { |
| + Text("No events on this day").foregroundStyle(.secondary) |
| + } |
| + ForEach(model.dayEvents(model.date), id: \.identity) { event in |
| + VStack(alignment: .leading, spacing: 4) { |
| + Text(event.title).font(.headline).strikethrough(event.status == "cancelled") |
| + Text(event.all_day ? "All day" : model.display(Date(timeIntervalSince1970: Double(event.start)), template: "jm")) |
| + Text(event.calendar_name).font(.caption).foregroundStyle(.secondary) |
| + if event.status == "cancelled" { Label("Cancelled", systemImage: "xmark.circle") } |
| + if event.status == "tentative" { Label("Tentative", systemImage: "questionmark.circle") } |
| + }.accessibilityElement(children: .combine) |
| + Divider() |
| + } |
| + }.padding(.horizontal) |
| + } |
| + Divider().padding(.vertical, 8) |
| + QuickAddView(client: client, model: model).frame(height: model.quickText.isEmpty && model.quickSavedNotice.isEmpty ? 84 : 220).padding(.horizontal) |
| + HStack { |
| + Text(model.zone).font(.caption).foregroundStyle(.secondary) |
| + Spacer() |
| + Button("Quit Calendar") { NSApplication.shared.terminate(nil) } |
| + }.padding() |
| + }.frame(width: 380, height: 640) |
| + .onAppear { Task { await model.load(client) } } |
| + .onChange(of: model.monthStart) { _, _ in Task { await model.load(client) } } |
| + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in |
| + if model.zone != CalendarModel.preferredZone { Task { await model.load(client) } } |
| + } |
| + .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in Task { await model.load(client) } } |
| + } |
| +} |
| +#endif |
diff --git a/native/apple/Sources/CalendarKit/CalendarModel.swift b/native/apple/Sources/CalendarKit/CalendarModel.swift| index 1308aa6..4bfc9b5 100644 |
| --- a/native/apple/Sources/CalendarKit/CalendarModel.swift |
| +++ b/native/apple/Sources/CalendarKit/CalendarModel.swift |
| @@ -14,7 +14,18 @@ import RastrilloApple |
| private var weekStart = 1 |
| private var defaultCalendar: Int64 = 0 |
| private var generation = UUID() |
| - public init(zone: String = "UTC", date: Date = Date()) { self.zone = zone; self.date = date } |
| + private var active = true |
| + @Published var quickText = "" |
| + @Published var quickSavedNotice = "" |
| + @Published private(set) var quickSaving = false |
| + private let fixedZone: String? |
| + public static var preferredZone: String { |
| + let override = UserDefaults.standard.string(forKey: "calendar.timeZoneOverride") ?? "" |
| + return TimeZone(identifier: override)?.identifier ?? TimeZone.current.identifier |
| + } |
| + public init(zone: String? = nil, date: Date = Date()) { |
| + fixedZone = zone; self.zone = zone ?? Self.preferredZone; self.date = date |
| + } |
| public var calendar: Calendar { |
| var c = Calendar(identifier: .gregorian) |
| c.timeZone = TimeZone(identifier: zone) ?? .gmt |
| @@ -32,20 +43,27 @@ import RastrilloApple |
| let start = calendar.date(byAdding: .day, value: -offset, to: first) ?? first |
| return (0..<42).compactMap { calendar.date(byAdding: .day, value: $0, to: start) } |
| } |
| + public func clear() { |
| + active = false; quickText = ""; quickSavedNotice = ""; quickSaving = false |
| + generation = UUID(); calendars = []; events = []; notice = ""; error = ""; loading = false |
| + selectedCalendar = nil; date = Date(); zone = fixedZone ?? Self.preferredZone |
| + } |
| public func dayEvents(_ day: Date) -> [CalendarEvent] { |
| events.filter { (selectedCalendar == nil || $0.calendar_id == selectedCalendar) && $0.occurs(on: day, calendar: calendar) } |
| } |
| public func load(_ client: NativeHTTP) async { |
| + guard active else { return } |
| + zone = fixedZone ?? Self.preferredZone |
| let token = UUID(); generation = token |
| loading = true; error = "" |
| defer { if generation == token { loading = false } } |
| do { |
| let session: CalendarSession = try await client.request("/api/native/session") |
| guard generation == token else { return } |
| - zone = session.zone; weekStart = session.week_start; calendars = session.calendars; defaultCalendar = session.default_calendar_id |
| + weekStart = session.week_start; calendars = session.calendars; defaultCalendar = session.default_calendar_id |
| let from = format(days.first ?? monthStart, allDay: true) |
| let to = format(calendar.date(byAdding: .day, value: 1, to: days.last ?? monthStart)!, allDay: true) |
| - let response: CalendarEvents = try await client.request("/api/native/events?from=\(from)&to=\(to)") |
| + 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 } } |
| @@ -61,10 +79,25 @@ import RastrilloApple |
| return event |
| } |
| public func save(_ event: CalendarEvent, client: NativeHTTP) async throws { |
| + guard active else { throw CancellationError() } |
| let saved: SavedEvent = try await client.request(event.id == 0 ? "/api/native/events" : "/api/native/events/\(event.id)", method: "POST", body: JSONEncoder().encode(event)) |
| + guard active else { throw CancellationError() } |
| await load(client) |
| + guard active else { throw CancellationError() } |
| notice = saved.warning ?? "" |
| } |
| + func saveQuick(_ preview: QuickPreview, client: NativeHTTP) async throws { |
| + guard active, !quickSaving else { throw CancellationError() } |
| + quickSaving = true |
| + defer { if active { quickSaving = false } } |
| + let result: SavedEvent = try await client.request("/api/native/quick", method: "POST", body: JSONEncoder().encode(preview.draft)) |
| + guard active else { throw CancellationError() } |
| + quickText = "" |
| + quickSavedNotice = result.warning ?? "Added \(preview.draft.title)." |
| + notice = quickSavedNotice |
| + if let date = parse(preview.draft.start_local, allDay: preview.draft.all_day, zone: preview.draft.tz) { self.date = date } |
| + await load(client) |
| + } |
| public func format(_ date: Date, allDay: Bool, zone: String? = nil) -> String { |
| let f = DateFormatter(); f.locale = Locale(identifier: "en_US_POSIX") |
| f.timeZone = TimeZone(identifier: zone ?? self.zone) ?? .gmt |
diff --git a/native/apple/Sources/CalendarKit/CalendarMonthView.swift b/native/apple/Sources/CalendarKit/CalendarMonthView.swift| new file mode 100644 |
| index 0000000..4173768 |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarKit/CalendarMonthView.swift |
| @@ -0,0 +1,73 @@ |
| +import SwiftUI |
| + |
| +struct CalendarMonthView: View { |
| + @ObservedObject var model: CalendarModel |
| + @Environment(\.controlSize) private var controlSize |
| + private var compact: Bool { |
| + #if os(macOS) |
| + controlSize == .small |
| + #else |
| + false |
| + #endif |
| + } |
| + @Environment(\.dynamicTypeSize) private var typeSize |
| + private let accent = Color(red: 0.78, green: 0.22, blue: 0.29) |
| + var body: some View { VStack(spacing: 0) { monthHeader; monthGrid } } |
| + private var monthHeader: some View { |
| + ViewThatFits(in: .horizontal) { |
| + HStack { |
| + Text(model.display(model.monthStart, template: "MMMM yyyy")).font(.title2.bold()) |
| + Spacer() |
| + monthControls |
| + } |
| + VStack(alignment: .leading) { |
| + Text(model.display(model.monthStart, template: "MMMM yyyy")).font(.title2.bold()) |
| + monthControls |
| + } |
| + }.padding(.vertical, compact ? 4 : 16) |
| + } |
| + private var monthControls: some View { |
| + HStack { |
| + Button("Previous month", systemImage: "chevron.left") { move(-1) }.labelStyle(.iconOnly).frame(minWidth: compact ? 28 : 44, minHeight: compact ? 28 : 44) |
| + Button("Today") { model.date = Date() }.frame(minHeight: compact ? 28 : 44) |
| + Button("Next month", systemImage: "chevron.right") { move(1) }.labelStyle(.iconOnly).frame(minWidth: compact ? 28 : 44, minHeight: compact ? 28 : 44) |
| + }.buttonStyle(.borderless) |
| + } |
| + private func move(_ amount: Int) { model.date = model.calendar.date(byAdding: .month, value: amount, to: model.date) ?? model.date } |
| + private var monthGrid: some View { |
| + Grid(horizontalSpacing: 4, verticalSpacing: 4) { |
| + GridRow { |
| + ForEach(Array(model.weekdayLabels.enumerated()), id: \.offset) { _, label in |
| + Text(typeSize.isAccessibilitySize ? String(label.prefix(1)) : label) |
| + .font(.caption).foregroundStyle(.secondary).accessibilityHidden(true) |
| + } |
| + } |
| + ForEach(0..<6) { week in |
| + GridRow { |
| + ForEach(Array(model.days.dropFirst(week * 7).prefix(7)), id: \.self) { day in |
| + dayButton(day) |
| + } |
| + } |
| + } |
| + }.padding(.bottom, 12) |
| + } |
| + private func dayButton(_ day: Date) -> some View { |
| + let selected = model.calendar.isDate(day, inSameDayAs: model.date) |
| + let today = model.calendar.isDate(day, inSameDayAs: Date()) |
| + let count = model.dayEvents(day).count |
| + let current = model.calendar.isDate(day, equalTo: model.monthStart, toGranularity: .month) |
| + return Button { model.date = day } label: { |
| + VStack(spacing: 3) { |
| + Text("\(model.calendar.component(.day, from: day))") |
| + .font(.body.monospacedDigit()).bold(today).lineLimit(1).minimumScaleFactor(0.7) |
| + Circle().fill(count == 0 ? .clear : selected ? .white : accent).frame(width: 4, height: 4) |
| + }.frame(maxWidth: .infinity, minHeight: compact ? 28 : 44) |
| + .foregroundStyle(selected ? .white : current ? Color.primary : Color.secondary) |
| + .background(selected ? accent : .clear, in: RoundedRectangle(cornerRadius: 9)) |
| + .overlay { RoundedRectangle(cornerRadius: 9).strokeBorder(today ? accent : .clear, lineWidth: 2) } |
| + }.buttonStyle(.plain) |
| + .accessibilityLabel(model.display(day, template: "EEEE d MMMM yyyy")) |
| + .accessibilityValue("\(today ? "Today. " : "")\(count) events") |
| + .accessibilityAddTraits(selected ? .isSelected : []) |
| + } |
| +} |
diff --git a/native/apple/Sources/CalendarKit/CalendarSettingsView.swift b/native/apple/Sources/CalendarKit/CalendarSettingsView.swift| new file mode 100644 |
| index 0000000..8b1d4f2 |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarKit/CalendarSettingsView.swift |
| @@ -0,0 +1,29 @@ |
| +import SwiftUI |
| + |
| +public struct CalendarSettingsView: View { |
| + @AppStorage("calendar.timeZoneOverride") private var override = "" |
| + public init() {} |
| + public var body: some View { |
| + Form { |
| + Section { |
| + Toggle("Use system time zone", isOn: Binding( |
| + get: { override.isEmpty }, |
| + set: { override = $0 ? "" : TimeZone.current.identifier })) |
| + if override.isEmpty { |
| + LabeledContent("Time zone", value: TimeZone.current.identifier.replacingOccurrences(of: "_", with: " ")) |
| + } else { |
| + Picker("Time zone", selection: $override) { |
| + ForEach(Array(Set(TimeZone.knownTimeZoneIdentifiers + ["UTC", TimeZone.current.identifier])).sorted(), id: \.self) { zone in |
| + Text(zone.replacingOccurrences(of: "_", with: " ")).tag(zone) |
| + } |
| + } |
| + } |
| + } footer: { |
| + Text("Used for your agenda, new events and quick-add on this device. Existing events keep their original time zones.") |
| + } |
| + }.formStyle(.grouped).navigationTitle("Calendar settings") |
| + #if os(macOS) |
| + .frame(width: 460, height: 240) |
| + #endif |
| + } |
| +} |
diff --git a/native/apple/Sources/CalendarKit/CalendarWorkspace.swift b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift| index 3dbab40..9bc5f0c 100644 |
| --- a/native/apple/Sources/CalendarKit/CalendarWorkspace.swift |
| +++ b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift |
| @@ -5,6 +5,8 @@ import SwiftUI |
| public let client: NativeHTTP |
| public let signOut: () -> Void |
| @StateObject private var model: CalendarModel |
| + @State private var quickAdding = false |
| + @State private var settings = false |
| @State private var editing: CalendarEvent? |
| @State private var pendingEdit: CalendarEvent? |
| @State private var inspecting: CalendarEvent? |
| @@ -27,18 +29,16 @@ import SwiftUI |
| } |
| Section { |
| LabeledContent("Time zone", value: model.zone).font(.footnote) |
| + Button("Settings", systemImage: "gear") { settings = true } |
| Button("Sign out", action: signOut) |
| } |
| }.navigationTitle("Calendar").navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340) |
| } detail: { |
| List { |
| Section { |
| - VStack(spacing: 0) { |
| - monthHeader |
| - monthGrid |
| - }.listRowSeparator(.hidden) |
| + CalendarMonthView(model: model).listRowSeparator(.hidden) |
| } |
| - if !model.notice.isEmpty { Label(model.notice, systemImage: "bell.badge").font(.callout).padding() } |
| + 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", |
| @@ -78,6 +78,7 @@ import SwiftUI |
| .navigationBarTitleDisplayMode(.inline) |
| #endif |
| .toolbar { |
| + ToolbarItem { Button("Quick add", systemImage: "text.badge.plus") { quickAdding = true }.keyboardShortcut("k", modifiers: .command) } |
| ToolbarItem(placement: .primaryAction) { |
| Button("New event", systemImage: "plus") { editing = model.newEvent() } |
| .disabled(!model.calendars.contains(where: \.writable)).keyboardShortcut("n", modifiers: .command) |
| @@ -89,6 +90,22 @@ import SwiftUI |
| .task { await model.load(client) } |
| .onChange(of: model.monthStart) { _, _ in Task { await model.load(client) } } |
| .onChange(of: filter) { _, value in model.selectedCalendar = value == 0 ? nil : value; column = .detail } |
| + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in |
| + if model.zone != CalendarModel.preferredZone { Task { await model.load(client) } } |
| + } |
| + .onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in Task { await model.load(client) } } |
| + .sheet(isPresented: $quickAdding) { |
| + NavigationStack { |
| + QuickAddView(client: client, model: model) |
| + .padding().navigationTitle("Quick add") |
| + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { quickAdding = false }.disabled(model.quickSaving) } } |
| + } |
| + .interactiveDismissDisabled(model.quickSaving) |
| + #if os(macOS) |
| + .frame(width: 440, height: 540) |
| + #endif |
| + } |
| + .sheet(isPresented: $settings) { NavigationStack { CalendarSettingsView().toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { settings = false } } } } } |
| .sheet(item: $editing) { EventEditor(model: model, client: client, event: $0) } |
| .sheet(item: $inspecting, onDismiss: { if let event = pendingEdit { pendingEdit = nil; editing = event } }) { event in |
| NavigationStack { |
| @@ -119,65 +136,8 @@ import SwiftUI |
| } |
| } |
| - private var monthHeader: some View { |
| - ViewThatFits(in: .horizontal) { |
| - HStack { |
| - Text(model.display(model.monthStart, template: "MMMM yyyy")).font(.title2.bold()) |
| - Spacer() |
| - monthControls |
| - } |
| - VStack(alignment: .leading) { |
| - Text(model.display(model.monthStart, template: "MMMM yyyy")).font(.title2.bold()) |
| - monthControls |
| - } |
| - }.padding(.vertical) |
| - } |
| - private var monthControls: some View { |
| - HStack { |
| - Button("Previous month", systemImage: "chevron.left") { move(-1) }.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44) |
| - Button("Today") { model.date = Date() }.frame(minHeight: 44) |
| - Button("Next month", systemImage: "chevron.right") { move(1) }.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44) |
| - }.buttonStyle(.borderless) |
| - } |
| private func calendarColor(_ id: Int64) -> Color { |
| guard let calendar = model.calendars.first(where: { $0.id == id }) else { return accent } |
| return Color(hue: Double(calendar.hue % 360) / 360, saturation: 0.6, brightness: 0.75) |
| } |
| - private func move(_ amount: Int) { model.date = model.calendar.date(byAdding: .month, value: amount, to: model.date) ?? model.date } |
| - private var monthGrid: some View { |
| - Grid(horizontalSpacing: 4, verticalSpacing: 4) { |
| - GridRow { |
| - ForEach(Array(model.weekdayLabels.enumerated()), id: \.offset) { _, label in |
| - Text(typeSize.isAccessibilitySize ? String(label.prefix(1)) : label) |
| - .font(.caption).foregroundStyle(.secondary).accessibilityHidden(true) |
| - } |
| - } |
| - ForEach(0..<6) { week in |
| - GridRow { |
| - ForEach(Array(model.days.dropFirst(week * 7).prefix(7)), id: \.self) { day in |
| - dayButton(day) |
| - } |
| - } |
| - } |
| - }.padding(.bottom, 12) |
| - } |
| - private func dayButton(_ day: Date) -> some View { |
| - let selected = model.calendar.isDate(day, inSameDayAs: model.date) |
| - let today = model.calendar.isDate(day, inSameDayAs: Date()) |
| - let count = model.dayEvents(day).count |
| - let current = model.calendar.isDate(day, equalTo: model.monthStart, toGranularity: .month) |
| - return Button { model.date = day } label: { |
| - VStack(spacing: 3) { |
| - Text("\(model.calendar.component(.day, from: day))") |
| - .font(.body.monospacedDigit()).bold(today).lineLimit(1).minimumScaleFactor(0.7) |
| - Circle().fill(count == 0 ? .clear : selected ? .white : accent).frame(width: 4, height: 4) |
| - }.frame(maxWidth: .infinity, minHeight: 44) |
| - .foregroundStyle(selected ? .white : current ? Color.primary : Color.secondary) |
| - .background(selected ? accent : .clear, in: RoundedRectangle(cornerRadius: 9)) |
| - .overlay { RoundedRectangle(cornerRadius: 9).strokeBorder(today ? accent : .clear, lineWidth: 2) } |
| - }.buttonStyle(.plain) |
| - .accessibilityLabel(model.display(day, template: "EEEE d MMMM yyyy")) |
| - .accessibilityValue("\(today ? "Today. " : "")\(count) events") |
| - .accessibilityAddTraits(selected ? .isSelected : []) |
| - } |
| } |
diff --git a/native/apple/Sources/CalendarKit/QuickAddView.swift b/native/apple/Sources/CalendarKit/QuickAddView.swift| new file mode 100644 |
| index 0000000..84c3461 |
| --- /dev/null |
| +++ b/native/apple/Sources/CalendarKit/QuickAddView.swift |
| @@ -0,0 +1,81 @@ |
| +import RastrilloApple |
| +import SwiftUI |
| + |
| +struct QuickDraft: Codable { |
| + let calendar_id: Int64 |
| + let title, location, start_local, end_local, tz: String |
| + let all_day: Bool |
| + let `repeat`: String |
| + let attendees: [String] |
| +} |
| +struct QuickPreview: Decodable { |
| + let draft: QuickDraft |
| + let when, repeat_label, calendar_name: String |
| + let assumptions: [String] |
| +} |
| +struct QuickAddView: View { |
| + let client: NativeHTTP |
| + @ObservedObject var model: CalendarModel |
| + @State private var preview: QuickPreview? |
| + @State private var error = "" |
| + @State private var parsing = false |
| + @FocusState private var focused: Bool |
| + private var requestKey: String { model.zone + "\n" + model.quickText } |
| + var body: some View { |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 12) { |
| + TextField("Lunch tomorrow at 1pm for an hour", text: $model.quickText, axis: .vertical) |
| + .textFieldStyle(.roundedBorder).lineLimit(2...4) |
| + .accessibilityLabel("Describe an event").accessibilityIdentifier("quickAddText") |
| + .focused($focused).disabled(model.quickSaving) |
| + Text(model.zone.replacingOccurrences(of: "_", with: " ")).font(.footnote).foregroundStyle(.secondary) |
| + if parsing { ProgressView("Understanding your event…") } |
| + if let preview { |
| + VStack(alignment: .leading, spacing: 8) { |
| + Text(preview.draft.title).font(.headline) |
| + Label(preview.when, systemImage: "clock") |
| + Label(preview.calendar_name, systemImage: "calendar") |
| + if !preview.draft.location.isEmpty { Label(preview.draft.location, systemImage: "mappin") } |
| + if !preview.repeat_label.isEmpty { Label(preview.repeat_label, systemImage: "repeat") } |
| + if !preview.draft.attendees.isEmpty { |
| + Label(preview.draft.attendees.joined(separator: ", "), systemImage: "person.2") |
| + Text("Attendees are recorded, not invited. No invitations are sent.").font(.footnote) |
| + } |
| + ForEach(preview.assumptions, id: \.self) { Text($0).font(.footnote).foregroundStyle(.secondary) } |
| + }.accessibilityIdentifier("quickAddPreview") |
| + Button(model.quickSaving ? "Adding…" : "Add event", systemImage: "plus") { save(preview) } |
| + .buttonStyle(.borderedProminent).disabled(model.quickSaving || parsing) |
| + .accessibilityIdentifier("quickAddSave").keyboardShortcut(.return, modifiers: .command) |
| + } |
| + if !error.isEmpty { Label(error, systemImage: "exclamationmark.circle").foregroundStyle(.red) } |
| + if !model.quickSavedNotice.isEmpty { Label(model.quickSavedNotice, systemImage: "checkmark.circle").accessibilityIdentifier("quickAddSaved") } |
| + }.frame(maxWidth: .infinity, alignment: .leading) |
| + } |
| + .task(id: requestKey) { |
| + preview = nil; error = "" |
| + guard !model.quickText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { parsing = false; return } |
| + parsing = true |
| + let key = requestKey |
| + do { |
| + try await Task.sleep(for: .milliseconds(350)) |
| + let result: QuickPreview = try await client.request("/api/native/quick/parse", method: "POST", |
| + body: JSONEncoder().encode(["text": model.quickText, "zone": model.zone])) |
| + guard !Task.isCancelled, requestKey == key else { return } |
| + preview = result; parsing = false |
| + } catch { |
| + guard !Task.isCancelled, requestKey == key else { return } |
| + self.error = error.localizedDescription; parsing = false |
| + } |
| + } |
| + .onChange(of: model.quickText) { _, text in preview = nil; if !text.isEmpty { model.quickSavedNotice = "" } } |
| + .onChange(of: model.zone) { _, _ in preview = nil } |
| + } |
| + private func save(_ preview: QuickPreview) { |
| + error = "" |
| + Task { |
| + do { try await model.saveQuick(preview, client: client); self.preview = nil } |
| + catch is CancellationError { } |
| + catch { self.error = error.localizedDescription } |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CalendarReview/Review.swift b/native/apple/Sources/CalendarReview/Review.swift| index 63fbdcd..055c468 100644 |
| --- a/native/apple/Sources/CalendarReview/Review.swift |
| +++ b/native/apple/Sources/CalendarReview/Review.swift |
| @@ -14,8 +14,12 @@ struct Input: Decodable { let origin: URL; let credential: NativeSession; let ou |
| let model = CalendarModel() |
| model.date = ISO8601DateFormatter().date(from: "2026-10-24T12:00:00Z")!; await model.load(client) |
| guard model.error.isEmpty else { fatalError("Fixture failed: " + model.error) } |
| - let view = NSHostingView(rootView: CalendarWorkspace(client: client, model: model, signOut: {}).preferredColorScheme(.light).tint(Color(red: 0.76, green: 0.2, blue: 0.3))) |
| - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1100, height: 800), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) |
| + let menu = ProcessInfo.processInfo.environment["NATIVE_REVIEW_MENU"] == "1" |
| + let view = NSHostingView(rootView: Group { |
| + if menu { CalendarMenuView(client: client, model: model) } |
| + else { CalendarWorkspace(client: client, model: model, signOut: {}) } |
| + }.preferredColorScheme(.light).tint(Color(red: 0.76, green: 0.2, blue: 0.3))) |
| + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: menu ? 380 : 1100, height: menu ? 640 : 800), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) |
| window.appearance = NSAppearance(named: .aqua) |
| window.contentView = view |
| window.center() |
diff --git a/native/apple/Tests/CalendarKitTests/SessionLifecycleTests.swift b/native/apple/Tests/CalendarKitTests/SessionLifecycleTests.swift| new file mode 100644 |
| index 0000000..5118ac7 |
| --- /dev/null |
| +++ b/native/apple/Tests/CalendarKitTests/SessionLifecycleTests.swift |
| @@ -0,0 +1,73 @@ |
| +import XCTest |
| +import RastrilloApple |
| +@testable import CalendarKit |
| + |
| +final class SessionLifecycleTests: XCTestCase { |
| + // Independent reviewer mutation, 2026-09-13: remove saveQuick's guard after |
| + // the POST response. Four assertions fail (cancellation/date/notice/result); |
| + // restored source passes all five Swift tests. |
| + @MainActor func testLateSavesCannotReloadClearedSession() async throws { |
| + for quick in [false, true] { |
| + let process = Process() |
| + process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") |
| + process.arguments = ["-u", "-c", """ |
| +import http.server, threading, json |
| +started=threading.Event(); release=threading.Event(); reads=0 |
| +class Handler(http.server.BaseHTTPRequestHandler): |
| + def log_message(self,*args): pass |
| + def reply(self,value): |
| + data=json.dumps(value).encode(); self.send_response(200); self.send_header('Content-Length',str(len(data))); self.end_headers(); self.wfile.write(data) |
| + def do_POST(self): |
| + self.rfile.read(int(self.headers.get('Content-Length','0'))); started.set(); release.wait(10); self.reply({'id':1,'warning':'Event saved. Reminder unavailable.'}) |
| + def do_GET(self): |
| + global reads |
| + if self.path=='/wait': started.wait(10); self.reply({}); return |
| + if self.path=='/release': release.set(); self.reply({}); return |
| + if self.path=='/counts': self.reply({'reads':reads}); return |
| + reads+=1; self.reply({}) |
| +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") |
| + let draft = QuickDraft(calendar_id: 1, title: "Fixture", location: "", start_local: "2030-10-25T09:00", end_local: "2030-10-25T10:00", tz: "Europe/Dublin", all_day: false, repeat: "none", attendees: []) |
| + let preview = QuickPreview(draft: draft, when: "Fixture time", repeat_label: "", calendar_name: "Personal", assumptions: []) |
| + let task = Task { |
| + if quick { try await model.saveQuick(preview, client: client) } |
| + else { try await model.save(CalendarEvent(), client: client) } |
| + } |
| + _ = try await client.response("/wait") |
| + model.clear() |
| + let clearedDate = model.date |
| + _ = try await client.response("/release") |
| + do { try await task.value; XCTFail("Old save completed against cleared session") } |
| + catch is CancellationError { } |
| + await model.load(client) // Even queued refresh callbacks cannot revive this model. |
| + struct Counts: Decodable { let reads: Int } |
| + let counts: Counts = try await client.request("/counts") |
| + XCTAssertEqual(counts.reads, 0) |
| + XCTAssertEqual(model.date, clearedDate) |
| + XCTAssertTrue(model.events.isEmpty) |
| + XCTAssertTrue(model.calendars.isEmpty) |
| + XCTAssertTrue(model.notice.isEmpty) |
| + XCTAssertTrue(model.quickSavedNotice.isEmpty) |
| + XCTAssertFalse(model.quickSaving) |
| + } |
| + } |
| + @MainActor func testSystemTimeZoneDefaultAndOverride() { |
| + let key = "calendar.timeZoneOverride" |
| + let prior = UserDefaults.standard.object(forKey: key) |
| + defer { if let prior { UserDefaults.standard.set(prior, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) } } |
| + UserDefaults.standard.removeObject(forKey: key) |
| + XCTAssertEqual(CalendarModel().zone, TimeZone.current.identifier) |
| + UserDefaults.standard.set("Pacific/Kiritimati", forKey: key) |
| + XCTAssertEqual(CalendarModel().newEvent().tz, "Pacific/Kiritimati") |
| + UserDefaults.standard.set("", forKey: key) |
| + XCTAssertEqual(CalendarModel().zone, TimeZone.current.identifier) |
| + } |
| +} |
diff --git a/native/ios/UITests/NativeUITests.swift b/native/ios/UITests/NativeUITests.swift| index 8c13f6a..45dcd10 100644 |
| --- a/native/ios/UITests/NativeUITests.swift |
| +++ b/native/ios/UITests/NativeUITests.swift |
| @@ -17,4 +17,18 @@ import XCTest |
| let capture=XCTAttachment(screenshot: app.screenshot());capture.lifetime = .keepAlways;add(capture) |
| } |
| + func testQuickAddUsesPreview() { |
| + let app = XCUIApplication(); app.launch() |
| + XCTAssertTrue(app.buttons["Quick add"].waitForExistence(timeout: 10)) |
| + app.buttons["Quick add"].tap() |
| + let input = app.descendants(matching: .any)["quickAddText"].firstMatch |
| + XCTAssertTrue(input.waitForExistence(timeout: 5)); input.tap() |
| + input.typeText("Native quick lunch tomorrow at 1pm for an hour") |
| + let save = app.buttons["quickAddSave"] |
| + XCTAssertTrue(save.waitForExistence(timeout: 10)); save.tap() |
| + XCTAssertTrue(app.descendants(matching: .any)["quickAddSaved"].firstMatch.waitForExistence(timeout: 10)) |
| + app.buttons["Done"].tap() |
| + XCTAssertTrue(app.staticTexts["Native quick lunch"].firstMatch.waitForExistence(timeout: 10)) |
| + } |
| + |
| } |