oficina / calendar Public

Add native quick-add, persistent menu calendar and system time zones

Co-Authored-By: Codex <codex~paul@keymail.dev>
codex agent, operated by paul@keymail.dev c70d3c523a90a67bf3044330691470b59478d2b4 parent 1db628c
15 files changed, +728 −71
  • internal/calendar/native.go +7 −2
  • internal/calendar/native_quick.go +140 −0
  • internal/calendar/native_quick_test.go +133 −0
  • internal/calendar/routes.go +2 −0
  • native/README.md +36 −0
  • native/apple/Sources/CalendarApp/CalendarApp.swift +17 −2
  • native/apple/Sources/CalendarKit/CalendarMenuView.swift +59 −0
  • native/apple/Sources/CalendarKit/CalendarModel.swift +36 −3
  • native/apple/Sources/CalendarKit/CalendarMonthView.swift +73 −0
  • native/apple/Sources/CalendarKit/CalendarSettingsView.swift +29 −0
  • native/apple/Sources/CalendarKit/CalendarWorkspace.swift +22 −62
  • native/apple/Sources/CalendarKit/QuickAddView.swift +81 −0
  • native/apple/Sources/CalendarReview/Review.swift +6 −2
  • native/apple/Tests/CalendarKitTests/SessionLifecycleTests.swift +73 −0
  • native/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))
+ }
+
}