From 587d5bf39c90388c183a372c95d37b52dcaf10ed Mon Sep 17 00:00:00 2001
From: Robin | Liquidium
Date: Mon, 10 Aug 2026 19:29:10 +0200
Subject: [PATCH 1/9] Add Apple event creation and all-day calendars
---
README.md | 2 +-
Sources/Dayline/App/DaylineApp.swift | 11 ++
.../Dayline/Models/AppleCalendarSource.swift | 12 ++
.../Dayline/Models/CalendarEventItem.swift | 29 ++-
Sources/Dayline/Models/MenuControlID.swift | 4 +-
.../Services/AppleCalendarService.swift | 69 ++++++-
.../Dayline/Services/CalendarService.swift | 22 ++-
Sources/Dayline/Stores/StatusStore.swift | 127 +++++++++++-
...leCalendarEventEditorWindowPresenter.swift | 8 +
Sources/Dayline/Support/MockData.swift | 58 ++++++
.../Views/AppleCalendarEventEditorView.swift | 185 ++++++++++++++++++
Sources/Dayline/Views/PreviewPopovers.swift | 9 +
.../Views/Settings/CalendarSettingsTab.swift | 11 ++
.../Dayline/Views/Settings/SettingsTab.swift | 1 +
Sources/Dayline/Views/StatusMenuView.swift | 155 ++++++++++-----
.../AppleCalendarEventCreationTests.swift | 61 ++++++
.../DaylineTests/CalendarEventItemTests.swift | 73 ++++++-
UITests/DaylineUITests/DaylineUITests.swift | 27 ++-
script/build_and_run.sh | 4 +-
script/build_mock_and_run.sh | 4 +-
script/package_release.sh | 4 +-
website/src/routes/privacy.tsx | 22 ++-
22 files changed, 812 insertions(+), 86 deletions(-)
create mode 100644 Sources/Dayline/Support/AppleCalendarEventEditorWindowPresenter.swift
create mode 100644 Sources/Dayline/Views/AppleCalendarEventEditorView.swift
create mode 100644 Tests/DaylineTests/AppleCalendarEventCreationTests.swift
diff --git a/README.md b/README.md
index 68602d5..00e46bd 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ or Dock icon.
## Highlights
-- **Calendar at a glance:** see remaining timed events today and optionally expand tomorrow.
+- **Calendar at a glance:** see timed events plus optional all-day events today, expand tomorrow, and create Apple Calendar events directly.
- **Work without the tab:** review assigned Linear or GitHub issues alongside incomplete Apple Reminders.
- **Local Markdown notes:** write formatted notes on this Mac; the first line becomes the title.
- **Keyboard-first actions:** hover a work item and use configurable shortcuts for status, priority, due date, and provider-specific fields.
diff --git a/Sources/Dayline/App/DaylineApp.swift b/Sources/Dayline/App/DaylineApp.swift
index 51b70c1..de7a2d1 100644
--- a/Sources/Dayline/App/DaylineApp.swift
+++ b/Sources/Dayline/App/DaylineApp.swift
@@ -105,6 +105,13 @@ struct DaylineApp: App {
.defaultSize(width: 580, height: 540)
.handlesExternalEvents(matching: [])
+ Window("New Apple Calendar Event", id: "appleCalendarEventCreator") {
+ AppleCalendarEventEditorView()
+ .environmentObject(store)
+ }
+ .defaultSize(width: 560, height: 440)
+ .handlesExternalEvents(matching: [])
+
Window("\(appDisplayName) Settings", id: "settings") {
SettingsView()
.environmentObject(store)
@@ -152,6 +159,10 @@ private struct MenuBarLabelView: View {
openWindow(id: "appleReminderCreator")
AppleReminderEditorWindowPresenter.bringReminderWindowToFront()
}
+ .onChange(of: store.appleCalendarEventCreationRequestID) {
+ openWindow(id: "appleCalendarEventCreator")
+ AppleCalendarEventEditorWindowPresenter.bringEventWindowToFront()
+ }
.onChange(of: store.meetingAlertEvent, initial: true) {
if let event = store.meetingAlertEvent {
let snoozeMinutes = store.meetingAlertSnoozeMinutes
diff --git a/Sources/Dayline/Models/AppleCalendarSource.swift b/Sources/Dayline/Models/AppleCalendarSource.swift
index e79fd5b..ec33f68 100644
--- a/Sources/Dayline/Models/AppleCalendarSource.swift
+++ b/Sources/Dayline/Models/AppleCalendarSource.swift
@@ -14,6 +14,9 @@ struct AppleCalendarSource: Codable, Identifiable, Equatable, Sendable {
/// Whether Dayline includes this calendar in the merged agenda.
var isEnabled: Bool
+ /// Whether EventKit allows Dayline to create events in this calendar.
+ var allowsModifications: Bool = false
+
/// Restores explicit saved choices while leaving newly discovered calendars enabled.
static func restoringSelections(
in discovered: [AppleCalendarSource],
@@ -28,3 +31,12 @@ struct AppleCalendarSource: Codable, Identifiable, Equatable, Sendable {
}
}
}
+
+/// Values collected by Dayline's Apple Calendar event editor.
+struct AppleCalendarEventCreateDraft: Equatable, Sendable {
+ var title = ""
+ var calendarID = ""
+ var startDate = Date()
+ var endDate = Date().addingTimeInterval(30 * 60)
+ var isAllDay = false
+}
diff --git a/Sources/Dayline/Models/CalendarEventItem.swift b/Sources/Dayline/Models/CalendarEventItem.swift
index d1d085a..70bf2c9 100644
--- a/Sources/Dayline/Models/CalendarEventItem.swift
+++ b/Sources/Dayline/Models/CalendarEventItem.swift
@@ -4,6 +4,8 @@ import Foundation
struct CalendarAgendaSections: Equatable, Sendable {
let today: [CalendarEventItem]
let tomorrow: [CalendarEventItem]
+ let allDayToday: [CalendarEventItem]
+ let allDayTomorrow: [CalendarEventItem]
}
/// A normalized calendar event ready for display in the menu bar popover.
@@ -23,6 +25,9 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
/// Optional location copied from the calendar event.
let location: String?
+ /// Whether this event occupies one or more complete calendar days.
+ let isAllDay: Bool
+
/// Optional browser URL for opening the calendar event itself.
let calendarURL: URL?
@@ -44,6 +49,7 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
startDate: Date,
endDate: Date,
location: String?,
+ isAllDay: Bool = false,
calendarURL: URL?,
openURL: URL?,
sourceCalendarNames: [String] = [],
@@ -55,6 +61,7 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
self.startDate = startDate
self.endDate = endDate
self.location = location
+ self.isAllDay = isAllDay
self.calendarURL = calendarURL
self.openURL = openURL
self.sourceCalendarNames = sourceCalendarNames
@@ -124,6 +131,7 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
startDate: existing.startDate,
endDate: existing.endDate,
location: existing.location ?? event.location,
+ isAllDay: existing.isAllDay || event.isAllDay,
calendarURL: existing.calendarURL ?? event.calendarURL,
openURL: existing.openURL ?? event.openURL,
sourceCalendarNames: names,
@@ -145,17 +153,27 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
tomorrowStart: Date,
dayAfterTomorrow: Date,
todayLimit: Int,
- tomorrowLimit: Int
+ tomorrowLimit: Int,
+ todayAllDayLimit: Int = .max,
+ tomorrowAllDayLimit: Int = .max
) -> CalendarAgendaSections {
let merged = mergedAgenda(events)
return CalendarAgendaSections(
today: merged
- .filter { $0.startDate < tomorrowStart }
+ .filter { !$0.isAllDay && $0.startDate < tomorrowStart }
.prefix(todayLimit)
.map { $0 },
tomorrow: merged
- .filter { $0.endDate > tomorrowStart && $0.startDate < dayAfterTomorrow }
+ .filter { !$0.isAllDay && $0.endDate > tomorrowStart && $0.startDate < dayAfterTomorrow }
.prefix(tomorrowLimit)
+ .map { $0 },
+ allDayToday: merged
+ .filter { $0.isAllDay && $0.startDate < tomorrowStart }
+ .prefix(todayAllDayLimit)
+ .map { $0 },
+ allDayTomorrow: merged
+ .filter { $0.isAllDay && $0.endDate > tomorrowStart && $0.startDate < dayAfterTomorrow }
+ .prefix(tomorrowAllDayLimit)
.map { $0 }
)
}
@@ -179,11 +197,12 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
leadTime: TimeInterval,
postStartGrace: TimeInterval
) -> CalendarEventItem? {
- if let activeEvent = events.first(where: { $0.isHappening(at: date) }) {
+ let timedEvents = events.filter { !$0.isAllDay }
+ if let activeEvent = timedEvents.first(where: { $0.isHappening(at: date) }) {
return activeEvent
}
- return events.first { event in
+ return timedEvents.first { event in
date >= event.startDate.addingTimeInterval(-leadTime)
&& date <= event.startDate.addingTimeInterval(postStartGrace)
}
diff --git a/Sources/Dayline/Models/MenuControlID.swift b/Sources/Dayline/Models/MenuControlID.swift
index 78cada4..0df1260 100644
--- a/Sources/Dayline/Models/MenuControlID.swift
+++ b/Sources/Dayline/Models/MenuControlID.swift
@@ -8,8 +8,8 @@ enum MenuControlID {
/// Calendar tomorrow disclosure row.
case tomorrowEvents
- /// Opens Google Calendar in the browser.
- case openGoogleCalendar
+ /// Opens one of the available calendar event creation paths.
+ case newCalendarEvent
/// Linear issue creation button.
case newLinearIssue
diff --git a/Sources/Dayline/Services/AppleCalendarService.swift b/Sources/Dayline/Services/AppleCalendarService.swift
index 87045d6..acf7fa3 100644
--- a/Sources/Dayline/Services/AppleCalendarService.swift
+++ b/Sources/Dayline/Services/AppleCalendarService.swift
@@ -30,11 +30,53 @@ final class AppleCalendarService: @unchecked Sendable {
id: calendar.calendarIdentifier,
title: calendar.title,
sourceName: calendar.source.title,
- isEnabled: true
+ isEnabled: true,
+ allowsModifications: calendar.allowsContentModifications
)
}
}
+ /// Identifier of the system calendar used for newly created events.
+ func defaultCalendarIDForNewEvents() -> String? {
+ guard hasFullAccess else { return nil }
+ return eventStore.defaultCalendarForNewEvents?.calendarIdentifier
+ }
+
+ /// Creates one explicitly confirmed event in a writable device calendar.
+ func createEvent(_ draft: AppleCalendarEventCreateDraft) throws {
+ guard hasFullAccess else { throw AppleCalendarServiceError.accessDenied }
+ let title = draft.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !title.isEmpty else { throw AppleCalendarServiceError.missingTitle }
+ guard let calendar = eventStore.calendar(withIdentifier: draft.calendarID),
+ calendar.allowsContentModifications else {
+ throw AppleCalendarServiceError.calendarNotWritable
+ }
+
+ let event = EKEvent(eventStore: eventStore)
+ event.title = title
+ event.calendar = calendar
+ event.isAllDay = draft.isAllDay
+ if draft.isAllDay {
+ let startDay = Calendar.current.startOfDay(for: draft.startDate)
+ let selectedEndDay = Calendar.current.startOfDay(for: draft.endDate)
+ guard selectedEndDay >= startDay else { throw AppleCalendarServiceError.invalidDateRange }
+ event.startDate = startDay
+ event.endDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedEndDay)
+ ?? selectedEndDay.addingTimeInterval(24 * 60 * 60)
+ } else {
+ guard draft.endDate > draft.startDate else { throw AppleCalendarServiceError.invalidDateRange }
+ event.startDate = draft.startDate
+ event.endDate = draft.endDate
+ }
+
+ do {
+ try eventStore.save(event, span: .thisEvent, commit: true)
+ } catch {
+ eventStore.reset()
+ throw error
+ }
+ }
+
/// Loads timed events from the selected calendars in the given window.
func events(in calendarIDs: Set, from start: Date, to end: Date) -> [CalendarEventItem] {
let eventStore = EKEventStore()
@@ -44,8 +86,7 @@ final class AppleCalendarService: @unchecked Sendable {
}
let predicate = eventStore.predicateForEvents(withStart: start, end: end, calendars: calendars)
return eventStore.events(matching: predicate).compactMap { event in
- guard !event.isAllDay,
- let eventID = event.eventIdentifier,
+ guard let eventID = event.eventIdentifier,
let startDate = event.startDate,
let endDate = event.endDate else {
return nil
@@ -65,6 +106,7 @@ final class AppleCalendarService: @unchecked Sendable {
startDate: startDate,
endDate: endDate,
location: event.location,
+ isAllDay: event.isAllDay,
calendarURL: nil,
openURL: event.url,
sourceCalendarNames: [event.calendar.title],
@@ -74,3 +116,24 @@ final class AppleCalendarService: @unchecked Sendable {
}
}
}
+
+/// User-actionable failures from Apple Calendar event creation.
+enum AppleCalendarServiceError: LocalizedError {
+ case accessDenied
+ case missingTitle
+ case calendarNotWritable
+ case invalidDateRange
+
+ var errorDescription: String? {
+ switch self {
+ case .accessDenied:
+ "Apple Calendar access is unavailable. Reconnect it in Settings."
+ case .missingTitle:
+ "Enter an event title."
+ case .calendarNotWritable:
+ "Choose a writable Apple calendar."
+ case .invalidDateRange:
+ "The event must end after it starts."
+ }
+ }
+}
diff --git a/Sources/Dayline/Services/CalendarService.swift b/Sources/Dayline/Services/CalendarService.swift
index a28b29e..0ab3465 100644
--- a/Sources/Dayline/Services/CalendarService.swift
+++ b/Sources/Dayline/Services/CalendarService.swift
@@ -185,6 +185,7 @@ private struct GoogleCalendarEvent: Decodable {
/// Converts a Google event into one account- and calendar-scoped display item.
func displayItem(accountID: UUID, calendar: GoogleCalendarSource, now: Date) -> CalendarEventItem? {
+ let isAllDay = start.isDateOnly && end.isDateOnly
guard status != "cancelled",
let startDate = start.resolvedDate,
let endDate = end.resolvedDate,
@@ -200,6 +201,7 @@ private struct GoogleCalendarEvent: Decodable {
startDate: startDate,
endDate: endDate,
location: location,
+ isAllDay: isAllDay,
calendarURL: htmlLink.flatMap(URL.init(string:)),
openURL: preferredOpenURL,
sourceCalendarNames: [calendar.name],
@@ -244,16 +246,26 @@ private struct GoogleCalendarEntryPoint: Decodable {
}
}
-/// Google Calendar date wrapper; all-day dates intentionally resolve to nil.
-private struct GoogleCalendarEventDate: Decodable {
+/// Google Calendar timestamp or floating all-day date.
+struct GoogleCalendarEventDate: Decodable {
let dateTime: String?
let date: String?
let timeZone: String?
+ var isDateOnly: Bool {
+ dateTime == nil && date != nil
+ }
+
var resolvedDate: Date? {
- guard let dateTime else {
- return nil
+ if let dateTime {
+ return DateParsers.rfc3339Date(from: dateTime)
}
- return DateParsers.rfc3339Date(from: dateTime)
+ guard let date else { return nil }
+ let formatter = DateFormatter()
+ formatter.calendar = Calendar(identifier: .gregorian)
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = .current
+ formatter.dateFormat = "yyyy-MM-dd"
+ return formatter.date(from: date)
}
}
diff --git a/Sources/Dayline/Stores/StatusStore.swift b/Sources/Dayline/Stores/StatusStore.swift
index 077d548..de2e0cd 100644
--- a/Sources/Dayline/Stores/StatusStore.swift
+++ b/Sources/Dayline/Stores/StatusStore.swift
@@ -18,6 +18,12 @@ final class StatusStore: ObservableObject {
/// Timed calendar events for tomorrow.
@Published private(set) var tomorrowEvents: [CalendarEventItem] = []
+ /// All-day calendar events overlapping today.
+ @Published private(set) var allDayEvents: [CalendarEventItem] = []
+
+ /// All-day calendar events overlapping tomorrow.
+ @Published private(set) var tomorrowAllDayEvents: [CalendarEventItem] = []
+
/// Highest priority active Linear issues assigned to the user.
@Published private(set) var issues: [LinearIssueItem] = []
@@ -244,6 +250,13 @@ final class StatusStore: ObservableObject {
}
}
+ /// Whether all-day calendar events appear in the menu agenda.
+ @Published var showsAllDayEvents: Bool {
+ didSet {
+ UserDefaults.standard.set(showsAllDayEvents, forKey: Self.showsAllDayEventsKey)
+ }
+ }
+
/// Whether the Linear section appears in the menu bar popover.
@Published var showsLinearSection: Bool {
didSet {
@@ -511,6 +524,9 @@ final class StatusStore: ObservableObject {
/// Changes when a global hotkey needs to present the GitHub issue creator.
@Published private(set) var githubIssueCreationRequestID = UUID()
+ /// Changes when the Apple Calendar event creator should be presented.
+ @Published private(set) var appleCalendarEventCreationRequestID = UUID()
+
/// Changes when a global hotkey needs to present the Apple Reminder creator.
@Published private(set) var appleReminderCreationRequestID = UUID()
@@ -550,6 +566,7 @@ final class StatusStore: ObservableObject {
private static let assigneePickerHotkeyKey = "assigneePickerHotkey"
private static let showsCalendarSourceNamesKey = "showsCalendarSourceNames"
private static let showsCalendarSectionKey = "showsCalendarSection"
+ private static let showsAllDayEventsKey = "showsAllDayEvents"
private static let showsLinearSectionKey = "showsLinearSection"
private static let showsNotesSectionKey = "showsNotesSection"
private static let notesKeepOnTopKey = "notesKeepOnTop"
@@ -596,6 +613,8 @@ final class StatusStore: ObservableObject {
private static let meetingAlertPostStartGrace: TimeInterval = 10 * 60
private static let todayEventLimit = 6
private static let tomorrowEventLimit = 8
+ private static let todayAllDayEventLimit = 4
+ private static let tomorrowAllDayEventLimit = 6
private let linearService: LinearService
private let githubService = GitHubService()
@@ -715,6 +734,7 @@ final class StatusStore: ObservableObject {
Self.persistShortcut(reminderShortcut, forKey: Self.newAppleReminderShortcutKey)
self.showsCalendarSourceNames = defaults.object(forKey: Self.showsCalendarSourceNamesKey) as? Bool ?? true
self.showsCalendarSection = defaults.object(forKey: Self.showsCalendarSectionKey) as? Bool ?? true
+ self.showsAllDayEvents = defaults.object(forKey: Self.showsAllDayEventsKey) as? Bool ?? false
self.showsLinearSection = defaults.object(forKey: Self.showsLinearSectionKey) as? Bool ?? true
self.showsNotesSection = defaults.object(forKey: Self.showsNotesSectionKey) as? Bool ?? true
self.notesKeepOnTop = defaults.object(forKey: Self.notesKeepOnTopKey) as? Bool ?? false
@@ -1144,6 +1164,21 @@ final class StatusStore: ObservableObject {
showsCalendarSection && (appleCalendarConnected || !dismissedProviders.contains(.google))
}
+ /// Whether at least one Google Calendar account is currently connected.
+ var hasConnectedGoogleCalendar: Bool {
+ googleAccounts.contains(where: \.isConnected)
+ }
+
+ /// All-day events currently allowed by the user's menu preference.
+ var visibleAllDayEvents: [CalendarEventItem] {
+ showsAllDayEvents ? allDayEvents : []
+ }
+
+ /// Tomorrow's all-day events currently allowed by the user's menu preference.
+ var visibleTomorrowAllDayEvents: [CalendarEventItem] {
+ showsAllDayEvents ? tomorrowAllDayEvents : []
+ }
+
/// Whether the issues section should appear in the menu bar popover.
var isIssuesSectionVisible: Bool {
showsLinearSection && activeIssueSource != nil
@@ -1585,6 +1620,25 @@ final class StatusStore: ObservableObject {
NSWorkspace.shared.open(URL(string: "https://calendar.google.com/calendar/u/0/r/week")!)
}
+ /// Writable enabled device calendars offered by the Apple event editor.
+ var writableAppleCalendars: [AppleCalendarSource] {
+ appleCalendars.filter { $0.isEnabled && $0.allowsModifications }
+ }
+
+ /// Whether Apple Calendar can currently accept a new event.
+ var canCreateAppleCalendarEvent: Bool {
+ appleCalendarConnected && !writableAppleCalendars.isEmpty
+ }
+
+ /// Best writable calendar to preselect for a newly opened event editor.
+ var defaultAppleCalendarEventCalendarID: String {
+ let systemDefault = appleCalendarService.defaultCalendarIDForNewEvents()
+ if let systemDefault, writableAppleCalendars.contains(where: { $0.id == systemDefault }) {
+ return systemDefault
+ }
+ return writableAppleCalendars.first?.id ?? ""
+ }
+
/// Persists whether calendar event rows show their source calendar names.
func setShowsCalendarSourceNames(_ shows: Bool) {
showsCalendarSourceNames = shows
@@ -1595,6 +1649,11 @@ final class StatusStore: ObservableObject {
showsCalendarSection = shows
}
+ /// Persists whether all-day events appear in the menu agenda.
+ func setShowsAllDayEvents(_ shows: Bool) {
+ showsAllDayEvents = shows
+ }
+
/// Persists whether the Linear section appears in the menu bar popover.
func setShowsLinearSection(_ shows: Bool) {
showsLinearSection = shows
@@ -1939,6 +1998,49 @@ final class StatusStore: ObservableObject {
appleReminderCreationRequestID = UUID()
}
+ /// Requests the Apple Calendar event creator, resetting its draft.
+ func requestAppleCalendarEventCreation() {
+ appleCalendarEventCreationRequestID = UUID()
+ }
+
+ /// Creates an Apple Calendar event and refreshes the merged agenda.
+ func createAppleCalendarEvent(draft: AppleCalendarEventCreateDraft) async throws {
+ if mockData != nil {
+ let calendar = writableAppleCalendars.first(where: { $0.id == draft.calendarID })
+ guard let calendar else { throw AppleCalendarServiceError.calendarNotWritable }
+ let startDate = draft.isAllDay ? Calendar.current.startOfDay(for: draft.startDate) : draft.startDate
+ let endDate: Date
+ if draft.isAllDay {
+ let selectedEnd = Calendar.current.startOfDay(for: draft.endDate)
+ guard selectedEnd >= startDate else { throw AppleCalendarServiceError.invalidDateRange }
+ endDate = Calendar.current.date(byAdding: .day, value: 1, to: selectedEnd)
+ ?? selectedEnd.addingTimeInterval(24 * 60 * 60)
+ } else {
+ guard draft.endDate > draft.startDate else { throw AppleCalendarServiceError.invalidDateRange }
+ endDate = draft.endDate
+ }
+ let title = draft.title.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !title.isEmpty else { throw AppleCalendarServiceError.missingTitle }
+ appleSourceEvents.append(CalendarEventItem(
+ id: "mock-apple-event-\(UUID().uuidString)",
+ title: title,
+ startDate: startDate,
+ endDate: endDate,
+ location: nil,
+ isAllDay: draft.isAllDay,
+ calendarURL: nil,
+ openURL: nil,
+ sourceCalendarNames: [calendar.title],
+ sourceIDs: [CalendarEventItem.sourceID(accountID: AppleCalendarService.accountID, calendarID: calendar.id)]
+ ))
+ rebuildAgendaFromCachedSources()
+ return
+ }
+
+ try appleCalendarService.createEvent(draft)
+ await refresh()
+ }
+
/// Persists whether the full-screen meeting alert is enabled.
func setMeetingAlertEnabled(_ enabled: Bool) {
meetingAlertEnabled = enabled
@@ -3544,8 +3646,7 @@ final class StatusStore: ObservableObject {
// cannot hide meetings still inside the alert lead window.
meetingAlertEvent = CalendarEventItem.mergedAgenda(googleSourceEvents + appleSourceEvents)
.filter { event in
- // Skip all-day style events that would fire the alert at midnight.
- guard event.endDate.timeIntervalSince(event.startDate) < 24 * 60 * 60 else { return false }
+ guard !event.isAllDay else { return false }
let eventID = event.deduplicationKey ?? event.id
let snoozedUntil = snoozedMeetingAlertUntilByEventID[eventID]
if let snoozedUntil, now < snoozedUntil {
@@ -3695,7 +3796,15 @@ final class StatusStore: ObservableObject {
events = mockData.events
}
googleSourceEvents = events
+ + mockData.tomorrowEvents
+ + mockData.allDayEvents
+ + mockData.tomorrowAllDayEvents
+ appleSourceEvents = []
tomorrowEvents = mockData.tomorrowEvents
+ allDayEvents = mockData.allDayEvents
+ tomorrowAllDayEvents = mockData.tomorrowAllDayEvents
+ appleCalendarConnected = true
+ appleCalendars = mockData.appleCalendars
allIssues = mockData.issueSources.contains(.linear) ? mockData.issues : []
allAppleReminders = mockData.issueSources.contains(.reminders) ? mockData.appleReminders : []
allNotes = mockData.notes
@@ -4000,7 +4109,9 @@ final class StatusStore: ObservableObject {
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow,
todayLimit: Self.todayEventLimit,
- tomorrowLimit: Self.tomorrowEventLimit
+ tomorrowLimit: Self.tomorrowEventLimit,
+ todayAllDayLimit: Self.todayAllDayEventLimit,
+ tomorrowAllDayLimit: Self.tomorrowAllDayEventLimit
)
let warnings = additionalWarnings + sourceBatches.compactMap(\.warning)
let googleBatches = sourceBatches.filter { $0.provider == .google }
@@ -4010,6 +4121,8 @@ final class StatusStore: ObservableObject {
appleSourceEvents: appleBatches.flatMap(\.events),
today: sections.today,
tomorrow: sections.tomorrow,
+ allDayToday: sections.allDayToday,
+ allDayTomorrow: sections.allDayTomorrow,
warnings: Array(Set(warnings)).sorted(),
reauthenticationAccountIDs: reauthenticationAccountIDs,
shouldReplaceGoogleEvents: googleBatches.isEmpty || googleBatches.contains { $0.warning == nil },
@@ -4032,10 +4145,14 @@ final class StatusStore: ObservableObject {
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow,
todayLimit: Self.todayEventLimit,
- tomorrowLimit: Self.tomorrowEventLimit
+ tomorrowLimit: Self.tomorrowEventLimit,
+ todayAllDayLimit: Self.todayAllDayEventLimit,
+ tomorrowAllDayLimit: Self.tomorrowAllDayEventLimit
)
events = sections.today
tomorrowEvents = sections.tomorrow
+ allDayEvents = sections.allDayToday
+ tomorrowAllDayEvents = sections.allDayTomorrow
}
/// Loads Linear issues and packages thrown errors as `Result`.
@@ -4123,6 +4240,8 @@ struct CalendarAgendaLoadResult: Sendable {
let appleSourceEvents: [CalendarEventItem]
let today: [CalendarEventItem]
let tomorrow: [CalendarEventItem]
+ let allDayToday: [CalendarEventItem]
+ let allDayTomorrow: [CalendarEventItem]
let warnings: [String]
let reauthenticationAccountIDs: Set
let shouldReplaceGoogleEvents: Bool
diff --git a/Sources/Dayline/Support/AppleCalendarEventEditorWindowPresenter.swift b/Sources/Dayline/Support/AppleCalendarEventEditorWindowPresenter.swift
new file mode 100644
index 0000000..a381a16
--- /dev/null
+++ b/Sources/Dayline/Support/AppleCalendarEventEditorWindowPresenter.swift
@@ -0,0 +1,8 @@
+import AppKit
+
+/// Brings the Apple Calendar event creator forward for the menu-bar accessory app.
+enum AppleCalendarEventEditorWindowPresenter {
+ static func bringEventWindowToFront() {
+ WindowPresenterSupport.bringWindowToFront(titled: ["New Apple Calendar Event"])
+ }
+}
diff --git a/Sources/Dayline/Support/MockData.swift b/Sources/Dayline/Support/MockData.swift
index 4071996..48a21b3 100644
--- a/Sources/Dayline/Support/MockData.swift
+++ b/Sources/Dayline/Support/MockData.swift
@@ -6,6 +6,9 @@ struct MockData {
let availableUpdateVersion: String?
let events: [CalendarEventItem]
let tomorrowEvents: [CalendarEventItem]
+ let allDayEvents: [CalendarEventItem]
+ let tomorrowAllDayEvents: [CalendarEventItem]
+ let appleCalendars: [AppleCalendarSource]
let issues: [LinearIssueItem]
let notes: [LocalNoteItem]
let connectionStatuses: [ConnectionStatus]
@@ -58,6 +61,27 @@ struct MockData {
)
}
+ func allDayEvent(
+ _ id: String,
+ _ title: String,
+ start: Date,
+ end: Date,
+ source: String
+ ) -> CalendarEventItem {
+ CalendarEventItem(
+ id: id,
+ title: title,
+ startDate: calendar.startOfDay(for: start),
+ endDate: calendar.startOfDay(for: end),
+ location: nil,
+ isAllDay: true,
+ calendarURL: URL(string: "https://calendar.google.com"),
+ openURL: nil,
+ sourceCalendarNames: [source],
+ deduplicationKey: "mock-\(id)"
+ )
+ }
+
func issue(
_ id: String,
_ title: String,
@@ -225,6 +249,40 @@ struct MockData {
event("mock-planning", "Weekly planning", startsIn: calendar.dateComponents([.minute], from: now, to: calendar.date(byAdding: .hour, value: 1, to: tomorrow) ?? tomorrow).minute ?? 0, duration: 45, source: "Work"),
event("mock-coffee", "Coffee with Maya", startsIn: calendar.dateComponents([.minute], from: now, to: calendar.date(byAdding: .hour, value: 4, to: tomorrow) ?? tomorrow).minute ?? 0, duration: 45, location: "Juniper Cafe", source: "Personal")
],
+ allDayEvents: [
+ allDayEvent(
+ "mock-all-day-today",
+ "Product launch day",
+ start: now,
+ end: calendar.date(byAdding: .day, value: 1, to: now) ?? now,
+ source: "Work"
+ )
+ ],
+ tomorrowAllDayEvents: [
+ allDayEvent(
+ "mock-all-day-tomorrow",
+ "Maya's birthday",
+ start: tomorrow,
+ end: calendar.date(byAdding: .day, value: 1, to: tomorrow) ?? tomorrow,
+ source: "Birthdays"
+ )
+ ],
+ appleCalendars: [
+ AppleCalendarSource(
+ id: "mock-apple-work",
+ title: "Work",
+ sourceName: "iCloud",
+ isEnabled: true,
+ allowsModifications: true
+ ),
+ AppleCalendarSource(
+ id: "mock-apple-read-only",
+ title: "Subscribed",
+ sourceName: "iCloud",
+ isEnabled: true,
+ allowsModifications: false
+ )
+ ],
issues: issues,
notes: [
note("mock-note-1", "Landing page ideas\nTry a warmer background and keep the hero quiet.", updatedMinutesAgo: 8),
diff --git a/Sources/Dayline/Views/AppleCalendarEventEditorView.swift b/Sources/Dayline/Views/AppleCalendarEventEditorView.swift
new file mode 100644
index 0000000..d3596fb
--- /dev/null
+++ b/Sources/Dayline/Views/AppleCalendarEventEditorView.swift
@@ -0,0 +1,185 @@
+import SwiftUI
+
+/// Native window for creating an event in Apple Calendar through EventKit.
+struct AppleCalendarEventEditorView: View {
+ @EnvironmentObject private var store: StatusStore
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var draft = AppleCalendarEventCreateDraft()
+ @State private var errorMessage: String?
+ @State private var isCreating = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Form {
+ Section {
+ TextField("Title", text: $draft.title, prompt: Text("Event title"))
+ .accessibilityIdentifier("calendarEventEditor.title")
+
+ Picker("Calendar", selection: $draft.calendarID) {
+ if !store.writableAppleCalendars.contains(where: { $0.id == draft.calendarID }) {
+ Text("No writable enabled calendar selected").tag("")
+ }
+ ForEach(store.writableAppleCalendars) { calendar in
+ Text("\(calendar.title) · \(calendar.sourceName)").tag(calendar.id)
+ }
+ }
+ .accessibilityIdentifier("calendarEventEditor.calendar")
+
+ if store.writableAppleCalendars.isEmpty {
+ Text("Enable a writable Apple calendar in Settings → Accounts to create events.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityIdentifier("calendarEventEditor.noWritableCalendar")
+ }
+
+ Toggle("All-day event", isOn: $draft.isAllDay)
+ .accessibilityIdentifier("calendarEventEditor.allDay")
+
+ DatePicker(
+ "Starts",
+ selection: $draft.startDate,
+ displayedComponents: draft.isAllDay ? [.date] : [.date, .hourAndMinute]
+ )
+ .accessibilityIdentifier("calendarEventEditor.start")
+
+ DatePicker(
+ "Ends",
+ selection: $draft.endDate,
+ displayedComponents: draft.isAllDay ? [.date] : [.date, .hourAndMinute]
+ )
+ .accessibilityIdentifier("calendarEventEditor.end")
+
+ if draft.isAllDay {
+ Text("The end date is inclusive.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ } header: {
+ Label("Event", systemImage: "calendar.badge.plus")
+ }
+ }
+ .formStyle(.grouped)
+
+ if let errorMessage {
+ Text(errorMessage)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 20)
+ .padding(.bottom, 6)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .accessibilityIdentifier("calendarEventEditor.error")
+ }
+
+ Divider()
+
+ HStack {
+ Spacer()
+
+ Button("Cancel") {
+ dismiss()
+ }
+ .keyboardShortcut(.cancelAction)
+ .accessibilityIdentifier("calendarEventEditor.cancel")
+
+ Button(isCreating ? "Creating..." : "Create") {
+ guard canCreate else { return }
+ isCreating = true
+ Task { await createEvent() }
+ }
+ .keyboardShortcut(.defaultAction)
+ .disabled(!canCreate)
+ .accessibilityIdentifier("calendarEventEditor.create")
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 14)
+ }
+ .frame(minWidth: 500, idealWidth: 560, minHeight: 380, idealHeight: 440)
+ .task {
+ resetDraft()
+ }
+ .onChange(of: store.appleCalendarEventCreationRequestID) { _, _ in
+ resetDraft()
+ }
+ .onChange(of: store.writableAppleCalendars.map(\.id)) { _, validIDs in
+ if !validIDs.contains(draft.calendarID) {
+ draft.calendarID = store.defaultAppleCalendarEventCalendarID
+ }
+ }
+ .onChange(of: draft.startDate) { oldStart, newStart in
+ repairEndDate(previousStart: oldStart, newStart: newStart)
+ }
+ .onChange(of: draft.isAllDay) { _, isAllDay in
+ if isAllDay {
+ draft.startDate = Calendar.current.startOfDay(for: draft.startDate)
+ draft.endDate = max(
+ Calendar.current.startOfDay(for: draft.endDate),
+ draft.startDate
+ )
+ } else if draft.endDate <= draft.startDate {
+ draft.endDate = draft.startDate.addingTimeInterval(30 * 60)
+ }
+ }
+ }
+
+ private var canCreate: Bool {
+ !isCreating
+ && !draft.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ && store.writableAppleCalendars.contains { $0.id == draft.calendarID }
+ && validDateRange
+ }
+
+ private var validDateRange: Bool {
+ if draft.isAllDay {
+ return Calendar.current.startOfDay(for: draft.endDate)
+ >= Calendar.current.startOfDay(for: draft.startDate)
+ }
+ return draft.endDate > draft.startDate
+ }
+
+ private func createEvent() async {
+ defer { isCreating = false }
+ errorMessage = nil
+ do {
+ try await store.createAppleCalendarEvent(draft: draft)
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription.compactLine(limit: 160)
+ }
+ }
+
+ private func resetDraft() {
+ let calendar = Calendar.current
+ let now = Date()
+ let minute = calendar.component(.minute, from: now)
+ let minutesToNextHalfHour = minute < 30 ? 30 - minute : 60 - minute
+ let start = calendar.date(byAdding: .minute, value: minutesToNextHalfHour, to: now) ?? now
+ let roundedStart = calendar.date(bySetting: .second, value: 0, of: start) ?? start
+ draft = AppleCalendarEventCreateDraft(
+ title: "",
+ calendarID: store.defaultAppleCalendarEventCalendarID,
+ startDate: roundedStart,
+ endDate: roundedStart.addingTimeInterval(30 * 60),
+ isAllDay: false
+ )
+ errorMessage = nil
+ }
+
+ private func repairEndDate(previousStart: Date, newStart: Date) {
+ if draft.isAllDay {
+ let startDay = Calendar.current.startOfDay(for: newStart)
+ if Calendar.current.startOfDay(for: draft.endDate) < startDay {
+ draft.endDate = startDay
+ }
+ return
+ }
+
+ let previousDuration = draft.endDate.timeIntervalSince(previousStart)
+ if previousDuration > 0 {
+ draft.endDate = newStart.addingTimeInterval(previousDuration)
+ } else if draft.endDate <= newStart {
+ draft.endDate = newStart.addingTimeInterval(30 * 60)
+ }
+ }
+}
diff --git a/Sources/Dayline/Views/PreviewPopovers.swift b/Sources/Dayline/Views/PreviewPopovers.swift
index 4cf319b..2531a66 100644
--- a/Sources/Dayline/Views/PreviewPopovers.swift
+++ b/Sources/Dayline/Views/PreviewPopovers.swift
@@ -23,6 +23,15 @@ struct EventPreviewPopover: View {
/// Compact start–end time range for the preview.
private var timeRange: String {
+ if event.isAllDay {
+ let calendar = Calendar.current
+ let inclusiveEnd = calendar.date(byAdding: .day, value: -1, to: event.endDate) ?? event.endDate
+ let startDay = event.startDate.formatted(date: .abbreviated, time: .omitted)
+ let endDay = inclusiveEnd.formatted(date: .abbreviated, time: .omitted)
+ return calendar.isDate(event.startDate, inSameDayAs: inclusiveEnd)
+ ? "\(startDay), all day"
+ : "\(startDay) – \(endDay), all day"
+ }
let start = event.startDate.formatted(date: .omitted, time: .shortened)
let end = event.endDate.formatted(date: .omitted, time: .shortened)
let day = event.startDate.formatted(date: .abbreviated, time: .omitted)
diff --git a/Sources/Dayline/Views/Settings/CalendarSettingsTab.swift b/Sources/Dayline/Views/Settings/CalendarSettingsTab.swift
index 7551a47..f30cd61 100644
--- a/Sources/Dayline/Views/Settings/CalendarSettingsTab.swift
+++ b/Sources/Dayline/Views/Settings/CalendarSettingsTab.swift
@@ -36,6 +36,9 @@ struct CalendarSettingsTab: View {
Toggle("Show calendar names", isOn: showsCalendarSourceNamesBinding)
.accessibilityIdentifier("settings.showsCalendarSourceNames")
+
+ Toggle("Show all-day events", isOn: showsAllDayEventsBinding)
+ .accessibilityIdentifier("settings.showsAllDayEvents")
} header: {
Label("Menu", systemImage: "list.bullet")
}
@@ -109,6 +112,14 @@ struct CalendarSettingsTab: View {
)
}
+ /// Binding that persists whether all-day events appear in the menu.
+ private var showsAllDayEventsBinding: Binding {
+ Binding(
+ get: { store.showsAllDayEvents },
+ set: { store.setShowsAllDayEvents($0) }
+ )
+ }
+
/// Binding that persists whether full-screen meeting alerts are enabled.
private var meetingAlertEnabledBinding: Binding {
Binding(
diff --git a/Sources/Dayline/Views/Settings/SettingsTab.swift b/Sources/Dayline/Views/Settings/SettingsTab.swift
index 8982cd9..c162217 100644
--- a/Sources/Dayline/Views/Settings/SettingsTab.swift
+++ b/Sources/Dayline/Views/Settings/SettingsTab.swift
@@ -69,6 +69,7 @@ enum SettingsSearchCatalog {
SettingsSearchItem(id: "menuBarEventLeadTime", title: "Show title before", section: "Menu Bar Title", tab: .calendar, keywords: ["lead time", "upcoming meeting", "menu bar title", "event title"]),
SettingsSearchItem(id: "menuBarEventPostStartGrace", title: "Show title after", section: "Menu Bar Title", tab: .calendar, keywords: ["grace", "meeting started", "menu bar title", "event title"]),
SettingsSearchItem(id: "showsCalendarSection", title: "Show calendar in menu", section: "Menu", tab: .calendar, keywords: ["calendar section", "events in menu"]),
+ SettingsSearchItem(id: "showsAllDayEvents", title: "Show all-day events", section: "Menu", tab: .calendar, keywords: ["calendar all day", "birthdays", "holidays"]),
SettingsSearchItem(id: "showsLinearSection", title: "Show issues in menu", section: "Menu", tab: .issues, keywords: ["issues section", "linear section", "tickets in menu"]),
SettingsSearchItem(id: "showsNotesSection", title: "Show notes in menu", section: "Menu", tab: .notes, keywords: ["notes section"]),
diff --git a/Sources/Dayline/Views/StatusMenuView.swift b/Sources/Dayline/Views/StatusMenuView.swift
index 5bc29dd..7167313 100644
--- a/Sources/Dayline/Views/StatusMenuView.swift
+++ b/Sources/Dayline/Views/StatusMenuView.swift
@@ -40,6 +40,8 @@ struct StatusMenuView: View {
CalendarSection(
events: store.events,
tomorrowEvents: store.tomorrowEvents,
+ allDayEvents: store.visibleAllDayEvents,
+ tomorrowAllDayEvents: store.visibleTomorrowAllDayEvents,
warnings: store.calendarWarnings,
hoveredEventID: store.hoveredEventID,
isTomorrowExpanded: store.isTomorrowExpanded,
@@ -234,9 +236,11 @@ struct StatusMenuView: View {
let setupItemCount = store.connectionSetupItems.count + store.googleAccountsNeedingAttention.count
let setupRows = store.hasConnectionSetupItems ? CGFloat(max(setupItemCount, 1)) * 64 + 44 : 0
let eventRowEstimate: CGFloat = store.showsCalendarSourceNames ? 48 : compactEventRowHeight
- let eventRows = store.isCalendarSectionVisible ? CGFloat(max(store.events.count, 1)) * eventRowEstimate : 0
+ let todayCalendarRowCount = store.events.count + store.visibleAllDayEvents.count
+ let eventRows = store.isCalendarSectionVisible ? CGFloat(max(todayCalendarRowCount, 1)) * eventRowEstimate : 0
+ let tomorrowCalendarRowCount = store.tomorrowEvents.count + store.visibleTomorrowAllDayEvents.count
let tomorrowRows = store.isCalendarSectionVisible && store.isTomorrowExpanded
- ? CGFloat(max(store.tomorrowEvents.count, 1)) * eventRowEstimate + 34 : 0
+ ? CGFloat(max(tomorrowCalendarRowCount, 1)) * eventRowEstimate + 34 : 0
let issueCounts: [IssueSource: Int] = [
.linear: store.issues.count,
.github: store.githubIssues.count,
@@ -1447,6 +1451,12 @@ private struct CalendarSection: View {
/// Tomorrow's timed calendar events.
let tomorrowEvents: [CalendarEventItem]
+ /// All-day events overlapping today.
+ let allDayEvents: [CalendarEventItem]
+
+ /// All-day events overlapping tomorrow.
+ let tomorrowAllDayEvents: [CalendarEventItem]
+
/// Recoverable account- or calendar-scoped loading warnings.
let warnings: [String]
@@ -1467,22 +1477,7 @@ private struct CalendarSection: View {
Spacer(minLength: 0)
- Button {
- store.openGoogleCalendar()
- } label: {
- Image(systemName: "plus")
- .padding(5)
- .contentShape(Rectangle())
- .hoverHighlight(isHovered: store.hoveredControlID == .openGoogleCalendar)
- }
- .buttonStyle(.plain)
- .help("Open Google Calendar")
- .accessibilityLabel("Open Google Calendar")
- .accessibilityHint("Open Google Calendar's week view to create an event")
- .accessibilityIdentifier("calendar.new")
- .onHover { isHovered in
- store.setHoveredControl(isHovered ? .openGoogleCalendar : nil)
- }
+ newEventControl
}
ForEach(Array(warnings.prefix(2).enumerated()), id: \.offset) { _, warning in
@@ -1490,26 +1485,18 @@ private struct CalendarSection: View {
}
VStack(alignment: .leading, spacing: 0) {
- if events.isEmpty {
+ if events.isEmpty && allDayEvents.isEmpty {
MessageRow(title: "No more events today", detail: nil)
.padding(.horizontal, 16)
.padding(.vertical, 5)
} else {
+ ForEach(allDayEvents) { event in
+ calendarEventRow(event)
+ }
+
ForEach(events) { event in
- EventRow(
- event: event,
- isHovered: hoveredEventID == event.id,
- now: now,
- showsSource: store.showsCalendarSourceNames,
- isCopied: store.copiedEventID == event.id
- )
- .onHover { isHovered in
- store.setHoveredEvent(isHovered ? event.id : nil)
- }
- .popover(isPresented: eventPreviewBinding(for: event.id), arrowEdge: .trailing) {
- EventPreviewPopover(event: event)
- }
- }
+ calendarEventRow(event)
+ }
}
TomorrowEventsButton(isExpanded: isTomorrowExpanded) {
@@ -1520,26 +1507,18 @@ private struct CalendarSection: View {
VStack(alignment: .leading, spacing: 8) {
SectionTitle(title: "Tomorrow")
- if tomorrowEvents.isEmpty {
- MessageRow(title: "No timed events tomorrow", detail: nil)
+ if tomorrowEvents.isEmpty && tomorrowAllDayEvents.isEmpty {
+ MessageRow(title: "No events tomorrow", detail: nil)
.padding(.horizontal, 16)
.padding(.vertical, 5)
} else {
VStack(alignment: .leading, spacing: 0) {
+ ForEach(tomorrowAllDayEvents) { event in
+ calendarEventRow(event)
+ }
+
ForEach(tomorrowEvents) { event in
- EventRow(
- event: event,
- isHovered: hoveredEventID == event.id,
- now: now,
- showsSource: store.showsCalendarSourceNames,
- isCopied: store.copiedEventID == event.id
- )
- .onHover { isHovered in
- store.setHoveredEvent(isHovered ? event.id : nil)
- }
- .popover(isPresented: eventPreviewBinding(for: event.id), arrowEdge: .trailing) {
- EventPreviewPopover(event: event)
- }
+ calendarEventRow(event)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -1552,6 +1531,80 @@ private struct CalendarSection: View {
}
}
+ /// Provider-aware control that creates an Apple event or opens Google Calendar.
+ @ViewBuilder
+ private var newEventControl: some View {
+ if store.appleCalendarConnected && store.hasConnectedGoogleCalendar {
+ Menu {
+ Button("New Apple Calendar Event", systemImage: "calendar.badge.plus") {
+ store.requestAppleCalendarEventCreation()
+ }
+ .disabled(!store.canCreateAppleCalendarEvent)
+ .accessibilityIdentifier("calendar.new.apple")
+
+ Button("Open Google Calendar", systemImage: "globe") {
+ store.openGoogleCalendar()
+ }
+ .accessibilityIdentifier("calendar.new.google")
+ } label: {
+ newEventIcon
+ }
+ .menuStyle(.borderlessButton)
+ .menuIndicator(.hidden)
+ .fixedSize()
+ .help("Create a calendar event")
+ .accessibilityLabel("Create a calendar event")
+ .accessibilityIdentifier("calendar.new")
+ .onHover { isHovered in
+ store.setHoveredControl(isHovered ? .newCalendarEvent : nil)
+ }
+ } else {
+ Button {
+ if store.appleCalendarConnected {
+ store.requestAppleCalendarEventCreation()
+ } else {
+ store.openGoogleCalendar()
+ }
+ } label: {
+ newEventIcon
+ }
+ .buttonStyle(.plain)
+ .disabled(store.appleCalendarConnected && !store.canCreateAppleCalendarEvent)
+ .help(store.appleCalendarConnected ? "New Apple Calendar event" : "Open Google Calendar")
+ .accessibilityLabel(store.appleCalendarConnected ? "New Apple Calendar event" : "Open Google Calendar")
+ .accessibilityHint("Open the available calendar event creation flow")
+ .accessibilityIdentifier("calendar.new")
+ .onHover { isHovered in
+ store.setHoveredControl(isHovered ? .newCalendarEvent : nil)
+ }
+ }
+ }
+
+ /// Shared plus icon for the provider-aware calendar action.
+ private var newEventIcon: some View {
+ Image(systemName: "plus")
+ .padding(5)
+ .contentShape(Rectangle())
+ .hoverHighlight(isHovered: store.hoveredControlID == .newCalendarEvent)
+ }
+
+ /// One calendar row with shared hover and preview behavior.
+ private func calendarEventRow(_ event: CalendarEventItem) -> some View {
+ EventRow(
+ event: event,
+ isHovered: hoveredEventID == event.id,
+ now: now,
+ showsSource: store.showsCalendarSourceNames,
+ isCopied: store.copiedEventID == event.id
+ )
+ .onHover { isHovered in
+ store.setHoveredEvent(isHovered ? event.id : nil)
+ }
+ .popover(isPresented: eventPreviewBinding(for: event.id), arrowEdge: .trailing) {
+ EventPreviewPopover(event: event)
+ }
+ }
+
/// Binding that anchors the detail preview to its selected event row.
private func eventPreviewBinding(for eventID: CalendarEventItem.ID) -> Binding {
Binding(
@@ -2689,7 +2742,7 @@ private struct EventRow: View {
/// Main event content.
private var eventContent: some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
- Text(DisplayFormatters.eventTimeRange(start: event.startDate, end: event.endDate))
+ Text(event.isAllDay ? "All day" : DisplayFormatters.eventTimeRange(start: event.startDate, end: event.endDate))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -2736,7 +2789,7 @@ private struct EventRow: View {
/// VoiceOver summary for the calendar event row.
private var accessibilityLabel: String {
- let time = DisplayFormatters.eventTimeRange(start: event.startDate, end: event.endDate)
+ let time = event.isAllDay ? "All day" : DisplayFormatters.eventTimeRange(start: event.startDate, end: event.endDate)
if let source = event.accessibilitySourceLabel {
return "\(event.title), \(time), \(source)"
}
@@ -2745,7 +2798,7 @@ private struct EventRow: View {
/// Whether this calendar event is currently in progress.
private var isCurrent: Bool {
- event.isHappening(at: now)
+ !event.isAllDay && event.isHappening(at: now)
}
/// Subtle row background that keeps active meetings visibly green.
diff --git a/Tests/DaylineTests/AppleCalendarEventCreationTests.swift b/Tests/DaylineTests/AppleCalendarEventCreationTests.swift
new file mode 100644
index 0000000..1cbe484
--- /dev/null
+++ b/Tests/DaylineTests/AppleCalendarEventCreationTests.swift
@@ -0,0 +1,61 @@
+import Foundation
+import Testing
+@testable import Dayline
+
+struct AppleCalendarEventCreationTests {
+ @Test @MainActor func mockStoreExposesOnlyWritableEnabledCalendarsForCreation() throws {
+ let store = StatusStore(mockData: MockData.make())
+
+ #expect(store.appleCalendarConnected)
+ #expect(store.hasConnectedGoogleCalendar)
+ #expect(store.canCreateAppleCalendarEvent)
+ #expect(store.writableAppleCalendars.map(\.id) == ["mock-apple-work"])
+ #expect(store.defaultAppleCalendarEventCalendarID == "mock-apple-work")
+ }
+
+ @Test @MainActor func eventCreationRequestAlwaysAdvances() {
+ let store = StatusStore(mockData: MockData.make())
+ let previousRequest = store.appleCalendarEventCreationRequestID
+
+ store.requestAppleCalendarEventCreation()
+
+ #expect(store.appleCalendarEventCreationRequestID != previousRequest)
+ }
+
+ @Test @MainActor func mockAllDayCreationStoresAnExclusiveEndDate() async throws {
+ let store = StatusStore(mockData: MockData.make())
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = .current
+ let start = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 10)))
+ let inclusiveEnd = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 11)))
+
+ try await store.createAppleCalendarEvent(draft: AppleCalendarEventCreateDraft(
+ title: "Conference",
+ calendarID: "mock-apple-work",
+ startDate: start,
+ endDate: inclusiveEnd,
+ isAllDay: true
+ ))
+
+ let created = try #require(store.allDayEvents.first(where: { $0.title == "Conference" }))
+ #expect(created.isAllDay)
+ #expect(created.startDate == start)
+ #expect(created.endDate == calendar.date(byAdding: .day, value: 1, to: inclusiveEnd))
+ #expect(!store.events.contains(where: { $0.id == created.id }))
+ }
+
+ @Test @MainActor func mockTimedCreationRejectsInvalidRanges() async {
+ let store = StatusStore(mockData: MockData.make())
+ let start = Date(timeIntervalSince1970: 10_000)
+
+ await #expect(throws: AppleCalendarServiceError.self) {
+ try await store.createAppleCalendarEvent(draft: AppleCalendarEventCreateDraft(
+ title: "Broken",
+ calendarID: "mock-apple-work",
+ startDate: start,
+ endDate: start,
+ isAllDay: false
+ ))
+ }
+ }
+}
diff --git a/Tests/DaylineTests/CalendarEventItemTests.swift b/Tests/DaylineTests/CalendarEventItemTests.swift
index 1959c9a..eaa1a22 100644
--- a/Tests/DaylineTests/CalendarEventItemTests.swift
+++ b/Tests/DaylineTests/CalendarEventItemTests.swift
@@ -62,6 +62,25 @@ struct CalendarEventItemTests {
#expect(candidate == nil)
}
+ @Test func menuBarCandidateNeverUsesAllDayEvents() {
+ let now = Date(timeIntervalSince1970: 10_000)
+ let allDay = event(
+ id: "all-day",
+ startDate: now.addingTimeInterval(-60 * 60),
+ endDate: now.addingTimeInterval(20 * 60 * 60),
+ isAllDay: true
+ )
+
+ let candidate = CalendarEventItem.menuBarCandidate(
+ in: [allDay],
+ at: now,
+ leadTime: 30 * 60,
+ postStartGrace: 5 * 60
+ )
+
+ #expect(candidate == nil)
+ }
+
@Test func mergedAgendaCollapsesSharedMeetingOccurrenceAndCombinesSources() {
let start = Date(timeIntervalSince1970: 20_000)
let workCopy = event(
@@ -228,6 +247,56 @@ struct CalendarEventItemTests {
#expect(sections.tomorrow.map(\.id) == ["tomorrow-1"])
}
+ @Test func agendaSectionsKeepsAllDayEventsOutOfTimedLimits() {
+ let tomorrowStart = Date(timeIntervalSince1970: 86_400)
+ let dayAfterTomorrow = tomorrowStart.addingTimeInterval(86_400)
+ let timed = event(
+ id: "timed",
+ startDate: Date(timeIntervalSince1970: 20_000),
+ endDate: Date(timeIntervalSince1970: 21_000)
+ )
+ let allDay = event(
+ id: "all-day",
+ startDate: Date(timeIntervalSince1970: 0),
+ endDate: tomorrowStart,
+ isAllDay: true
+ )
+ let multiDay = event(
+ id: "multi-day",
+ startDate: Date(timeIntervalSince1970: 0),
+ endDate: dayAfterTomorrow,
+ isAllDay: true
+ )
+
+ let sections = CalendarEventItem.agendaSections(
+ from: [allDay, timed, multiDay],
+ tomorrowStart: tomorrowStart,
+ dayAfterTomorrow: dayAfterTomorrow,
+ todayLimit: 1,
+ tomorrowLimit: 1,
+ todayAllDayLimit: 1,
+ tomorrowAllDayLimit: 1
+ )
+
+ #expect(sections.today == [timed])
+ #expect(sections.allDayToday.map(\.id) == ["all-day"])
+ #expect(sections.allDayTomorrow == [multiDay])
+ }
+
+ @Test func googleDateOnlyValuesResolveAsLocalGregorianDays() throws {
+ let value = try JSONDecoder().decode(
+ GoogleCalendarEventDate.self,
+ from: Data(#"{"date":"2026-08-10"}"#.utf8)
+ )
+ let date = try #require(value.resolvedDate)
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = .current
+
+ #expect(value.isDateOnly)
+ #expect(calendar.dateComponents([.year, .month, .day], from: date)
+ == DateComponents(year: 2026, month: 8, day: 10))
+ }
+
@Test @MainActor func partialSourceFailureRetainsSuccessfulEvents() {
let tomorrowStart = Date(timeIntervalSince1970: 86_400)
let dayAfterTomorrow = tomorrowStart.addingTimeInterval(86_400)
@@ -299,7 +368,8 @@ struct CalendarEventItemTests {
endDate: Date,
source: String? = nil,
deduplicationKey: String? = nil,
- sourceIDs: [String] = []
+ sourceIDs: [String] = [],
+ isAllDay: Bool = false
) -> CalendarEventItem {
CalendarEventItem(
id: id,
@@ -307,6 +377,7 @@ struct CalendarEventItemTests {
startDate: startDate,
endDate: endDate,
location: nil,
+ isAllDay: isAllDay,
calendarURL: nil,
openURL: nil,
sourceCalendarNames: source.map { [$0] } ?? [],
diff --git a/UITests/DaylineUITests/DaylineUITests.swift b/UITests/DaylineUITests/DaylineUITests.swift
index ed9f14c..65a0c44 100644
--- a/UITests/DaylineUITests/DaylineUITests.swift
+++ b/UITests/DaylineUITests/DaylineUITests.swift
@@ -432,6 +432,24 @@ final class DaylineUITests: XCTestCase {
func testCreatesLinearGitHubAndAppleReminder() throws {
try openMenu()
+ element("calendar.new").click()
+ let appleCalendarMenuItem = app.menuItems["New Apple Calendar Event"]
+ XCTAssertTrue(appleCalendarMenuItem.waitForExistence(timeout: 3))
+ appleCalendarMenuItem.click()
+ let calendarEventTitle = element("calendarEventEditor.title")
+ XCTAssertTrue(calendarEventTitle.waitForExistence(timeout: 5))
+ calendarEventTitle.click()
+ calendarEventTitle.typeText("Automated Apple Calendar event")
+ assertEnabled("calendarEventEditor.create")
+ element("calendarEventEditor.create").click()
+
+ try openMenu()
+ XCTAssertTrue(
+ app.descendants(matching: .any)
+ .matching(NSPredicate(format: "label BEGINSWITH %@", "Automated Apple Calendar event"))
+ .firstMatch.waitForExistence(timeout: 5)
+ )
+
element("linear.new").click()
let linearTitle = element("linearEditor.title")
XCTAssertTrue(linearTitle.waitForExistence(timeout: 5))
@@ -648,7 +666,7 @@ final class DaylineUITests: XCTestCase {
func testOpensSettings() throws {
try openMenu()
- XCTContext.runActivity(named: "Open Settings and verify General controls") { _ in
+ try XCTContext.runActivity(named: "Open Settings and verify General controls") { _ in
element("dayline.settings").click()
XCTAssertTrue(app.windows["settings"].waitForExistence(timeout: 5))
@@ -664,6 +682,13 @@ final class DaylineUITests: XCTestCase {
XCTAssertTrue(calendarTab.waitForExistence(timeout: 5))
calendarTab.click()
assertExists("settings.meetingAlertSnooze")
+ let allDayToggle = element("settings.showsAllDayEvents")
+ assertExists("settings.showsAllDayEvents")
+ allDayToggle.click()
+ app.typeKey("w", modifierFlags: .command)
+
+ try openMenu()
+ assertExists("calendar.event.mock-all-day-today")
}
}
diff --git a/script/build_and_run.sh b/script/build_and_run.sh
index 69bb240..04dc811 100755
--- a/script/build_and_run.sh
+++ b/script/build_and_run.sh
@@ -236,8 +236,8 @@ BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || echo 0)"
/usr/bin/plutil -insert LSMultipleInstancesProhibited -bool YES "$INFO_PLIST"
/usr/bin/plutil -insert LSUIElement -bool YES "$INFO_PLIST"
/usr/bin/plutil -insert NSPrincipalClass -string "NSApplication" "$INFO_PLIST"
-/usr/bin/plutil -insert NSCalendarsUsageDescription -string "Dayline shows your upcoming Apple Calendar events in the menu bar." "$INFO_PLIST"
-/usr/bin/plutil -insert NSCalendarsFullAccessUsageDescription -string "Dayline shows your upcoming Apple Calendar events in the menu bar." "$INFO_PLIST"
+/usr/bin/plutil -insert NSCalendarsUsageDescription -string "Dayline shows your Apple Calendar events and creates events when you ask." "$INFO_PLIST"
+/usr/bin/plutil -insert NSCalendarsFullAccessUsageDescription -string "Dayline shows your Apple Calendar events and creates events when you ask." "$INFO_PLIST"
/usr/bin/plutil -insert NSRemindersFullAccessUsageDescription -string "Dayline shows and manages your Apple Reminders from the menu bar." "$INFO_PLIST"
/usr/bin/plutil -insert DaylineApplicationSupportFolder -string "$APPLICATION_SUPPORT_FOLDER" "$INFO_PLIST"
/usr/bin/plutil -insert DaylineDevelopmentBuild -bool YES "$INFO_PLIST"
diff --git a/script/build_mock_and_run.sh b/script/build_mock_and_run.sh
index e69a12a..45eb426 100755
--- a/script/build_mock_and_run.sh
+++ b/script/build_mock_and_run.sh
@@ -71,9 +71,9 @@ cat >"$INFO_PLIST" <LSUIElement
NSCalendarsUsageDescription
- Dayline shows your upcoming Apple Calendar events in the menu bar.
+ Dayline shows your Apple Calendar events and creates events when you ask.
NSCalendarsFullAccessUsageDescription
- Dayline shows your upcoming Apple Calendar events in the menu bar.
+ Dayline shows your Apple Calendar events and creates events when you ask.
NSRemindersFullAccessUsageDescription
Dayline shows and manages your Apple Reminders from the menu bar.
NSPrincipalClass
diff --git a/script/package_release.sh b/script/package_release.sh
index ed11a08..ae7f90b 100755
--- a/script/package_release.sh
+++ b/script/package_release.sh
@@ -188,8 +188,8 @@ write_info_plist() {
/usr/bin/plutil -insert CFBundlePackageType -string "APPL" "$INFO_PLIST"
/usr/bin/plutil -insert CFBundleShortVersionString -string "$VERSION" "$INFO_PLIST"
/usr/bin/plutil -insert CFBundleVersion -string "$BUILD_NUMBER_RESOLVED" "$INFO_PLIST"
- /usr/bin/plutil -insert NSCalendarsUsageDescription -string "Dayline shows your upcoming Apple Calendar events in the menu bar." "$INFO_PLIST"
- /usr/bin/plutil -insert NSCalendarsFullAccessUsageDescription -string "Dayline shows your upcoming Apple Calendar events in the menu bar." "$INFO_PLIST"
+ /usr/bin/plutil -insert NSCalendarsUsageDescription -string "Dayline shows your Apple Calendar events and creates events when you ask." "$INFO_PLIST"
+ /usr/bin/plutil -insert NSCalendarsFullAccessUsageDescription -string "Dayline shows your Apple Calendar events and creates events when you ask." "$INFO_PLIST"
/usr/bin/plutil -insert NSRemindersFullAccessUsageDescription -string "Dayline shows and manages your Apple Reminders from the menu bar." "$INFO_PLIST"
/usr/bin/plutil -insert DaylineApplicationSupportFolder -string "Dayline" "$INFO_PLIST"
/usr/bin/plutil -insert DaylineOAuthKeychainService -string "build.local.Dayline.oauth" "$INFO_PLIST"
diff --git a/website/src/routes/privacy.tsx b/website/src/routes/privacy.tsx
index 7757f02..e389825 100644
--- a/website/src/routes/privacy.tsx
+++ b/website/src/routes/privacy.tsx
@@ -19,7 +19,7 @@ function PrivacyPolicy() {
@@ -32,13 +32,13 @@ function PrivacyPolicy() {
Google's selected-calendar status.
- For calendars you enable in Dayline, it reads timed event records in a
+ For calendars you enable in Dayline, it reads timed and all-day event records in a
limited window covering the remaining events today and tomorrow. It
processes event identifiers, titles, start and end times, cancellation
status, locations, Google Calendar links, and conferencing links needed
to display, deduplicate, open, and notify you about those events.
- All-day events are not included in Dayline's agenda. Dayline does not
- create, edit, or delete Google Calendar data.
+ All-day events appear only when you enable that menu setting. Dayline
+ does not create, edit, or delete Google Calendar data.
@@ -54,6 +54,13 @@ function PrivacyPolicy() {
Dayline sends that requested change directly to Linear or GitHub. It
does not make issue changes without your action.
+
+ When you connect Apple Calendar, Dayline asks macOS for full Calendar
+ access and uses EventKit directly on your Mac. It reads events from
+ calendars you enable and creates an event only after you complete the
+ event editor and choose Create. It does not edit or delete Apple
+ Calendar events.
+
When you connect Apple Reminders, Dayline asks macOS for full Reminders
access and uses EventKit directly on your Mac. It reads the names and
@@ -73,8 +80,8 @@ function PrivacyPolicy() {
OAuth access and refresh tokens are stored in the macOS Keychain.
Linked Google account labels, calendar identifiers and names, and your
enabled-calendar selections are stored in local app preferences.
- Google Calendar event data is held only in app memory for display and
- alerts; Dayline does not persist an event cache to disk. Linear and
+ Google and Apple Calendar event data is held only in app memory for
+ display and alerts; Dayline does not persist an event cache to disk. Linear and
GitHub account selections and enabled Apple Reminders lists are also
stored in local preferences. Reminder data is held in app memory and
remains stored by Apple Reminders. Notes
@@ -86,7 +93,8 @@ function PrivacyPolicy() {
Dayline talks directly from your Mac to Google, Linear, and GitHub over
- HTTPS and accesses Apple Reminders locally through macOS EventKit. It
+ HTTPS and accesses Apple Calendar and Apple Reminders locally through
+ macOS EventKit. It
does not copy Google user data or other connected-account data
to a Dayline-operated server. If
you submit feedback, the feedback is sent through Dayline's
From 36cd88c64acc0aa8d691994ba3102375b50915b1 Mon Sep 17 00:00:00 2001
From: Robin | Liquidium
Date: Mon, 10 Aug 2026 22:47:59 +0200
Subject: [PATCH 2/9] Reuse graphical date picker across creation forms
---
.../Views/AppleCalendarEventEditorView.swift | 42 +++++++++++--
.../Views/AppleReminderEditorView.swift | 18 ++++--
.../Dayline/Views/GraphicalDatePicker.swift | 63 +++++++++++++++++++
.../Dayline/Views/LinearIssueEditorView.swift | 33 +++-------
UITests/DaylineUITests/DaylineUITests.swift | 7 ++-
5 files changed, 125 insertions(+), 38 deletions(-)
diff --git a/Sources/Dayline/Views/AppleCalendarEventEditorView.swift b/Sources/Dayline/Views/AppleCalendarEventEditorView.swift
index d3596fb..3dac2d1 100644
--- a/Sources/Dayline/Views/AppleCalendarEventEditorView.swift
+++ b/Sources/Dayline/Views/AppleCalendarEventEditorView.swift
@@ -8,6 +8,8 @@ struct AppleCalendarEventEditorView: View {
@State private var draft = AppleCalendarEventCreateDraft()
@State private var errorMessage: String?
@State private var isCreating = false
+ @State private var isStartDatePickerPresented = false
+ @State private var isEndDatePickerPresented = false
var body: some View {
VStack(spacing: 0) {
@@ -37,19 +39,19 @@ struct AppleCalendarEventEditorView: View {
Toggle("All-day event", isOn: $draft.isAllDay)
.accessibilityIdentifier("calendarEventEditor.allDay")
- DatePicker(
+ eventDateRow(
"Starts",
selection: $draft.startDate,
- displayedComponents: draft.isAllDay ? [.date] : [.date, .hourAndMinute]
+ isPresented: $isStartDatePickerPresented,
+ identifier: "calendarEventEditor.start"
)
- .accessibilityIdentifier("calendarEventEditor.start")
- DatePicker(
+ eventDateRow(
"Ends",
selection: $draft.endDate,
- displayedComponents: draft.isAllDay ? [.date] : [.date, .hourAndMinute]
+ isPresented: $isEndDatePickerPresented,
+ identifier: "calendarEventEditor.end"
)
- .accessibilityIdentifier("calendarEventEditor.end")
if draft.isAllDay {
Text("The end date is inclusive.")
@@ -130,6 +132,32 @@ struct AppleCalendarEventEditorView: View {
&& validDateRange
}
+ /// Shared date field plus a compact time control for timed events.
+ private func eventDateRow(
+ _ title: String,
+ selection: Binding,
+ isPresented: Binding,
+ identifier: String
+ ) -> some View {
+ LabeledContent(title) {
+ HStack(spacing: 8) {
+ CalendarDatePickerField(
+ selection: selection,
+ isPresented: isPresented,
+ fieldIdentifier: "\(identifier).date",
+ calendarIdentifier: "\(identifier).calendar"
+ )
+
+ if !draft.isAllDay {
+ DatePicker("", selection: selection, displayedComponents: .hourAndMinute)
+ .labelsHidden()
+ .accessibilityLabel("\(title) time")
+ .accessibilityIdentifier("\(identifier).time")
+ }
+ }
+ }
+ }
+
private var validDateRange: Bool {
if draft.isAllDay {
return Calendar.current.startOfDay(for: draft.endDate)
@@ -164,6 +192,8 @@ struct AppleCalendarEventEditorView: View {
isAllDay: false
)
errorMessage = nil
+ isStartDatePickerPresented = false
+ isEndDatePickerPresented = false
}
private func repairEndDate(previousStart: Date, newStart: Date) {
diff --git a/Sources/Dayline/Views/AppleReminderEditorView.swift b/Sources/Dayline/Views/AppleReminderEditorView.swift
index f4aa753..bb47d50 100644
--- a/Sources/Dayline/Views/AppleReminderEditorView.swift
+++ b/Sources/Dayline/Views/AppleReminderEditorView.swift
@@ -8,6 +8,7 @@ struct AppleReminderEditorView: View {
@State private var draft = AppleReminderCreateDraft()
@State private var errorMessage: String?
@State private var isCreating = false
+ @State private var isDueDatePickerPresented = false
var body: some View {
VStack(spacing: 0) {
@@ -123,12 +124,14 @@ struct AppleReminderEditorView: View {
@ViewBuilder
private var dueDateControls: some View {
if draft.dueDate != nil {
- DatePicker(
- "Due date",
- selection: dueDateBinding,
- displayedComponents: .date
- )
- .accessibilityIdentifier("reminderEditor.dueDate")
+ LabeledContent("Due date") {
+ CalendarDatePickerField(
+ selection: dueDateBinding,
+ isPresented: $isDueDatePickerPresented,
+ fieldIdentifier: "reminderEditor.dueDate",
+ calendarIdentifier: "reminderEditor.dueDate.calendar"
+ )
+ }
Toggle("Include time", isOn: dueTimeEnabledBinding)
.accessibilityIdentifier("reminderEditor.dueTimeEnabled")
@@ -143,6 +146,7 @@ struct AppleReminderEditorView: View {
}
Button(role: .destructive) {
+ isDueDatePickerPresented = false
draft.dueDate = nil
draft.dueDateIncludesTime = false
} label: {
@@ -152,6 +156,7 @@ struct AppleReminderEditorView: View {
} else {
Button {
draft.dueDate = Calendar.current.startOfDay(for: Date())
+ isDueDatePickerPresented = true
} label: {
Label("Add due date", systemImage: "calendar")
}
@@ -231,6 +236,7 @@ struct AppleReminderEditorView: View {
dueDateIncludesTime: false
)
errorMessage = nil
+ isDueDatePickerPresented = false
}
private var validDefaultListID: String {
diff --git a/Sources/Dayline/Views/GraphicalDatePicker.swift b/Sources/Dayline/Views/GraphicalDatePicker.swift
index 5e5c97c..da916d4 100644
--- a/Sources/Dayline/Views/GraphicalDatePicker.swift
+++ b/Sources/Dayline/Views/GraphicalDatePicker.swift
@@ -47,3 +47,66 @@ struct GraphicalDatePicker: NSViewRepresentable {
}
}
}
+
+/// Shared date field used by editor forms throughout Dayline.
+///
+/// The button, graphical popover, and optional clear action intentionally live
+/// together so every creation flow keeps the same appearance and behavior.
+struct CalendarDatePickerField: View {
+ @Binding private var selection: Date
+ @Binding private var isPresented: Bool
+
+ private let fieldIdentifier: String
+ private let calendarIdentifier: String
+ private let removeIdentifier: String?
+ private let removeHelp: String
+ private let onRemove: (() -> Void)?
+
+ init(
+ selection: Binding,
+ isPresented: Binding,
+ fieldIdentifier: String,
+ calendarIdentifier: String,
+ removeIdentifier: String? = nil,
+ removeHelp: String = "Remove date",
+ onRemove: (() -> Void)? = nil
+ ) {
+ _selection = selection
+ _isPresented = isPresented
+ self.fieldIdentifier = fieldIdentifier
+ self.calendarIdentifier = calendarIdentifier
+ self.removeIdentifier = removeIdentifier
+ self.removeHelp = removeHelp
+ self.onRemove = onRemove
+ }
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Button {
+ isPresented.toggle()
+ } label: {
+ Text(selection, format: .dateTime.year().month().day())
+ }
+ .accessibilityIdentifier(fieldIdentifier)
+ .popover(isPresented: $isPresented, arrowEdge: .bottom) {
+ GraphicalDatePicker(selection: $selection)
+ .padding(8)
+ .accessibilityIdentifier(calendarIdentifier)
+ }
+
+ if let onRemove {
+ Button {
+ isPresented = false
+ onRemove()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.borderless)
+ .help(removeHelp)
+ .accessibilityLabel(removeHelp)
+ .accessibilityIdentifier(removeIdentifier ?? "\(fieldIdentifier).remove")
+ }
+ }
+ }
+}
diff --git a/Sources/Dayline/Views/LinearIssueEditorView.swift b/Sources/Dayline/Views/LinearIssueEditorView.swift
index 6e74000..b2ae2ff 100644
--- a/Sources/Dayline/Views/LinearIssueEditorView.swift
+++ b/Sources/Dayline/Views/LinearIssueEditorView.swift
@@ -220,32 +220,15 @@ struct LinearIssueEditorView: View {
@ViewBuilder
private var dueDateControls: some View {
if draft.issue.dueDate != nil {
- HStack(spacing: 6) {
- Button {
- isDueDatePickerPresented.toggle()
- } label: {
- if let dueDate = draft.issue.dueDate {
- Text(dueDate, format: .dateTime.year().month().day())
- }
- }
- .accessibilityIdentifier("linearEditor.dueDate")
- .popover(isPresented: $isDueDatePickerPresented, arrowEdge: .bottom) {
- GraphicalDatePicker(selection: dueDateBinding)
- .padding(8)
- .accessibilityIdentifier("linearEditor.dueDate.calendar")
- }
-
- Button {
- isDueDatePickerPresented = false
+ CalendarDatePickerField(
+ selection: dueDateBinding,
+ isPresented: $isDueDatePickerPresented,
+ fieldIdentifier: "linearEditor.dueDate",
+ calendarIdentifier: "linearEditor.dueDate.calendar",
+ removeIdentifier: "linearEditor.dueDate.remove",
+ removeHelp: "Remove due date"
+ ) {
draft.issue.dueDate = nil
- } label: {
- Image(systemName: "xmark.circle.fill")
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.borderless)
- .help("Remove due date")
- .accessibilityLabel("Remove due date")
- .accessibilityIdentifier("linearEditor.dueDate.remove")
}
} else {
Button {
diff --git a/UITests/DaylineUITests/DaylineUITests.swift b/UITests/DaylineUITests/DaylineUITests.swift
index 65a0c44..f6ec514 100644
--- a/UITests/DaylineUITests/DaylineUITests.swift
+++ b/UITests/DaylineUITests/DaylineUITests.swift
@@ -433,11 +433,14 @@ final class DaylineUITests: XCTestCase {
try openMenu()
element("calendar.new").click()
- let appleCalendarMenuItem = app.menuItems["New Apple Calendar Event"]
+ let appleCalendarMenuItem = app.menuItems["calendar.new.apple"]
XCTAssertTrue(appleCalendarMenuItem.waitForExistence(timeout: 3))
appleCalendarMenuItem.click()
let calendarEventTitle = element("calendarEventEditor.title")
XCTAssertTrue(calendarEventTitle.waitForExistence(timeout: 5))
+ element("calendarEventEditor.start.date").click()
+ XCTAssertTrue(element("calendarEventEditor.start.calendar").waitForExistence(timeout: 3))
+ app.typeKey(.escape, modifierFlags: [])
calendarEventTitle.click()
calendarEventTitle.typeText("Automated Apple Calendar event")
assertEnabled("calendarEventEditor.create")
@@ -495,6 +498,8 @@ final class DaylineUITests: XCTestCase {
element("reminderEditor.priority").click()
app.menuItems["Low"].click()
element("reminderEditor.dueDate.add").click()
+ XCTAssertTrue(element("reminderEditor.dueDate.calendar").waitForExistence(timeout: 3))
+ app.typeKey(.escape, modifierFlags: [])
element("reminderEditor.dueTimeEnabled").click()
let reminderNotes = element("reminderEditor.notes")
reminderNotes.click()
From c7cea35655025b29737db2c4b79c20af722d532c Mon Sep 17 00:00:00 2001
From: Robin | Liquidium
Date: Mon, 10 Aug 2026 22:52:51 +0200
Subject: [PATCH 3/9] Add link-only meeting alert setting
---
.../Dayline/Models/CalendarEventItem.swift | 5 ++
Sources/Dayline/Stores/StatusStore.swift | 56 ++++++++++++++++---
Sources/Dayline/Views/MeetingAlertView.swift | 2 +-
.../Views/Settings/CalendarSettingsTab.swift | 12 ++++
.../Dayline/Views/Settings/SettingsTab.swift | 1 +
Tests/DaylineTests/MeetingAlertTests.swift | 50 +++++++++++++++++
UITests/DaylineUITests/DaylineUITests.swift | 1 +
7 files changed, 119 insertions(+), 8 deletions(-)
diff --git a/Sources/Dayline/Models/CalendarEventItem.swift b/Sources/Dayline/Models/CalendarEventItem.swift
index 70bf2c9..625bcb2 100644
--- a/Sources/Dayline/Models/CalendarEventItem.swift
+++ b/Sources/Dayline/Models/CalendarEventItem.swift
@@ -34,6 +34,11 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
/// Preferred URL for clicking the event, such as Google Meet or a URL in the location.
let openURL: URL?
+ /// Whether the event has a real join link rather than only its calendar page.
+ var hasMeetingLink: Bool {
+ openURL != nil && openURL != calendarURL
+ }
+
/// Calendar names contributing this event after cross-calendar deduplication.
let sourceCalendarNames: [String]
diff --git a/Sources/Dayline/Stores/StatusStore.swift b/Sources/Dayline/Stores/StatusStore.swift
index de2e0cd..8e8eec0 100644
--- a/Sources/Dayline/Stores/StatusStore.swift
+++ b/Sources/Dayline/Stores/StatusStore.swift
@@ -318,6 +318,17 @@ final class StatusStore: ObservableObject {
}
}
+ /// Whether full-screen alerts are limited to events with a real meeting link.
+ @Published var meetingAlertRequiresMeetingLink: Bool {
+ didSet {
+ UserDefaults.standard.set(
+ meetingAlertRequiresMeetingLink,
+ forKey: Self.meetingAlertRequiresMeetingLinkKey
+ )
+ updateMeetingAlert()
+ }
+ }
+
/// Minutes before a meeting starts when the full-screen alert may appear.
@Published var meetingAlertLeadMinutes: Int {
didSet {
@@ -577,6 +588,7 @@ final class StatusStore: ObservableObject {
private static let appleReminderSelectionsKey = "appleReminderSelections"
private static let appleRemindersEnabledKey = "appleRemindersEnabled"
private static let meetingAlertEnabledKey = "meetingAlertEnabled"
+ private static let meetingAlertRequiresMeetingLinkKey = "meetingAlertRequiresMeetingLink"
private static let meetingAlertLeadMinutesKey = "meetingAlertLeadMinutes"
private static let meetingAlertSnoozeMinutesKey = "meetingAlertSnoozeMinutes"
private static let issueSourceKey = "issueSource"
@@ -770,6 +782,9 @@ final class StatusStore: ObservableObject {
self.issueRowFields = (defaults.object(forKey: Self.issueRowFieldsKey) as? Int)
.map(IssueRowFields.init(rawValue:)) ?? .default
self.meetingAlertEnabled = defaults.object(forKey: Self.meetingAlertEnabledKey) as? Bool ?? true
+ self.meetingAlertRequiresMeetingLink = defaults.object(
+ forKey: Self.meetingAlertRequiresMeetingLinkKey
+ ) as? Bool ?? false
self.meetingAlertLeadMinutes = Self.storedInteger(forKey: Self.meetingAlertLeadMinutesKey, defaultValue: 0)
self.meetingAlertSnoozeMinutes = Self.clampedMeetingAlertSnoozeMinutes(Self.storedInteger(
forKey: Self.meetingAlertSnoozeMinutesKey,
@@ -2046,6 +2061,11 @@ final class StatusStore: ObservableObject {
meetingAlertEnabled = enabled
}
+ /// Persists whether full-screen alerts require a real meeting link.
+ func setMeetingAlertRequiresMeetingLink(_ requiresMeetingLink: Bool) {
+ meetingAlertRequiresMeetingLink = requiresMeetingLink
+ }
+
/// Persists how early the full-screen meeting alert may appear.
func setMeetingAlertLead(minutes: Int) {
meetingAlertLeadMinutes = minutes
@@ -3646,15 +3666,16 @@ final class StatusStore: ObservableObject {
// cannot hide meetings still inside the alert lead window.
meetingAlertEvent = CalendarEventItem.mergedAgenda(googleSourceEvents + appleSourceEvents)
.filter { event in
- guard !event.isAllDay else { return false }
let eventID = event.deduplicationKey ?? event.id
let snoozedUntil = snoozedMeetingAlertUntilByEventID[eventID]
- if let snoozedUntil, now < snoozedUntil {
- return false
- }
- return now >= event.startDate.addingTimeInterval(-lead)
- && now < Self.meetingAlertEligibilityEnd(for: event, snoozedUntil: snoozedUntil)
- && !dismissedMeetingAlertEventIDs.contains(eventID)
+ return Self.isMeetingAlertEligible(
+ event,
+ at: now,
+ lead: lead,
+ requiresMeetingLink: meetingAlertRequiresMeetingLink,
+ snoozedUntil: snoozedUntil,
+ isDismissed: dismissedMeetingAlertEventIDs.contains(eventID)
+ )
}
.min { $0.startDate < $1.startDate }
}
@@ -3983,6 +4004,27 @@ final class StatusStore: ObservableObject {
min(max(minutes, 1), 120)
}
+ /// Applies the persisted alert filter and the session-specific timing state.
+ static func isMeetingAlertEligible(
+ _ event: CalendarEventItem,
+ at now: Date,
+ lead: TimeInterval,
+ requiresMeetingLink: Bool,
+ snoozedUntil: Date?,
+ isDismissed: Bool
+ ) -> Bool {
+ guard !event.isAllDay,
+ !isDismissed,
+ !requiresMeetingLink || event.hasMeetingLink else {
+ return false
+ }
+ if let snoozedUntil, now < snoozedUntil {
+ return false
+ }
+ return now >= event.startDate.addingTimeInterval(-lead)
+ && now < meetingAlertEligibilityEnd(for: event, snoozedUntil: snoozedUntil)
+ }
+
/// Keeps an explicitly snoozed meeting eligible to re-alert after the original start window.
static func meetingAlertEligibilityEnd(
for event: CalendarEventItem,
diff --git a/Sources/Dayline/Views/MeetingAlertView.swift b/Sources/Dayline/Views/MeetingAlertView.swift
index 27ece0d..df1506d 100644
--- a/Sources/Dayline/Views/MeetingAlertView.swift
+++ b/Sources/Dayline/Views/MeetingAlertView.swift
@@ -106,7 +106,7 @@ struct MeetingAlertView: View {
/// "Join Meeting" only when the open URL is a real meeting link; when it
/// just falls back to the calendar page the honest label is "Open Event".
private var joinButtonLabel: String {
- if event.openURL != nil, event.openURL != event.calendarURL {
+ if event.hasMeetingLink {
return "Join Meeting"
}
return "Open Event"
diff --git a/Sources/Dayline/Views/Settings/CalendarSettingsTab.swift b/Sources/Dayline/Views/Settings/CalendarSettingsTab.swift
index f30cd61..07f0e72 100644
--- a/Sources/Dayline/Views/Settings/CalendarSettingsTab.swift
+++ b/Sources/Dayline/Views/Settings/CalendarSettingsTab.swift
@@ -47,6 +47,10 @@ struct CalendarSettingsTab: View {
Toggle("Full-screen meeting alerts", isOn: meetingAlertEnabledBinding)
.accessibilityIdentifier("settings.meetingAlertEnabled")
+ Toggle("Only alert for meetings with links", isOn: meetingAlertRequiresMeetingLinkBinding)
+ .disabled(!store.meetingAlertEnabled)
+ .accessibilityIdentifier("settings.meetingAlertRequiresMeetingLink")
+
Picker("Show alert", selection: meetingAlertLeadBinding) {
ForEach(meetingAlertLeadPickerOptions, id: \.self) { minutes in
Text(minutes == 0 ? "When the meeting starts" : "\(minutes) min before").tag(minutes)
@@ -128,6 +132,14 @@ struct CalendarSettingsTab: View {
)
}
+ /// Binding that limits full-screen alerts to events with real join links.
+ private var meetingAlertRequiresMeetingLinkBinding: Binding {
+ Binding(
+ get: { store.meetingAlertRequiresMeetingLink },
+ set: { store.setMeetingAlertRequiresMeetingLink($0) }
+ )
+ }
+
/// Binding that persists how early the meeting alert may appear.
private var meetingAlertLeadBinding: Binding {
Binding(
diff --git a/Sources/Dayline/Views/Settings/SettingsTab.swift b/Sources/Dayline/Views/Settings/SettingsTab.swift
index c162217..7d161ff 100644
--- a/Sources/Dayline/Views/Settings/SettingsTab.swift
+++ b/Sources/Dayline/Views/Settings/SettingsTab.swift
@@ -75,6 +75,7 @@ enum SettingsSearchCatalog {
SettingsSearchItem(id: "showsCalendarSourceNames", title: "Show calendar names", section: "Menu", tab: .calendar, keywords: ["source names", "account names"]),
SettingsSearchItem(id: "meetingAlertEnabled", title: "Full-screen meeting alerts", section: "Meeting Alerts", tab: .calendar, keywords: ["alert", "reminder", "notification"]),
+ SettingsSearchItem(id: "meetingAlertRequiresMeetingLink", title: "Only alert for meetings with links", section: "Meeting Alerts", tab: .calendar, keywords: ["meeting link", "join", "video call", "conference"]),
SettingsSearchItem(id: "meetingAlertLead", title: "Show alert", section: "Meeting Alerts", tab: .calendar, keywords: ["alert lead", "minutes before", "reminder"]),
SettingsSearchItem(id: "meetingAlertSnooze", title: "Default snooze", section: "Meeting Alerts", tab: .calendar, keywords: ["snooze", "delay", "remind me later"]),
diff --git a/Tests/DaylineTests/MeetingAlertTests.swift b/Tests/DaylineTests/MeetingAlertTests.swift
index cc2918a..df594cc 100644
--- a/Tests/DaylineTests/MeetingAlertTests.swift
+++ b/Tests/DaylineTests/MeetingAlertTests.swift
@@ -37,4 +37,54 @@ struct MeetingAlertTests {
snoozedUntil: start.addingTimeInterval(25 * 60)
) == event.endDate)
}
+
+ @Test @MainActor func linkOnlyAlertsRejectCalendarPagesAndKeepJoinLinks() {
+ let now = Date(timeIntervalSince1970: 10_000)
+ let calendarURL = URL(string: "https://calendar.google.com/event")!
+ let eventWithoutMeetingLink = CalendarEventItem(
+ id: "calendar-only",
+ title: "Calendar only",
+ startDate: now,
+ endDate: now.addingTimeInterval(30 * 60),
+ location: nil,
+ calendarURL: calendarURL,
+ openURL: calendarURL
+ )
+ let eventWithMeetingLink = CalendarEventItem(
+ id: "linked",
+ title: "Linked meeting",
+ startDate: now,
+ endDate: now.addingTimeInterval(30 * 60),
+ location: nil,
+ calendarURL: calendarURL,
+ openURL: URL(string: "https://meet.google.com/dayline-test")
+ )
+
+ #expect(!eventWithoutMeetingLink.hasMeetingLink)
+ #expect(eventWithMeetingLink.hasMeetingLink)
+ #expect(!StatusStore.isMeetingAlertEligible(
+ eventWithoutMeetingLink,
+ at: now,
+ lead: 0,
+ requiresMeetingLink: true,
+ snoozedUntil: nil,
+ isDismissed: false
+ ))
+ #expect(StatusStore.isMeetingAlertEligible(
+ eventWithMeetingLink,
+ at: now,
+ lead: 0,
+ requiresMeetingLink: true,
+ snoozedUntil: nil,
+ isDismissed: false
+ ))
+ #expect(StatusStore.isMeetingAlertEligible(
+ eventWithoutMeetingLink,
+ at: now,
+ lead: 0,
+ requiresMeetingLink: false,
+ snoozedUntil: nil,
+ isDismissed: false
+ ))
+ }
}
diff --git a/UITests/DaylineUITests/DaylineUITests.swift b/UITests/DaylineUITests/DaylineUITests.swift
index f6ec514..0ce2abd 100644
--- a/UITests/DaylineUITests/DaylineUITests.swift
+++ b/UITests/DaylineUITests/DaylineUITests.swift
@@ -687,6 +687,7 @@ final class DaylineUITests: XCTestCase {
XCTAssertTrue(calendarTab.waitForExistence(timeout: 5))
calendarTab.click()
assertExists("settings.meetingAlertSnooze")
+ assertExists("settings.meetingAlertRequiresMeetingLink")
let allDayToggle = element("settings.showsAllDayEvents")
assertExists("settings.showsAllDayEvents")
allDayToggle.click()
From 4a0c1868051e6159605cb94513942ed3cdf54b14 Mon Sep 17 00:00:00 2001
From: Robin | Liquidium
Date: Tue, 11 Aug 2026 00:07:26 +0200
Subject: [PATCH 4/9] Speed up native UI tests
---
Sources/Dayline/Views/NoteEditorView.swift | 12 +-
UITests/DaylineUITests/DaylineUITests.swift | 162 +++++++++++++-------
2 files changed, 117 insertions(+), 57 deletions(-)
diff --git a/Sources/Dayline/Views/NoteEditorView.swift b/Sources/Dayline/Views/NoteEditorView.swift
index 0c532cc..0bf9113 100644
--- a/Sources/Dayline/Views/NoteEditorView.swift
+++ b/Sources/Dayline/Views/NoteEditorView.swift
@@ -210,7 +210,17 @@ private final class NoteEditorDraft: ObservableObject {
let formatting = NoteFormattingBridge()
/// Markdown engine defaults plus Dayline's formatting command bridge.
- lazy var markdownConfiguration = formatting.configuration
+ lazy var markdownConfiguration: MarkdownEditorConfiguration = {
+ var configuration = formatting.configuration
+ if ProcessInfo.processInfo.arguments.contains("--ui-testing") {
+ configuration.spellChecking = SpellCheckingPolicy(
+ continuousSpellChecking: false,
+ grammarChecking: false,
+ automaticSpellingCorrection: false
+ )
+ }
+ return configuration
+ }()
/// Editable note body.
@Published var text = ""
diff --git a/UITests/DaylineUITests/DaylineUITests.swift b/UITests/DaylineUITests/DaylineUITests.swift
index 0ce2abd..c09ff1d 100644
--- a/UITests/DaylineUITests/DaylineUITests.swift
+++ b/UITests/DaylineUITests/DaylineUITests.swift
@@ -30,7 +30,13 @@ final class DaylineUITests: XCTestCase {
}
app = XCUIApplication(url: appURL)
- app.launchArguments = ["--mock", "--ui-testing", "-AppleShowScrollBars", "Always"]
+ app.launchArguments = [
+ "--mock",
+ "--ui-testing",
+ "-AppleShowScrollBars", "Always",
+ "-NSAutomaticTextCompletionEnabled", "NO",
+ "-NSAutomaticInlinePredictionEnabled", "NO",
+ ]
if let testRunID = ProcessInfo.processInfo.environment["DAYLINE_UI_TEST_RUN_ID"] {
app.launchArguments += ["--ui-test-run-id", testRunID]
}
@@ -45,7 +51,9 @@ final class DaylineUITests: XCTestCase {
},
object: app
)
- XCTAssertEqual(XCTWaiter.wait(for: [running], timeout: 10), .completed)
+ if app.state == .notRunning {
+ XCTAssertEqual(XCTWaiter.wait(for: [running], timeout: 10), .completed)
+ }
}
override func tearDownWithError() throws {
@@ -88,7 +96,7 @@ final class DaylineUITests: XCTestCase {
)
element("calendar.tomorrow.toggle").click()
- XCTAssertFalse(element("calendar.event.mock-planning").waitForExistence(timeout: 1))
+ waitForRemoval(element("calendar.event.mock-planning"))
attachCheckpoint(
"tomorrow-collapsed",
identifiers: ["calendar.tomorrow.toggle", "calendar.event.mock-planning"]
@@ -102,7 +110,7 @@ final class DaylineUITests: XCTestCase {
XCTContext.runActivity(named: "Switch from Linear to GitHub and back") { _ in
element("issues.source.github").click()
assertExists("github.issue.mock-gh-1")
- XCTAssertFalse(element("linear.issue.DAY-104").waitForExistence(timeout: 1))
+ XCTAssertFalse(element("linear.issue.DAY-104").exists)
attachCheckpoint(
"github-issues",
identifiers: ["issues.source.github", "github.issue.mock-gh-1", "linear.issue.DAY-104"]
@@ -115,8 +123,8 @@ final class DaylineUITests: XCTestCase {
element("issues.source.reminders").click()
assertExists("reminders.issue.mock-reminder-1")
- XCTAssertFalse(element("linear.issue.DAY-104").waitForExistence(timeout: 1))
- XCTAssertFalse(element("github.issue.mock-gh-1").waitForExistence(timeout: 1))
+ XCTAssertFalse(element("linear.issue.DAY-104").exists)
+ XCTAssertFalse(element("github.issue.mock-gh-1").exists)
element("issues.source.linear").click()
assertExists("linear.issue.DAY-104")
@@ -132,7 +140,7 @@ final class DaylineUITests: XCTestCase {
)
element("linear.showLess").click()
- XCTAssertFalse(element("linear.issue.DAY-121").waitForExistence(timeout: 1))
+ waitForRemoval(element("linear.issue.DAY-121"))
}
XCTContext.runActivity(named: "Change Linear priority") { _ in
@@ -238,14 +246,14 @@ final class DaylineUITests: XCTestCase {
reminder.hover()
app.typeKey("l", modifierFlags: [])
- XCTAssertFalse(element("issue.label.mock-label-bug").waitForExistence(timeout: 1))
+ assertDoesNotAppear("issue.label.mock-label-bug")
app.typeKey("a", modifierFlags: [])
- XCTAssertFalse(element("issue.assignee.mock-user").waitForExistence(timeout: 1))
+ assertDoesNotAppear("issue.assignee.mock-user")
let recurring = element("reminders.issue.mock-reminder-2")
recurring.hover()
app.typeKey("d", modifierFlags: [])
- XCTAssertFalse(element("reminders.dueDate.calendar.mock-reminder-2").waitForExistence(timeout: 1))
+ assertDoesNotAppear("reminders.dueDate.calendar.mock-reminder-2")
if element("reminders.showMore").exists {
element("reminders.showMore").click()
@@ -254,11 +262,11 @@ final class DaylineUITests: XCTestCase {
scrollIntoView(readOnly)
readOnly.hover()
app.typeKey("s", modifierFlags: [])
- XCTAssertFalse(element("reminders.status.completed").waitForExistence(timeout: 1))
+ assertDoesNotAppear("reminders.status.completed")
app.typeKey("p", modifierFlags: [])
- XCTAssertFalse(element("reminders.priority.9").waitForExistence(timeout: 1))
+ assertDoesNotAppear("reminders.priority.9")
app.typeKey("d", modifierFlags: [])
- XCTAssertFalse(element("reminders.dueDate.calendar.mock-reminder-6").waitForExistence(timeout: 1))
+ assertDoesNotAppear("reminders.dueDate.calendar.mock-reminder-6")
scrollIntoView(reminder)
openReminderHoverAction(on: reminder, expected: "reminders.status.completed") {
@@ -297,7 +305,7 @@ final class DaylineUITests: XCTestCase {
XCTAssertFalse(element("linear.cancel.DAY-112").exists)
revealDestructiveAction(on: issue)
let cancelIssueButton = element("linear.cancel.DAY-112")
- XCTAssertTrue(cancelIssueButton.waitForExistence(timeout: 5))
+ XCTAssertTrue(cancelIssueButton.waitForExistenceIfNeeded(timeout: 5))
assertNoVisibleHorizontalScrollBars()
cancelIssueButton.click()
let linearConfirmationButtons = app.buttons.matching(NSPredicate(
@@ -315,7 +323,7 @@ final class DaylineUITests: XCTestCase {
XCTAssertFalse(element("reminders.delete.mock-reminder-1").exists)
revealDestructiveAction(on: reminder)
let deleteButton = element("reminders.delete.mock-reminder-1")
- XCTAssertTrue(deleteButton.waitForExistence(timeout: 5))
+ XCTAssertTrue(deleteButton.waitForExistenceIfNeeded(timeout: 5))
assertNoVisibleHorizontalScrollBars()
deleteButton.click()
let confirmationButtons = app.buttons.matching(NSPredicate(
@@ -339,7 +347,7 @@ final class DaylineUITests: XCTestCase {
assertExists("linear.cancelContext.DAY-112")
element("linear.cancelContext.DAY-112").click()
let cancelButton = app.buttons["Cancel"].firstMatch
- XCTAssertTrue(cancelButton.waitForExistence(timeout: 5))
+ XCTAssertTrue(cancelButton.waitForExistenceIfNeeded(timeout: 5))
cancelButton.click()
try? openMenu()
assertExists("linear.issue.DAY-112")
@@ -348,7 +356,7 @@ final class DaylineUITests: XCTestCase {
assertExists("linear.cancelContext.DAY-112")
element("linear.cancelContext.DAY-112").click()
let confirmButton = app.buttons["Cancel Linear issue"].firstMatch
- XCTAssertTrue(confirmButton.waitForExistence(timeout: 3))
+ XCTAssertTrue(confirmButton.waitForExistenceIfNeeded(timeout: 3))
confirmButton.click()
try? openMenu()
waitForRemoval(issue)
@@ -361,7 +369,7 @@ final class DaylineUITests: XCTestCase {
assertExists("notes.deleteContext.mock-note-2")
element("notes.deleteContext.mock-note-2").click()
let cancelButton = app.buttons["Cancel"].firstMatch
- XCTAssertTrue(cancelButton.waitForExistence(timeout: 3))
+ XCTAssertTrue(cancelButton.waitForExistenceIfNeeded(timeout: 3))
cancelButton.click()
try? openMenu()
scrollIntoView(note)
@@ -425,7 +433,7 @@ final class DaylineUITests: XCTestCase {
scrollIntoView(element("notes.note.mock-note-1"))
XCTAssertTrue(
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Updated note title")).firstMatch
- .waitForExistence(timeout: 5)
+ .waitForExistenceIfNeeded(timeout: 5)
)
}
@@ -434,12 +442,12 @@ final class DaylineUITests: XCTestCase {
element("calendar.new").click()
let appleCalendarMenuItem = app.menuItems["calendar.new.apple"]
- XCTAssertTrue(appleCalendarMenuItem.waitForExistence(timeout: 3))
+ XCTAssertTrue(appleCalendarMenuItem.waitForExistenceIfNeeded(timeout: 3))
appleCalendarMenuItem.click()
let calendarEventTitle = element("calendarEventEditor.title")
- XCTAssertTrue(calendarEventTitle.waitForExistence(timeout: 5))
+ XCTAssertTrue(calendarEventTitle.waitForExistenceIfNeeded(timeout: 5))
element("calendarEventEditor.start.date").click()
- XCTAssertTrue(element("calendarEventEditor.start.calendar").waitForExistence(timeout: 3))
+ XCTAssertTrue(element("calendarEventEditor.start.calendar").waitForExistenceIfNeeded(timeout: 3))
app.typeKey(.escape, modifierFlags: [])
calendarEventTitle.click()
calendarEventTitle.typeText("Automated Apple Calendar event")
@@ -447,15 +455,17 @@ final class DaylineUITests: XCTestCase {
element("calendarEventEditor.create").click()
try openMenu()
- XCTAssertTrue(
- app.descendants(matching: .any)
- .matching(NSPredicate(format: "label BEGINSWITH %@", "Automated Apple Calendar event"))
- .firstMatch.waitForExistence(timeout: 5)
- )
+ let createdCalendarEvent = app.descendants(matching: .any)
+ .matching(NSPredicate(format: "label BEGINSWITH %@", "Automated Apple Calendar event"))
+ .firstMatch
+ if !createdCalendarEvent.exists, element("calendar.tomorrow.toggle").exists {
+ element("calendar.tomorrow.toggle").click()
+ }
+ XCTAssertTrue(createdCalendarEvent.waitForExistenceIfNeeded(timeout: 5))
element("linear.new").click()
let linearTitle = element("linearEditor.title")
- XCTAssertTrue(linearTitle.waitForExistence(timeout: 5))
+ XCTAssertTrue(linearTitle.waitForExistenceIfNeeded(timeout: 5))
linearTitle.click()
linearTitle.typeText("Automated Linear issue")
assertEnabled("linearEditor.create")
@@ -466,14 +476,14 @@ final class DaylineUITests: XCTestCase {
XCTAssertTrue(
app.descendants(matching: .any)
.matching(NSPredicate(format: "label BEGINSWITH %@", "Automated Linear issue"))
- .firstMatch.waitForExistence(timeout: 5)
+ .firstMatch.waitForExistenceIfNeeded(timeout: 5)
)
element("issues.source.github").click()
assertExists("github.issue.mock-gh-1")
element("github.new").click()
let githubTitle = element("githubEditor.title")
- XCTAssertTrue(githubTitle.waitForExistence(timeout: 5))
+ XCTAssertTrue(githubTitle.waitForExistenceIfNeeded(timeout: 5))
githubTitle.click()
githubTitle.typeText("Automated GitHub issue")
assertEnabled("githubEditor.create")
@@ -483,14 +493,14 @@ final class DaylineUITests: XCTestCase {
XCTAssertTrue(
app.descendants(matching: .any)
.matching(NSPredicate(format: "label BEGINSWITH %@", "Automated GitHub issue"))
- .firstMatch.waitForExistence(timeout: 5)
+ .firstMatch.waitForExistenceIfNeeded(timeout: 5)
)
element("issues.source.reminders").click()
assertExists("reminders.issue.mock-reminder-1")
element("reminders.new").click()
let reminderTitle = element("reminderEditor.title")
- XCTAssertTrue(reminderTitle.waitForExistence(timeout: 5))
+ XCTAssertTrue(reminderTitle.waitForExistenceIfNeeded(timeout: 5))
reminderTitle.click()
reminderTitle.typeText("Automated Apple Reminder")
element("reminderEditor.list").click()
@@ -498,7 +508,7 @@ final class DaylineUITests: XCTestCase {
element("reminderEditor.priority").click()
app.menuItems["Low"].click()
element("reminderEditor.dueDate.add").click()
- XCTAssertTrue(element("reminderEditor.dueDate.calendar").waitForExistence(timeout: 3))
+ XCTAssertTrue(element("reminderEditor.dueDate.calendar").waitForExistenceIfNeeded(timeout: 3))
app.typeKey(.escape, modifierFlags: [])
element("reminderEditor.dueTimeEnabled").click()
let reminderNotes = element("reminderEditor.notes")
@@ -511,7 +521,7 @@ final class DaylineUITests: XCTestCase {
let createdReminder = app.descendants(matching: .any)
.matching(NSPredicate(format: "label BEGINSWITH %@", "Automated Apple Reminder"))
.firstMatch
- XCTAssertTrue(createdReminder.waitForExistence(timeout: 5))
+ XCTAssertTrue(createdReminder.waitForExistenceIfNeeded(timeout: 5))
XCTAssertTrue(createdReminder.label.contains("Low"))
XCTAssertTrue(createdReminder.label.contains("Personal"))
XCTAssertTrue(createdReminder.label.contains("Due"))
@@ -519,7 +529,7 @@ final class DaylineUITests: XCTestCase {
func testGlobalReminderShortcutOpensCreator() throws {
app.typeKey("r", modifierFlags: [.control, .option, .command])
- XCTAssertTrue(element("reminderEditor.title").waitForExistence(timeout: 5))
+ XCTAssertTrue(element("reminderEditor.title").waitForExistenceIfNeeded(timeout: 5))
assertExists("reminderEditor.list")
assertExists("reminderEditor.priority")
assertExists("reminderEditor.cancel")
@@ -528,15 +538,15 @@ final class DaylineUITests: XCTestCase {
func testGlobalReminderShortcutExplainsMissingWritableList() throws {
try openMenu()
element("dayline.settings").click()
- XCTAssertTrue(app.windows["settings"].waitForExistence(timeout: 5))
+ XCTAssertTrue(app.windows["settings"].waitForExistenceIfNeeded(timeout: 5))
let accountsTab = app.staticTexts["Accounts"].firstMatch
- XCTAssertTrue(accountsTab.waitForExistence(timeout: 5))
+ XCTAssertTrue(accountsTab.waitForExistenceIfNeeded(timeout: 5))
accountsTab.click()
let inboxList = element("settings.account.reminders.list.mock-reminders-work")
let personalList = element("settings.account.reminders.list.mock-reminders-personal")
- XCTAssertTrue(inboxList.waitForExistence(timeout: 5))
- XCTAssertTrue(personalList.waitForExistence(timeout: 5))
+ XCTAssertTrue(inboxList.waitForExistenceIfNeeded(timeout: 5))
+ XCTAssertTrue(personalList.waitForExistenceIfNeeded(timeout: 5))
inboxList.click()
personalList.click()
app.typeKey("w", modifierFlags: .command)
@@ -572,7 +582,7 @@ final class DaylineUITests: XCTestCase {
"Markdown regression"
))
.firstMatch
- XCTAssertTrue(savedNote.waitForExistence(timeout: 5))
+ XCTAssertTrue(savedNote.waitForExistenceIfNeeded(timeout: 5))
attachCheckpoint(
"markdown-note-saved",
identifiers: ["notes.new"],
@@ -650,7 +660,7 @@ final class DaylineUITests: XCTestCase {
editor.typeKey(.leftArrow, modifierFlags: [.option, .shift])
editor.typeKey("k", modifierFlags: .command)
let linkURL = app.textFields["URL"].firstMatch
- XCTAssertTrue(linkURL.waitForExistence(timeout: 3))
+ XCTAssertTrue(linkURL.waitForExistenceIfNeeded(timeout: 3))
linkURL.click()
linkURL.typeText("https://example.com")
let insertLink = app.sheets.firstMatch.buttons["Insert"]
@@ -674,7 +684,7 @@ final class DaylineUITests: XCTestCase {
try XCTContext.runActivity(named: "Open Settings and verify General controls") { _ in
element("dayline.settings").click()
- XCTAssertTrue(app.windows["settings"].waitForExistence(timeout: 5))
+ XCTAssertTrue(app.windows["settings"].waitForExistenceIfNeeded(timeout: 5))
assertExists("settings.launchAtLogin")
assertExists("settings.refreshCadence")
attachCheckpoint(
@@ -684,7 +694,7 @@ final class DaylineUITests: XCTestCase {
)
let calendarTab = app.staticTexts["Calendar"].firstMatch
- XCTAssertTrue(calendarTab.waitForExistence(timeout: 5))
+ XCTAssertTrue(calendarTab.waitForExistenceIfNeeded(timeout: 5))
calendarTab.click()
assertExists("settings.meetingAlertSnooze")
assertExists("settings.meetingAlertRequiresMeetingLink")
@@ -704,18 +714,18 @@ final class DaylineUITests: XCTestCase {
app.launch()
let alert = element("meetingAlert.view")
- XCTAssertTrue(alert.waitForExistence(timeout: 5))
+ XCTAssertTrue(alert.waitForExistenceIfNeeded(timeout: 5))
let currentTime = app.descendants(matching: .any)
.matching(NSPredicate(format: "label == %@", "Current time"))
.firstMatch
- XCTAssertTrue(currentTime.waitForExistence(timeout: 5))
+ XCTAssertTrue(currentTime.waitForExistenceIfNeeded(timeout: 5))
XCTAssertFalse((currentTime.value as? String ?? "").isEmpty)
let snooze = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Snooze ")).firstMatch
- XCTAssertTrue(snooze.waitForExistence(timeout: 5))
- XCTAssertTrue(app.buttons["Dismiss"].waitForExistence(timeout: 5))
+ XCTAssertTrue(snooze.waitForExistenceIfNeeded(timeout: 5))
+ XCTAssertTrue(app.buttons["Dismiss"].waitForExistenceIfNeeded(timeout: 5))
snooze.click()
- XCTAssertFalse(alert.waitForExistence(timeout: 2))
+ waitForRemoval(alert)
}
private func openMenu() throws {
@@ -724,13 +734,13 @@ final class DaylineUITests: XCTestCase {
}
let statusItem = app.descendants(matching: .statusItem)["dayline.menuBarItem"].firstMatch
- guard statusItem.waitForExistence(timeout: 5) else {
+ guard statusItem.waitForExistenceIfNeeded(timeout: 5) else {
XCTFail("Dayline menu bar item was not exposed to XCUITest.\n\(app.debugDescription)")
return
}
statusItem.click()
- XCTAssertTrue(element("dayline.refresh").waitForExistence(timeout: 5))
+ XCTAssertTrue(element("dayline.refresh").waitForExistenceIfNeeded(timeout: 5))
}
private func element(_ identifier: String) -> XCUIElement {
@@ -744,7 +754,7 @@ final class DaylineUITests: XCTestCase {
) {
reminder.hover()
trigger()
- if !element(identifier).waitForExistence(timeout: 1) {
+ if !element(identifier).waitForExistenceIfNeeded(timeout: 1) {
element("issues.source.reminders").hover()
reminder.hover()
trigger()
@@ -753,9 +763,9 @@ final class DaylineUITests: XCTestCase {
}
private func noteEditor() -> XCUIElement {
- XCTAssertTrue(element("noteEditor.text").waitForExistence(timeout: 5))
+ XCTAssertTrue(element("noteEditor.text").waitForExistenceIfNeeded(timeout: 5))
let editor = app.textViews.firstMatch
- XCTAssertTrue(editor.waitForExistence(timeout: 5))
+ XCTAssertTrue(editor.waitForExistenceIfNeeded(timeout: 5))
return editor
}
@@ -786,7 +796,7 @@ final class DaylineUITests: XCTestCase {
private func scrollIntoView(_ target: XCUIElement) {
let menuScrollView = app.scrollViews.firstMatch
- XCTAssertTrue(menuScrollView.waitForExistence(timeout: 3))
+ XCTAssertTrue(menuScrollView.waitForExistenceIfNeeded(timeout: 3))
let bottomSafetyMargin: CGFloat = 80
func isSafelyVisible() -> Bool {
target.isHittable
@@ -809,6 +819,9 @@ final class DaylineUITests: XCTestCase {
file: StaticString = #filePath,
line: UInt = #line
) {
+ if element.value as? String == expected {
+ return
+ }
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "value == %@", expected),
object: element
@@ -829,7 +842,10 @@ final class DaylineUITests: XCTestCase {
file: StaticString = #filePath,
line: UInt = #line
) {
- guard element.waitForExistence(timeout: timeout) else {
+ if element.exists, !element.label.contains(text) {
+ return
+ }
+ guard element.waitForExistenceIfNeeded(timeout: timeout) else {
XCTFail(
"Expected \(element.identifier) to exist before checking that its label does not contain \(text)",
file: file,
@@ -858,6 +874,9 @@ final class DaylineUITests: XCTestCase {
file: StaticString = #filePath,
line: UInt = #line
) throws -> String {
+ if let value = element.value as? String, allowed.contains(value) {
+ return value
+ }
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "value IN %@", allowed),
object: element
@@ -881,6 +900,9 @@ final class DaylineUITests: XCTestCase {
file: StaticString = #filePath,
line: UInt = #line
) -> Bool {
+ if !element.exists {
+ return true
+ }
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "exists == false"),
object: element
@@ -897,6 +919,9 @@ final class DaylineUITests: XCTestCase {
line: UInt = #line
) {
let candidate = element(identifier)
+ if candidate.exists, candidate.isEnabled {
+ return
+ }
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "exists == true AND enabled == true"),
object: candidate
@@ -917,6 +942,9 @@ final class DaylineUITests: XCTestCase {
file: StaticString = #filePath,
line: UInt = #line
) {
+ if candidate.exists, candidate.isEnabled {
+ return
+ }
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "exists == true AND enabled == true"),
object: candidate
@@ -930,6 +958,21 @@ final class DaylineUITests: XCTestCase {
)
}
+ /// Gives actions that must stay unavailable a brief chance to incorrectly present UI.
+ private func assertDoesNotAppear(
+ _ identifier: String,
+ timeout: TimeInterval = 0.25,
+ file: StaticString = #filePath,
+ line: UInt = #line
+ ) {
+ XCTAssertFalse(
+ element(identifier).waitForExistence(timeout: timeout),
+ "Expected accessibility element \(identifier) to remain unavailable",
+ file: file,
+ line: line
+ )
+ }
+
/// Types list lines as a user would, accepting the editor's auto-continued marker.
private func typeComplexMarkdownNote(in editor: XCUIElement) {
let lines = complexMarkdownNote.components(separatedBy: "\n")
@@ -984,10 +1027,17 @@ final class DaylineUITests: XCTestCase {
line: UInt = #line
) {
XCTAssertTrue(
- element(identifier).waitForExistence(timeout: timeout),
+ element(identifier).waitForExistenceIfNeeded(timeout: timeout),
"Expected accessibility element \(identifier)",
file: file,
line: line
)
}
}
+
+private extension XCUIElement {
+ /// Avoids XCTest's roughly one-second initial polling delay when the element is already present.
+ func waitForExistenceIfNeeded(timeout: TimeInterval) -> Bool {
+ exists || waitForExistence(timeout: timeout)
+ }
+}
From 76d5f279cd5ac49ca5e35ecb3624f118adb9e9c0 Mon Sep 17 00:00:00 2001
From: Robin | Liquidium
Date: Tue, 11 Aug 2026 00:40:27 +0200
Subject: [PATCH 5/9] Address calendar review findings
---
.../Dayline/Models/CalendarEventItem.swift | 53 +++++++++++--
.../Services/AppleCalendarService.swift | 4 +-
.../Dayline/Services/CalendarService.swift | 24 +++---
Sources/Dayline/Stores/StatusStore.swift | 22 ++++--
Sources/Dayline/Support/MockData.swift | 1 +
Sources/Dayline/Views/PreviewPopovers.swift | 8 +-
.../DaylineTests/CalendarEventItemTests.swift | 78 +++++++++++++++++++
Tests/DaylineTests/MeetingAlertTests.swift | 46 +++++++++++
Tests/DaylineTests/MockDataTests.swift | 13 ++++
website/src/routes/privacy.tsx | 6 +-
10 files changed, 230 insertions(+), 25 deletions(-)
diff --git a/Sources/Dayline/Models/CalendarEventItem.swift b/Sources/Dayline/Models/CalendarEventItem.swift
index 625bcb2..68ea84e 100644
--- a/Sources/Dayline/Models/CalendarEventItem.swift
+++ b/Sources/Dayline/Models/CalendarEventItem.swift
@@ -31,12 +31,15 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
/// Optional browser URL for opening the calendar event itself.
let calendarURL: URL?
+ /// Conferencing URL sourced from structured provider data or a recognized meeting host.
+ let meetingURL: URL?
+
/// Preferred URL for clicking the event, such as Google Meet or a URL in the location.
let openURL: URL?
- /// Whether the event has a real join link rather than only its calendar page.
+ /// Whether the event has a real join link rather than an unrelated or calendar URL.
var hasMeetingLink: Bool {
- openURL != nil && openURL != calendarURL
+ meetingURL != nil
}
/// Calendar names contributing this event after cross-calendar deduplication.
@@ -56,6 +59,7 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
location: String?,
isAllDay: Bool = false,
calendarURL: URL?,
+ meetingURL: URL? = nil,
openURL: URL?,
sourceCalendarNames: [String] = [],
sourceIDs: [String] = [],
@@ -67,8 +71,9 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
self.endDate = endDate
self.location = location
self.isAllDay = isAllDay
- self.calendarURL = calendarURL
- self.openURL = openURL
+ self.calendarURL = Self.safeWebURL(calendarURL)
+ self.meetingURL = Self.safeWebURL(meetingURL)
+ self.openURL = self.meetingURL ?? Self.safeWebURL(openURL)
self.sourceCalendarNames = sourceCalendarNames
self.sourceIDs = sourceIDs
self.deduplicationKey = deduplicationKey
@@ -84,6 +89,39 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
"\(accountID.uuidString)|\(calendarID)"
}
+ /// Allows only browser-safe web URLs for event actions.
+ static func safeWebURL(_ url: URL?) -> URL? {
+ guard let url,
+ let scheme = url.scheme?.lowercased(),
+ ["http", "https"].contains(scheme) else {
+ return nil
+ }
+ return url
+ }
+
+ /// Recognizes common conferencing links when a provider did not identify one structurally.
+ static func recognizedMeetingURL(_ url: URL?) -> URL? {
+ guard let url = safeWebURL(url), let host = url.host?.lowercased() else {
+ return nil
+ }
+ let meetingHosts = [
+ "meet.google.com",
+ "zoom.us",
+ "teams.microsoft.com",
+ "teams.live.com",
+ "webex.com",
+ "meet.jit.si",
+ "whereby.com",
+ "facetime.apple.com",
+ "chime.aws",
+ "around.co"
+ ]
+ guard meetingHosts.contains(where: { host == $0 || host.hasSuffix(".\($0)") }) else {
+ return nil
+ }
+ return url
+ }
+
/// Compact source label for the agenda row.
var sourceLabel: String? {
guard let first = sourceCalendarNames.first else {
@@ -130,6 +168,7 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
}
}
}
+ let meetingURL = existing.meetingURL ?? event.meetingURL
merged[index] = CalendarEventItem(
id: existing.id,
title: existing.title,
@@ -138,7 +177,8 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
location: existing.location ?? event.location,
isAllDay: existing.isAllDay || event.isAllDay,
calendarURL: existing.calendarURL ?? event.calendarURL,
- openURL: existing.openURL ?? event.openURL,
+ meetingURL: meetingURL,
+ openURL: meetingURL ?? existing.openURL ?? event.openURL,
sourceCalendarNames: names,
sourceIDs: sourceIDs,
deduplicationKey: key
@@ -155,6 +195,7 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
/// Deduplicates, globally sorts, partitions, and caps an agenda after all sources load.
static func agendaSections(
from events: [CalendarEventItem],
+ todayStart: Date,
tomorrowStart: Date,
dayAfterTomorrow: Date,
todayLimit: Int,
@@ -173,7 +214,7 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
.prefix(tomorrowLimit)
.map { $0 },
allDayToday: merged
- .filter { $0.isAllDay && $0.startDate < tomorrowStart }
+ .filter { $0.isAllDay && $0.endDate > todayStart && $0.startDate < tomorrowStart }
.prefix(todayAllDayLimit)
.map { $0 },
allDayTomorrow: merged
diff --git a/Sources/Dayline/Services/AppleCalendarService.swift b/Sources/Dayline/Services/AppleCalendarService.swift
index acf7fa3..34f870a 100644
--- a/Sources/Dayline/Services/AppleCalendarService.swift
+++ b/Sources/Dayline/Services/AppleCalendarService.swift
@@ -96,6 +96,7 @@ final class AppleCalendarService: @unchecked Sendable {
let deduplicationKey = event.calendarItemExternalIdentifier.map {
"\($0)|\(occurrenceDate.timeIntervalSince1970)"
}
+ let eventURL = CalendarEventItem.safeWebURL(event.url)
return CalendarEventItem(
id: CalendarEventItem.compositeID(
accountID: Self.accountID,
@@ -108,7 +109,8 @@ final class AppleCalendarService: @unchecked Sendable {
location: event.location,
isAllDay: event.isAllDay,
calendarURL: nil,
- openURL: event.url,
+ meetingURL: CalendarEventItem.recognizedMeetingURL(eventURL),
+ openURL: eventURL,
sourceCalendarNames: [event.calendar.title],
sourceIDs: [CalendarEventItem.sourceID(accountID: Self.accountID, calendarID: event.calendar.calendarIdentifier)],
deduplicationKey: deduplicationKey
diff --git a/Sources/Dayline/Services/CalendarService.swift b/Sources/Dayline/Services/CalendarService.swift
index 0ab3465..2f81bf5 100644
--- a/Sources/Dayline/Services/CalendarService.swift
+++ b/Sources/Dayline/Services/CalendarService.swift
@@ -195,6 +195,10 @@ private struct GoogleCalendarEvent: Decodable {
let occurrenceDate = originalStartTime?.resolvedDate ?? startDate
let deduplicationKey = iCalUID.map { "\($0)|\(occurrenceDate.timeIntervalSince1970)" }
+ let structuredMeetingURL = CalendarEventItem.safeWebURL(conferenceURL)
+ let locationURL = location.flatMap(Self.firstURL(in:)).flatMap(CalendarEventItem.safeWebURL)
+ let meetingURL = structuredMeetingURL ?? CalendarEventItem.recognizedMeetingURL(locationURL)
+ let calendarURL = CalendarEventItem.safeWebURL(htmlLink.flatMap(URL.init(string:)))
return CalendarEventItem(
id: CalendarEventItem.compositeID(accountID: accountID, calendarID: calendar.id, eventID: id),
title: (summary?.isEmpty == false ? summary : "Untitled event") ?? "Untitled event",
@@ -202,27 +206,27 @@ private struct GoogleCalendarEvent: Decodable {
endDate: endDate,
location: location,
isAllDay: isAllDay,
- calendarURL: htmlLink.flatMap(URL.init(string:)),
- openURL: preferredOpenURL,
+ calendarURL: calendarURL,
+ meetingURL: meetingURL,
+ openURL: meetingURL ?? locationURL ?? calendarURL,
sourceCalendarNames: [calendar.name],
sourceIDs: [CalendarEventItem.sourceID(accountID: accountID, calendarID: calendar.id)],
deduplicationKey: deduplicationKey
)
}
- /// Best URL to open when the user clicks the event row.
- private var preferredOpenURL: URL? {
- conferenceURL ?? location.flatMap(Self.firstURL(in:)) ?? htmlLink.flatMap(URL.init(string:))
- }
-
/// Best structured conferencing URL from Google Calendar.
private var conferenceURL: URL? {
- if let hangoutURL = hangoutLink.flatMap(URL.init(string:)) {
+ if let hangoutURL = CalendarEventItem.safeWebURL(hangoutLink.flatMap(URL.init(string:))) {
return hangoutURL
}
let entries = conferenceData?.entryPoints ?? []
- return entries.first(where: { $0.entryPointType == "video" })?.url
- ?? entries.first(where: { $0.url != nil })?.url
+ if let videoURL = entries
+ .first(where: { $0.entryPointType == "video" })
+ .flatMap({ CalendarEventItem.safeWebURL($0.url) }) {
+ return videoURL
+ }
+ return entries.lazy.compactMap { CalendarEventItem.safeWebURL($0.url) }.first
}
/// Finds the first URL embedded in a location string.
diff --git a/Sources/Dayline/Stores/StatusStore.swift b/Sources/Dayline/Stores/StatusStore.swift
index 8e8eec0..a188733 100644
--- a/Sources/Dayline/Stores/StatusStore.swift
+++ b/Sources/Dayline/Stores/StatusStore.swift
@@ -2102,7 +2102,7 @@ final class StatusStore: ObservableObject {
/// Opens the current alert event's meeting link and dismisses the alert.
func joinMeetingAlert() {
guard let event = meetingAlertEvent else { return }
- if let url = event.openURL ?? event.calendarURL {
+ if let url = event.meetingURL ?? event.openURL ?? event.calendarURL {
NSWorkspace.shared.open(url)
}
dismissMeetingAlert()
@@ -2415,7 +2415,7 @@ final class StatusStore: ObservableObject {
if let target = validHoveredIssueTarget {
return .issue(target)
}
- if let hoveredEventID, (events + tomorrowEvents).contains(where: { $0.id == hoveredEventID }) {
+ if let hoveredEventID, visibleCalendarEvents.contains(where: { $0.id == hoveredEventID }) {
return .event(hoveredEventID)
}
return nil
@@ -2541,7 +2541,7 @@ final class StatusStore: ObservableObject {
@discardableResult
func copyHoveredEventLink() -> Bool {
guard let hoveredEventID,
- let event = (events + tomorrowEvents).first(where: { $0.id == hoveredEventID }),
+ let event = visibleCalendarEvents.first(where: { $0.id == hoveredEventID }),
let url = event.openURL ?? event.calendarURL else {
return false
}
@@ -2560,6 +2560,11 @@ final class StatusStore: ObservableObject {
return true
}
+ /// Calendar rows currently available to hover actions.
+ private var visibleCalendarEvents: [CalendarEventItem] {
+ events + tomorrowEvents + visibleAllDayEvents + visibleTomorrowAllDayEvents
+ }
+
/// Changes a Linear issue status and updates the visible list.
func changeIssueStatus(issueID: LinearIssueItem.ID, state: LinearWorkflowState) async {
let target = IssueActionTarget.linear(issueID)
@@ -3808,6 +3813,7 @@ final class StatusStore: ObservableObject {
endDate: now.addingTimeInterval(30 * 60),
location: nil,
calendarURL: URL(string: "https://calendar.google.com"),
+ meetingURL: URL(string: "https://meet.google.com/mock-dayline-demo"),
openURL: URL(string: "https://meet.google.com/mock-dayline-demo"),
sourceCalendarNames: ["Product Team"]
)
@@ -4045,10 +4051,11 @@ final class StatusStore: ObservableObject {
/// Loads all enabled calendars, preserving successful results when individual sources fail.
private func loadCalendarAgenda(now: Date = Date()) async -> CalendarAgendaLoadResult {
let calendar = Calendar.current
+ let todayStart = calendar.startOfDay(for: now)
let tomorrowStart = calendar.date(
byAdding: .day,
value: 1,
- to: calendar.startOfDay(for: now)
+ to: todayStart
) ?? now.addingTimeInterval(24 * 60 * 60)
let dayAfterTomorrow = calendar.date(byAdding: .day, value: 1, to: tomorrowStart)
?? tomorrowStart.addingTimeInterval(24 * 60 * 60)
@@ -4133,6 +4140,7 @@ final class StatusStore: ObservableObject {
sourceBatches: sourceBatches,
additionalWarnings: accountWarnings,
reauthenticationAccountIDs: reauthenticationAccountIDs,
+ todayStart: todayStart,
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow
)
@@ -4143,11 +4151,13 @@ final class StatusStore: ObservableObject {
sourceBatches: [CalendarAgendaSourceBatch],
additionalWarnings: [String] = [],
reauthenticationAccountIDs: Set = [],
+ todayStart: Date,
tomorrowStart: Date,
dayAfterTomorrow: Date
) -> CalendarAgendaLoadResult {
let sections = CalendarEventItem.agendaSections(
from: sourceBatches.flatMap(\.events),
+ todayStart: todayStart,
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow,
todayLimit: Self.todayEventLimit,
@@ -4175,15 +4185,17 @@ final class StatusStore: ObservableObject {
/// Rebuilds the visible agenda after a local source is disabled or disconnected.
private func rebuildAgendaFromCachedSources(now: Date = Date()) {
let calendar = Calendar.current
+ let todayStart = calendar.startOfDay(for: now)
let tomorrowStart = calendar.date(
byAdding: .day,
value: 1,
- to: calendar.startOfDay(for: now)
+ to: todayStart
) ?? now.addingTimeInterval(24 * 60 * 60)
let dayAfterTomorrow = calendar.date(byAdding: .day, value: 1, to: tomorrowStart)
?? tomorrowStart.addingTimeInterval(24 * 60 * 60)
let sections = CalendarEventItem.agendaSections(
from: googleSourceEvents + appleSourceEvents,
+ todayStart: todayStart,
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow,
todayLimit: Self.todayEventLimit,
diff --git a/Sources/Dayline/Support/MockData.swift b/Sources/Dayline/Support/MockData.swift
index 48a21b3..00d657f 100644
--- a/Sources/Dayline/Support/MockData.swift
+++ b/Sources/Dayline/Support/MockData.swift
@@ -55,6 +55,7 @@ struct MockData {
endDate: endDate,
location: location,
calendarURL: URL(string: "https://calendar.google.com"),
+ meetingURL: URL(string: "https://meet.google.com/mock-dayline-demo"),
openURL: URL(string: "https://meet.google.com/mock-dayline-demo"),
sourceCalendarNames: [source],
deduplicationKey: "mock-\(id)"
diff --git a/Sources/Dayline/Views/PreviewPopovers.swift b/Sources/Dayline/Views/PreviewPopovers.swift
index 2531a66..b5339fd 100644
--- a/Sources/Dayline/Views/PreviewPopovers.swift
+++ b/Sources/Dayline/Views/PreviewPopovers.swift
@@ -25,7 +25,7 @@ struct EventPreviewPopover: View {
private var timeRange: String {
if event.isAllDay {
let calendar = Calendar.current
- let inclusiveEnd = calendar.date(byAdding: .day, value: -1, to: event.endDate) ?? event.endDate
+ let inclusiveEnd = Self.inclusiveAllDayEnd(for: event, calendar: calendar)
let startDay = event.startDate.formatted(date: .abbreviated, time: .omitted)
let endDay = inclusiveEnd.formatted(date: .abbreviated, time: .omitted)
return calendar.isDate(event.startDate, inSameDayAs: inclusiveEnd)
@@ -37,6 +37,12 @@ struct EventPreviewPopover: View {
let day = event.startDate.formatted(date: .abbreviated, time: .omitted)
return "\(day), \(start) – \(end)"
}
+
+ /// Converts an exclusive all-day end into a display end without preceding the start.
+ static func inclusiveAllDayEnd(for event: CalendarEventItem, calendar: Calendar) -> Date {
+ let candidate = calendar.date(byAdding: .day, value: -1, to: event.endDate) ?? event.endDate
+ return max(event.startDate, candidate)
+ }
}
/// Detail preview for a hovered Linear issue.
diff --git a/Tests/DaylineTests/CalendarEventItemTests.swift b/Tests/DaylineTests/CalendarEventItemTests.swift
index eaa1a22..927709b 100644
--- a/Tests/DaylineTests/CalendarEventItemTests.swift
+++ b/Tests/DaylineTests/CalendarEventItemTests.swift
@@ -121,6 +121,7 @@ struct CalendarEventItemTests {
endDate: start.addingTimeInterval(30 * 60),
location: "Studio",
calendarURL: URL(string: "https://calendar.google.com"),
+ meetingURL: URL(string: "https://meet.google.com/example"),
openURL: URL(string: "https://meet.google.com/example"),
sourceCalendarNames: ["Work", "Shared"],
deduplicationKey: "meeting-uid|20000"
@@ -130,10 +131,43 @@ struct CalendarEventItemTests {
#expect(merged.count == 1)
#expect(merged[0].location == "Studio")
+ #expect(merged[0].meetingURL == URL(string: "https://meet.google.com/example"))
#expect(merged[0].openURL == URL(string: "https://meet.google.com/example"))
#expect(merged[0].sourceCalendarNames == ["Work", "Shared"])
}
+ @Test func mergedAgendaPrefersARealJoinLinkOverAnUnrelatedURL() throws {
+ let start = Date(timeIntervalSince1970: 20_000)
+ let genericURL = try #require(URL(string: "https://example.com/agenda"))
+ let meetingURL = try #require(URL(string: "https://meet.google.com/dayline-test"))
+ let genericCopy = CalendarEventItem(
+ id: "a-generic",
+ title: "Meeting",
+ startDate: start,
+ endDate: start.addingTimeInterval(30 * 60),
+ location: nil,
+ calendarURL: nil,
+ openURL: genericURL,
+ deduplicationKey: "meeting-uid|20000"
+ )
+ let meetingCopy = CalendarEventItem(
+ id: "b-meeting",
+ title: "Meeting",
+ startDate: start,
+ endDate: start.addingTimeInterval(30 * 60),
+ location: nil,
+ calendarURL: nil,
+ meetingURL: meetingURL,
+ openURL: meetingURL,
+ deduplicationKey: "meeting-uid|20000"
+ )
+
+ let merged = try #require(CalendarEventItem.mergedAgenda([genericCopy, meetingCopy]).first)
+
+ #expect(merged.meetingURL == meetingURL)
+ #expect(merged.openURL == meetingURL)
+ }
+
@Test func rebuildingAfterSourceRemovalUsesTheRemainingSourcePayload() throws {
let firstAccountID = UUID()
let secondAccountID = UUID()
@@ -174,6 +208,7 @@ struct CalendarEventItemTests {
let sections = CalendarEventItem.agendaSections(
from: [overnight],
+ todayStart: Date(timeIntervalSince1970: 0),
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow,
todayLimit: 6,
@@ -237,6 +272,7 @@ struct CalendarEventItemTests {
let sections = CalendarEventItem.agendaSections(
from: events,
+ todayStart: Date(timeIntervalSince1970: 0),
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow,
todayLimit: 2,
@@ -270,6 +306,7 @@ struct CalendarEventItemTests {
let sections = CalendarEventItem.agendaSections(
from: [allDay, timed, multiDay],
+ todayStart: Date(timeIntervalSince1970: 0),
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow,
todayLimit: 1,
@@ -283,6 +320,44 @@ struct CalendarEventItemTests {
#expect(sections.allDayTomorrow == [multiDay])
}
+ @Test func agendaSectionsDropsAllDayEventsThatEndedAtMidnight() {
+ let todayStart = Date(timeIntervalSince1970: 86_400)
+ let tomorrowStart = todayStart.addingTimeInterval(86_400)
+ let dayAfterTomorrow = tomorrowStart.addingTimeInterval(86_400)
+ let endedYesterday = event(
+ id: "ended-yesterday",
+ startDate: todayStart.addingTimeInterval(-86_400),
+ endDate: todayStart,
+ isAllDay: true
+ )
+ let activeToday = event(
+ id: "active-today",
+ startDate: todayStart,
+ endDate: tomorrowStart,
+ isAllDay: true
+ )
+
+ let sections = CalendarEventItem.agendaSections(
+ from: [endedYesterday, activeToday],
+ todayStart: todayStart,
+ tomorrowStart: tomorrowStart,
+ dayAfterTomorrow: dayAfterTomorrow,
+ todayLimit: 6,
+ tomorrowLimit: 8
+ )
+
+ #expect(sections.allDayToday == [activeToday])
+ }
+
+ @Test func malformedAllDayPreviewNeverEndsBeforeItStarts() {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: 0)!
+ let start = Date(timeIntervalSince1970: 86_400)
+ let malformed = event(id: "malformed", startDate: start, endDate: start, isAllDay: true)
+
+ #expect(EventPreviewPopover.inclusiveAllDayEnd(for: malformed, calendar: calendar) == start)
+ }
+
@Test func googleDateOnlyValuesResolveAsLocalGregorianDays() throws {
let value = try JSONDecoder().decode(
GoogleCalendarEventDate.self,
@@ -311,6 +386,7 @@ struct CalendarEventItemTests {
CalendarAgendaSourceBatch(provider: .google, events: [successfulEvent], warning: nil),
CalendarAgendaSourceBatch(provider: .google, events: [], warning: "Work (other@example.com): Timed out")
],
+ todayStart: Date(timeIntervalSince1970: 0),
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow
)
@@ -329,6 +405,7 @@ struct CalendarEventItemTests {
CalendarAgendaSourceBatch(provider: .google, events: [], warning: "Work: Timed out"),
CalendarAgendaSourceBatch(provider: .google, events: [], warning: "Personal: Offline")
],
+ todayStart: Date(timeIntervalSince1970: 0),
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow
)
@@ -352,6 +429,7 @@ struct CalendarEventItemTests {
CalendarAgendaSourceBatch(provider: .google, events: [], warning: "Work: Offline"),
CalendarAgendaSourceBatch(provider: .apple, events: [appleEvent], warning: nil)
],
+ todayStart: Date(timeIntervalSince1970: 0),
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow
)
diff --git a/Tests/DaylineTests/MeetingAlertTests.swift b/Tests/DaylineTests/MeetingAlertTests.swift
index df594cc..d07ef6b 100644
--- a/Tests/DaylineTests/MeetingAlertTests.swift
+++ b/Tests/DaylineTests/MeetingAlertTests.swift
@@ -57,6 +57,7 @@ struct MeetingAlertTests {
endDate: now.addingTimeInterval(30 * 60),
location: nil,
calendarURL: calendarURL,
+ meetingURL: URL(string: "https://meet.google.com/dayline-test"),
openURL: URL(string: "https://meet.google.com/dayline-test")
)
@@ -87,4 +88,49 @@ struct MeetingAlertTests {
isDismissed: false
))
}
+
+ @Test func meetingLinkProvenanceRejectsGenericAndUnsafeURLs() throws {
+ let now = Date(timeIntervalSince1970: 10_000)
+ let restaurantURL = try #require(URL(string: "https://example.com/restaurant"))
+ let arbitraryStructuredURL = try #require(URL(string: "https://calls.example.org/room/123"))
+ let unsafeURL = try #require(URL(string: "file:///tmp/dayline"))
+ let genericEvent = CalendarEventItem(
+ id: "restaurant",
+ title: "Lunch",
+ startDate: now,
+ endDate: now.addingTimeInterval(30 * 60),
+ location: "Restaurant",
+ calendarURL: nil,
+ openURL: restaurantURL
+ )
+ let structuredEvent = CalendarEventItem(
+ id: "structured",
+ title: "Provider call",
+ startDate: now,
+ endDate: now.addingTimeInterval(30 * 60),
+ location: nil,
+ calendarURL: nil,
+ meetingURL: arbitraryStructuredURL,
+ openURL: arbitraryStructuredURL
+ )
+ let unsafeEvent = CalendarEventItem(
+ id: "unsafe",
+ title: "Unsafe",
+ startDate: now,
+ endDate: now.addingTimeInterval(30 * 60),
+ location: nil,
+ calendarURL: unsafeURL,
+ meetingURL: unsafeURL,
+ openURL: unsafeURL
+ )
+
+ #expect(!genericEvent.hasMeetingLink)
+ #expect(CalendarEventItem.recognizedMeetingURL(restaurantURL) == nil)
+ #expect(CalendarEventItem.recognizedMeetingURL(URL(string: "https://us02web.zoom.us/j/123")) != nil)
+ #expect(structuredEvent.hasMeetingLink)
+ #expect(structuredEvent.meetingURL == arbitraryStructuredURL)
+ #expect(unsafeEvent.calendarURL == nil)
+ #expect(unsafeEvent.meetingURL == nil)
+ #expect(unsafeEvent.openURL == nil)
+ }
}
diff --git a/Tests/DaylineTests/MockDataTests.swift b/Tests/DaylineTests/MockDataTests.swift
index 4329971..7c09da4 100644
--- a/Tests/DaylineTests/MockDataTests.swift
+++ b/Tests/DaylineTests/MockDataTests.swift
@@ -28,4 +28,17 @@ struct MockDataTests {
#expect(mock.connectionStatuses.first { $0.provider == .linear }?.state == .disconnected)
#expect(mock.connectionStatuses.first { $0.provider == .github }?.state == .disconnected)
}
+
+ @Test @MainActor func allDayRowsSupportHoverPreviewAndCopyWhenVisible() throws {
+ let store = StatusStore(mockData: MockData.make())
+ store.setShowsAllDayEvents(true)
+ let event = try #require(store.visibleAllDayEvents.first)
+
+ store.setHoveredEvent(event.id)
+
+ #expect(store.presentPreviewForHovered())
+ #expect(store.previewTarget == .event(event.id))
+ #expect(store.copyHoveredEventLink())
+ #expect(store.copiedEventID == event.id)
+ }
}
diff --git a/website/src/routes/privacy.tsx b/website/src/routes/privacy.tsx
index e389825..d343a7b 100644
--- a/website/src/routes/privacy.tsx
+++ b/website/src/routes/privacy.tsx
@@ -80,8 +80,10 @@ function PrivacyPolicy() {
OAuth access and refresh tokens are stored in the macOS Keychain.
Linked Google account labels, calendar identifiers and names, and your
enabled-calendar selections are stored in local app preferences.
- Google and Apple Calendar event data is held only in app memory for
- display and alerts; Dayline does not persist an event cache to disk. Linear and
+ Google and Apple Calendar event data fetched for display and alerts is
+ held only in app memory; Dayline does not persist an event cache to disk.
+ Events you create are saved to the Apple Calendar you select and may be
+ synced or stored by that calendar's provider. Linear and
GitHub account selections and enabled Apple Reminders lists are also
stored in local preferences. Reminder data is held in app memory and
remains stored by Apple Reminders. Notes
From da8296bcf268101028b2b6bb22351d1f46b24b4c Mon Sep 17 00:00:00 2001
From: Robin | Liquidium
Date: Tue, 11 Aug 2026 01:09:27 +0200
Subject: [PATCH 6/9] Harden calendar meeting detection
---
.../Dayline/Models/CalendarEventItem.swift | 91 ++++++++++++++++---
.../Dayline/Services/CalendarService.swift | 54 +++++++----
Sources/Dayline/Stores/StatusStore.swift | 2 +-
.../DaylineTests/CalendarEventItemTests.swift | 44 ++++++++-
Tests/DaylineTests/MeetingAlertTests.swift | 76 +++++++++++++++-
5 files changed, 229 insertions(+), 38 deletions(-)
diff --git a/Sources/Dayline/Models/CalendarEventItem.swift b/Sources/Dayline/Models/CalendarEventItem.swift
index 68ea84e..3578552 100644
--- a/Sources/Dayline/Models/CalendarEventItem.swift
+++ b/Sources/Dayline/Models/CalendarEventItem.swift
@@ -104,24 +104,87 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
guard let url = safeWebURL(url), let host = url.host?.lowercased() else {
return nil
}
- let meetingHosts = [
- "meet.google.com",
- "zoom.us",
- "teams.microsoft.com",
- "teams.live.com",
- "webex.com",
- "meet.jit.si",
- "whereby.com",
- "facetime.apple.com",
- "chime.aws",
- "around.co"
- ]
- guard meetingHosts.contains(where: { host == $0 || host.hasSuffix(".\($0)") }) else {
+ let path = url.path.split(separator: "/").map { $0.lowercased() }
+
+ switch host {
+ case "meet.google.com":
+ guard (path.count == 1 && isGoogleMeetCode(path[0]))
+ || (path.count == 2 && path[0] == "lookup" && !path[1].isEmpty) else { return nil }
+ case let host where isHost(host, under: "zoom.us"):
+ guard path.count >= 2,
+ (["j", "s", "w"].contains(path[0]) && path[1].allSatisfy(\.isNumber)
+ || path[0] == "my" && !path[1].isEmpty
+ || path.count == 3 && path[0] == "wc" && path[1] == "join"
+ && path[2].allSatisfy(\.isNumber)
+ || path.count == 3 && path[0] == "wc" && path[1].allSatisfy(\.isNumber)
+ && path[2] == "join") else { return nil }
+ case "teams.microsoft.com", "teams.live.com":
+ let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let isLauncher = path == ["dl", "launcher", "launcher.html"]
+ && queryItems.contains(where: {
+ $0.name.caseInsensitiveCompare("type") == .orderedSame
+ && $0.value?.caseInsensitiveCompare("meetup-join") == .orderedSame
+ })
+ && queryItems.contains(where: {
+ $0.name.caseInsensitiveCompare("url") == .orderedSame
+ && $0.value?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
+ })
+ guard isLauncher
+ || (path.count >= 2 && path[0] == "meet" && !path[1].isEmpty)
+ || (path.count >= 3 && path[0] == "l" && path[1] == "meetup-join" && !path[2].isEmpty) else { return nil }
+ case "instant.webex.com":
+ guard path == ["gen", "v1", "talk"]
+ || (path.count == 2 && path[0] == "visit" && !path[1].isEmpty) else { return nil }
+ case let host where isHost(host, under: "webex.com"):
+ let hasGuestJoinPath = host != "join.webex.com"
+ && host.hasSuffix(".join.webex.com")
+ && path.count >= 2
+ && path[0] == "guest"
+ && !path[1].isEmpty
+ let hasMeetingPath = path.contains("meet") || path.contains("join")
+ let hasLegacyJoin = path.last == "j.php"
+ && URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
+ .contains(where: { $0.name.caseInsensitiveCompare("MTID") == .orderedSame && $0.value?.isEmpty == false }) == true
+ guard hasGuestJoinPath || hasMeetingPath || hasLegacyJoin else { return nil }
+ case "meet.jit.si":
+ guard let room = path.first, !room.isEmpty, !["about", "static"].contains(room) else { return nil }
+ case let host where isHost(host, under: "whereby.com"):
+ guard let room = path.first,
+ !room.isEmpty,
+ !["about", "information", "pricing"].contains(room) else { return nil }
+ case "facetime.apple.com":
+ guard path.first == "join" else { return nil }
+ case "chime.aws":
+ guard path.count == 1, isChimeMeetingPath(path[0]) else { return nil }
+ case let host where isHost(host, under: "around.co"):
+ guard path.count >= 2, path[0] == "r", !path[1].isEmpty else { return nil }
+ default:
return nil
}
return url
}
+ private static func isHost(_ host: String, under domain: String) -> Bool {
+ host == domain || host.hasSuffix(".\(domain)")
+ }
+
+ private static func isGoogleMeetCode(_ value: String) -> Bool {
+ let groups = value.split(separator: "-")
+ return groups.map(\.count) == [3, 4, 3]
+ && groups.joined().allSatisfy { $0.isASCII && $0.isLetter }
+ }
+
+ private static func isChimeMeetingPath(_ value: String) -> Bool {
+ if [10, 13].contains(value.count), value.allSatisfy(\.isNumber) {
+ return true
+ }
+ let reserved = ["about", "download", "pricing", "signup"]
+ return (12...35).contains(value.count)
+ && !reserved.contains(value)
+ && value.contains(where: { $0.isASCII && $0.isLetter })
+ && value.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") }
+ }
+
/// Compact source label for the agenda row.
var sourceLabel: String? {
guard let first = sourceCalendarNames.first else {
@@ -206,7 +269,7 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
let merged = mergedAgenda(events)
return CalendarAgendaSections(
today: merged
- .filter { !$0.isAllDay && $0.startDate < tomorrowStart }
+ .filter { !$0.isAllDay && $0.endDate > todayStart && $0.startDate < tomorrowStart }
.prefix(todayLimit)
.map { $0 },
tomorrow: merged
diff --git a/Sources/Dayline/Services/CalendarService.swift b/Sources/Dayline/Services/CalendarService.swift
index 2f81bf5..94b6957 100644
--- a/Sources/Dayline/Services/CalendarService.swift
+++ b/Sources/Dayline/Services/CalendarService.swift
@@ -81,30 +81,16 @@ struct CalendarService: Sendable {
to endDate: Date,
cutoff: Date
) async throws -> [CalendarEventItem] {
- let isoFormatter = ISO8601DateFormatter()
- isoFormatter.timeZone = TimeZone(secondsFromGMT: 0)
- isoFormatter.formatOptions = [.withInternetDateTime]
-
var events: [CalendarEventItem] = []
var pageToken: String?
repeat {
- var components = URLComponents()
- components.scheme = "https"
- components.host = "www.googleapis.com"
- components.percentEncodedPath = "/calendar/v3/calendars/\(Self.percentEncode(calendar.id))/events"
- components.queryItems = [
- URLQueryItem(name: "timeMin", value: isoFormatter.string(from: startDate)),
- URLQueryItem(name: "timeMax", value: isoFormatter.string(from: endDate)),
- URLQueryItem(name: "singleEvents", value: "true"),
- URLQueryItem(name: "orderBy", value: "startTime"),
- URLQueryItem(name: "maxResults", value: "2500")
- ]
- if let pageToken {
- components.queryItems?.append(URLQueryItem(name: "pageToken", value: pageToken))
- }
-
- guard let url = components.url else {
+ guard let url = Self.eventsRequestURL(
+ calendarID: calendar.id,
+ from: startDate,
+ to: endDate,
+ pageToken: pageToken
+ ) else {
throw OAuthError.httpError(-1, "Could not build the Google Calendar request URL.")
}
@@ -119,6 +105,34 @@ struct CalendarService: Sendable {
return events.sorted { $0.startDate < $1.startDate }
}
+ /// Builds the bounded Events.list URL used for every result page.
+ static func eventsRequestURL(
+ calendarID: String,
+ from startDate: Date,
+ to endDate: Date,
+ pageToken: String? = nil
+ ) -> URL? {
+ let isoFormatter = ISO8601DateFormatter()
+ isoFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+ isoFormatter.formatOptions = [.withInternetDateTime]
+
+ var components = URLComponents()
+ components.scheme = "https"
+ components.host = "www.googleapis.com"
+ components.percentEncodedPath = "/calendar/v3/calendars/\(Self.percentEncode(calendarID))/events"
+ components.queryItems = [
+ URLQueryItem(name: "timeMin", value: isoFormatter.string(from: startDate)),
+ URLQueryItem(name: "timeMax", value: isoFormatter.string(from: endDate)),
+ URLQueryItem(name: "singleEvents", value: "true"),
+ URLQueryItem(name: "orderBy", value: "startTime"),
+ URLQueryItem(name: "maxResults", value: "2500")
+ ]
+ if let pageToken {
+ components.queryItems?.append(URLQueryItem(name: "pageToken", value: pageToken))
+ }
+ return components.url
+ }
+
/// Loads the primary calendar identity, whose ID is the stable Google account email.
private func fetchPrimaryCalendarIdentity() async throws -> GoogleCalendarIdentity {
let url = URL(string: "https://www.googleapis.com/calendar/v3/calendars/primary")!
diff --git a/Sources/Dayline/Stores/StatusStore.swift b/Sources/Dayline/Stores/StatusStore.swift
index a188733..d14f7e6 100644
--- a/Sources/Dayline/Stores/StatusStore.swift
+++ b/Sources/Dayline/Stores/StatusStore.swift
@@ -4097,7 +4097,7 @@ final class StatusStore: ObservableObject {
let events = try await context.service.fetchEvents(
accountID: context.accountID,
calendar: context.calendar,
- from: now,
+ from: todayStart,
to: dayAfterTomorrow,
cutoff: now
)
diff --git a/Tests/DaylineTests/CalendarEventItemTests.swift b/Tests/DaylineTests/CalendarEventItemTests.swift
index 927709b..d8c7fdf 100644
--- a/Tests/DaylineTests/CalendarEventItemTests.swift
+++ b/Tests/DaylineTests/CalendarEventItemTests.swift
@@ -320,10 +320,20 @@ struct CalendarEventItemTests {
#expect(sections.allDayTomorrow == [multiDay])
}
- @Test func agendaSectionsDropsAllDayEventsThatEndedAtMidnight() {
+ @Test func agendaSectionsDropsEventsThatEndedAtMidnight() {
let todayStart = Date(timeIntervalSince1970: 86_400)
let tomorrowStart = todayStart.addingTimeInterval(86_400)
let dayAfterTomorrow = tomorrowStart.addingTimeInterval(86_400)
+ let endedTimedEvent = event(
+ id: "ended-timed-event",
+ startDate: todayStart.addingTimeInterval(-60 * 60),
+ endDate: todayStart
+ )
+ let activeTimedEvent = event(
+ id: "active-timed-event",
+ startDate: todayStart,
+ endDate: todayStart.addingTimeInterval(60 * 60)
+ )
let endedYesterday = event(
id: "ended-yesterday",
startDate: todayStart.addingTimeInterval(-86_400),
@@ -338,7 +348,7 @@ struct CalendarEventItemTests {
)
let sections = CalendarEventItem.agendaSections(
- from: [endedYesterday, activeToday],
+ from: [endedTimedEvent, activeTimedEvent, endedYesterday, activeToday],
todayStart: todayStart,
tomorrowStart: tomorrowStart,
dayAfterTomorrow: dayAfterTomorrow,
@@ -346,6 +356,7 @@ struct CalendarEventItemTests {
tomorrowLimit: 8
)
+ #expect(sections.today == [activeTimedEvent])
#expect(sections.allDayToday == [activeToday])
}
@@ -372,6 +383,35 @@ struct CalendarEventItemTests {
== DateComponents(year: 2026, month: 8, day: 10))
}
+ @Test func googleEventsRequestSerializesTheLocalDayStartBoundary() throws {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = try #require(TimeZone(identifier: "Europe/Berlin"))
+ let now = try #require(calendar.date(from: DateComponents(
+ year: 2026,
+ month: 8,
+ day: 10,
+ hour: 22,
+ minute: 30
+ )))
+ let todayStart = calendar.startOfDay(for: now)
+ let end = try #require(calendar.date(byAdding: .day, value: 2, to: todayStart))
+ let url = try #require(CalendarService.eventsRequestURL(
+ calendarID: "work@example.com",
+ from: todayStart,
+ to: end
+ ))
+ let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
+ let query = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
+ item.value.map { (item.name, $0) }
+ })
+ let formatter = ISO8601DateFormatter()
+ formatter.timeZone = TimeZone(secondsFromGMT: 0)
+ formatter.formatOptions = [.withInternetDateTime]
+
+ #expect(query["timeMin"] == formatter.string(from: todayStart))
+ #expect(query["timeMin"] != formatter.string(from: now))
+ }
+
@Test @MainActor func partialSourceFailureRetainsSuccessfulEvents() {
let tomorrowStart = Date(timeIntervalSince1970: 86_400)
let dayAfterTomorrow = tomorrowStart.addingTimeInterval(86_400)
diff --git a/Tests/DaylineTests/MeetingAlertTests.swift b/Tests/DaylineTests/MeetingAlertTests.swift
index d07ef6b..458fcdb 100644
--- a/Tests/DaylineTests/MeetingAlertTests.swift
+++ b/Tests/DaylineTests/MeetingAlertTests.swift
@@ -126,11 +126,85 @@ struct MeetingAlertTests {
#expect(!genericEvent.hasMeetingLink)
#expect(CalendarEventItem.recognizedMeetingURL(restaurantURL) == nil)
- #expect(CalendarEventItem.recognizedMeetingURL(URL(string: "https://us02web.zoom.us/j/123")) != nil)
#expect(structuredEvent.hasMeetingLink)
#expect(structuredEvent.meetingURL == arbitraryStructuredURL)
#expect(unsafeEvent.calendarURL == nil)
#expect(unsafeEvent.meetingURL == nil)
#expect(unsafeEvent.openURL == nil)
}
+
+ @Test func recognizedMeetingLinksRequireProviderJoinPaths() {
+ let validURLs = [
+ "https://meet.google.com/abc-defg-hij",
+ "https://meet.google.com/lookup/team-room",
+ "https://us02web.zoom.us/j/1234567890",
+ "https://zoom.us/s/1234567890",
+ "https://us02web.zoom.us/wc/join/1234567890",
+ "https://us02web.zoom.us/wc/1234567890/join",
+ "https://teams.microsoft.com/l/meetup-join/19%3ameeting_example%40thread.v2/0",
+ "https://teams.microsoft.com/meet/123456789?p=secret",
+ "https://teams.live.com/meet/123456789",
+ "https://teams.microsoft.com/dl/launcher/launcher.html?TYPE=MEETUP-JOIN&URL=https%3A%2F%2Fteams.microsoft.com%2Fl%2Fmeetup-join%2Fexample",
+ "https://company.webex.com/meet/alex",
+ "https://company.webex.com/company/j.php?MTID=example",
+ "https://dayline.join.webex.com/guest/vod",
+ "https://instant.webex.com/gen/v1/talk",
+ "https://instant.webex.com/visit/dayline-room",
+ "https://meet.jit.si/dayline-room",
+ "https://whereby.com/dayline-room",
+ "https://facetime.apple.com/join#v=1&p=example",
+ "https://chime.aws/0123456789",
+ "https://chime.aws/robin-dayline-room",
+ "https://around.co/r/dayline-room"
+ ]
+ let nonMeetingURLs = [
+ "https://meet.google.com/",
+ "https://meet.google.com/about",
+ "https://zoom.us/",
+ "https://zoom.us/pricing",
+ "https://zoom.us/wc/join/not-a-number",
+ "https://zoom.us/wc/not-a-number/join",
+ "https://zoom.us/wc/1234567890",
+ "https://zoom.us/wc/1234567890/join/extra",
+ "https://zoom.us/wc/join/1234567890/extra",
+ "https://teams.microsoft.com/",
+ "https://teams.microsoft.com/l/chat/123",
+ "https://teams.microsoft.com/dl/launcher/launcher.html",
+ "https://teams.microsoft.com/dl/launcher/launcher.html?type=meetup-join",
+ "https://teams.microsoft.com/dl/launcher/launcher.html?url=https%3A%2F%2Fteams.microsoft.com%2Fl%2Fmeetup-join%2Fexample",
+ "https://teams.microsoft.com/dl/launcher/launcher.html?type=chat&url=https%3A%2F%2Fteams.microsoft.com%2Fl%2Fchat%2Fexample",
+ "https://teams.microsoft.com/dl/launcher?type=meetup-join&url=example",
+ "https://company.webex.com/",
+ "https://company.webex.com/company/j.php",
+ "https://dayline.join.webex.com/",
+ "https://dayline.join.webex.com/pricing",
+ "https://company.webex.com/guest/vod",
+ "https://instant.webex.com/",
+ "https://instant.webex.com/pricing",
+ "https://instant.webex.com/gen/v1",
+ "https://instant.webex.com/gen/v1/talk/extra",
+ "https://instant.webex.com/visit",
+ "https://instant.webex.com/visit/dayline-room/extra",
+ "https://meet.jit.si/about",
+ "https://whereby.com/pricing",
+ "https://facetime.apple.com/",
+ "https://chime.aws/pricing",
+ "https://chime.aws/about",
+ "https://chime.aws/signup",
+ "https://chime.aws/download",
+ "https://chime.aws/short-name",
+ "https://chime.aws/dayline.meeting-room",
+ "https://chime.aws/dayline-room/extra",
+ "https://chime.aws/123456789012",
+ "https://chime.aws/abcdefghijklmnopqrstuvwxyz1234567890",
+ "https://around.co/pricing"
+ ]
+
+ #expect(validURLs.allSatisfy {
+ CalendarEventItem.recognizedMeetingURL(URL(string: $0)) != nil
+ })
+ #expect(nonMeetingURLs.allSatisfy {
+ CalendarEventItem.recognizedMeetingURL(URL(string: $0)) == nil
+ })
+ }
}
From 19d3fb60e071dc4ca2408f9a20a61bde1971e18a Mon Sep 17 00:00:00 2001
From: Robin | Liquidium
Date: Tue, 11 Aug 2026 01:25:26 +0200
Subject: [PATCH 7/9] Refine structured meeting links
---
.../Dayline/Models/CalendarEventItem.swift | 4 +-
.../Dayline/Services/CalendarService.swift | 11 +++--
.../DaylineTests/CalendarEventItemTests.swift | 49 +++++++++++++++++++
Tests/DaylineTests/MeetingAlertTests.swift | 3 ++
4 files changed, 62 insertions(+), 5 deletions(-)
diff --git a/Sources/Dayline/Models/CalendarEventItem.swift b/Sources/Dayline/Models/CalendarEventItem.swift
index 3578552..e1316a1 100644
--- a/Sources/Dayline/Models/CalendarEventItem.swift
+++ b/Sources/Dayline/Models/CalendarEventItem.swift
@@ -141,7 +141,9 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
&& path.count >= 2
&& path[0] == "guest"
&& !path[1].isEmpty
- let hasMeetingPath = path.contains("meet") || path.contains("join")
+ let hasMeetingPath = path.count >= 2
+ && ["meet", "join"].contains(path[0])
+ && !path[1].isEmpty
let hasLegacyJoin = path.last == "j.php"
&& URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
.contains(where: { $0.name.caseInsensitiveCompare("MTID") == .orderedSame && $0.value?.isEmpty == false }) == true
diff --git a/Sources/Dayline/Services/CalendarService.swift b/Sources/Dayline/Services/CalendarService.swift
index 94b6957..9f89452 100644
--- a/Sources/Dayline/Services/CalendarService.swift
+++ b/Sources/Dayline/Services/CalendarService.swift
@@ -184,7 +184,7 @@ private struct GoogleCalendarEventsResponse: Decodable {
}
/// Google Calendar event shape used by the app.
-private struct GoogleCalendarEvent: Decodable {
+struct GoogleCalendarEvent: Decodable {
let id: String
let iCalUID: String?
let status: String?
@@ -240,7 +240,10 @@ private struct GoogleCalendarEvent: Decodable {
.flatMap({ CalendarEventItem.safeWebURL($0.url) }) {
return videoURL
}
- return entries.lazy.compactMap { CalendarEventItem.safeWebURL($0.url) }.first
+ return entries.lazy
+ .filter { $0.entryPointType?.lowercased() != "more" }
+ .compactMap { CalendarEventItem.safeWebURL($0.url) }
+ .first
}
/// Finds the first URL embedded in a location string.
@@ -251,11 +254,11 @@ private struct GoogleCalendarEvent: Decodable {
}
}
-private struct GoogleCalendarConferenceData: Decodable {
+struct GoogleCalendarConferenceData: Decodable {
let entryPoints: [GoogleCalendarEntryPoint]?
}
-private struct GoogleCalendarEntryPoint: Decodable {
+struct GoogleCalendarEntryPoint: Decodable {
let entryPointType: String?
let uri: String?
diff --git a/Tests/DaylineTests/CalendarEventItemTests.swift b/Tests/DaylineTests/CalendarEventItemTests.swift
index d8c7fdf..912c05c 100644
--- a/Tests/DaylineTests/CalendarEventItemTests.swift
+++ b/Tests/DaylineTests/CalendarEventItemTests.swift
@@ -412,6 +412,32 @@ struct CalendarEventItemTests {
#expect(query["timeMin"] != formatter.string(from: now))
}
+ @Test func googleConferenceMoreEntryIsNotAJoinLink() throws {
+ let event = try decodeGoogleEvent(conferenceEntryType: "more")
+ let item = try #require(event.displayItem(
+ accountID: UUID(),
+ calendar: GoogleCalendarSource(id: "work", name: "Work", isPrimary: true, isEnabled: true),
+ now: .distantPast
+ ))
+
+ #expect(item.meetingURL == nil)
+ #expect(!item.hasMeetingLink)
+ #expect(item.openURL == URL(string: "https://calendar.google.com/event"))
+ }
+
+ @Test func googleStructuredVideoEntryRemainsAJoinLink() throws {
+ let event = try decodeGoogleEvent(conferenceEntryType: "video")
+ let item = try #require(event.displayItem(
+ accountID: UUID(),
+ calendar: GoogleCalendarSource(id: "work", name: "Work", isPrimary: true, isEnabled: true),
+ now: .distantPast
+ ))
+
+ #expect(item.meetingURL == URL(string: "https://calls.example.com/room/dayline"))
+ #expect(item.hasMeetingLink)
+ #expect(item.openURL == item.meetingURL)
+ }
+
@Test @MainActor func partialSourceFailureRetainsSuccessfulEvents() {
let tomorrowStart = Date(timeIntervalSince1970: 86_400)
let dayAfterTomorrow = tomorrowStart.addingTimeInterval(86_400)
@@ -503,4 +529,27 @@ struct CalendarEventItemTests {
deduplicationKey: deduplicationKey
)
}
+
+ private func decodeGoogleEvent(conferenceEntryType: String) throws -> GoogleCalendarEvent {
+ try JSONDecoder().decode(
+ GoogleCalendarEvent.self,
+ from: Data(#"""
+ {
+ "id": "conference-event",
+ "summary": "Conference event",
+ "start": { "dateTime": "2026-08-10T10:00:00Z" },
+ "end": { "dateTime": "2026-08-10T11:00:00Z" },
+ "conferenceData": {
+ "entryPoints": [
+ {
+ "entryPointType": "\#(conferenceEntryType)",
+ "uri": "https://calls.example.com/room/dayline"
+ }
+ ]
+ },
+ "htmlLink": "https://calendar.google.com/event"
+ }
+ """#.utf8)
+ )
+ }
}
diff --git a/Tests/DaylineTests/MeetingAlertTests.swift b/Tests/DaylineTests/MeetingAlertTests.swift
index 458fcdb..eae2b9a 100644
--- a/Tests/DaylineTests/MeetingAlertTests.swift
+++ b/Tests/DaylineTests/MeetingAlertTests.swift
@@ -146,6 +146,7 @@ struct MeetingAlertTests {
"https://teams.live.com/meet/123456789",
"https://teams.microsoft.com/dl/launcher/launcher.html?TYPE=MEETUP-JOIN&URL=https%3A%2F%2Fteams.microsoft.com%2Fl%2Fmeetup-join%2Fexample",
"https://company.webex.com/meet/alex",
+ "https://company.webex.com/join/123456789",
"https://company.webex.com/company/j.php?MTID=example",
"https://dayline.join.webex.com/guest/vod",
"https://instant.webex.com/gen/v1/talk",
@@ -176,6 +177,8 @@ struct MeetingAlertTests {
"https://teams.microsoft.com/dl/launcher?type=meetup-join&url=example",
"https://company.webex.com/",
"https://company.webex.com/company/j.php",
+ "https://company.webex.com/products/meet",
+ "https://company.webex.com/pricing/join-a-meeting",
"https://dayline.join.webex.com/",
"https://dayline.join.webex.com/pricing",
"https://company.webex.com/guest/vod",
From 8927e830cd54476827604146ad152746abaa0474 Mon Sep 17 00:00:00 2001
From: Robin | Liquidium
Date: Tue, 11 Aug 2026 01:44:06 +0200
Subject: [PATCH 8/9] Stabilize calendar editor UI test
---
UITests/DaylineUITests/DaylineUITests.swift | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/UITests/DaylineUITests/DaylineUITests.swift b/UITests/DaylineUITests/DaylineUITests.swift
index c09ff1d..21eb34f 100644
--- a/UITests/DaylineUITests/DaylineUITests.swift
+++ b/UITests/DaylineUITests/DaylineUITests.swift
@@ -444,9 +444,20 @@ final class DaylineUITests: XCTestCase {
let appleCalendarMenuItem = app.menuItems["calendar.new.apple"]
XCTAssertTrue(appleCalendarMenuItem.waitForExistenceIfNeeded(timeout: 3))
appleCalendarMenuItem.click()
+ app.activate()
let calendarEventTitle = element("calendarEventEditor.title")
XCTAssertTrue(calendarEventTitle.waitForExistenceIfNeeded(timeout: 5))
- element("calendarEventEditor.start.date").click()
+ let calendarStartDate = element("calendarEventEditor.start.date")
+ XCTAssertTrue(calendarStartDate.waitForExistenceIfNeeded(timeout: 5))
+ if calendarStartDate.isHittable {
+ calendarStartDate.click()
+ } else {
+ XCTAssertFalse(
+ calendarStartDate.frame.isEmpty,
+ "Expected the Apple Calendar start-date control to have a visible frame"
+ )
+ calendarStartDate.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
+ }
XCTAssertTrue(element("calendarEventEditor.start.calendar").waitForExistenceIfNeeded(timeout: 3))
app.typeKey(.escape, modifierFlags: [])
calendarEventTitle.click()
From 7a2398565bc390557599d9047c18f0a462bac46c Mon Sep 17 00:00:00 2001
From: Robin | Liquidium
Date: Tue, 11 Aug 2026 02:02:37 +0200
Subject: [PATCH 9/9] Close status menu before calendar UI checks
---
UITests/DaylineUITests/DaylineUITests.swift | 27 +++++++++++++--------
1 file changed, 17 insertions(+), 10 deletions(-)
diff --git a/UITests/DaylineUITests/DaylineUITests.swift b/UITests/DaylineUITests/DaylineUITests.swift
index 21eb34f..af077d8 100644
--- a/UITests/DaylineUITests/DaylineUITests.swift
+++ b/UITests/DaylineUITests/DaylineUITests.swift
@@ -444,20 +444,27 @@ final class DaylineUITests: XCTestCase {
let appleCalendarMenuItem = app.menuItems["calendar.new.apple"]
XCTAssertTrue(appleCalendarMenuItem.waitForExistenceIfNeeded(timeout: 3))
appleCalendarMenuItem.click()
- app.activate()
+ let calendarEventWindow = app.windows["appleCalendarEventCreator"]
+ XCTAssertTrue(calendarEventWindow.waitForExistenceIfNeeded(timeout: 5))
let calendarEventTitle = element("calendarEventEditor.title")
XCTAssertTrue(calendarEventTitle.waitForExistenceIfNeeded(timeout: 5))
+ let statusMenuIndicator = element("dayline.refresh")
+ if statusMenuIndicator.exists {
+ let statusItem = app.descendants(matching: .statusItem)["dayline.menuBarItem"].firstMatch
+ XCTAssertTrue(statusItem.waitForExistenceIfNeeded(timeout: 3))
+ statusItem.click()
+ waitForRemoval(statusMenuIndicator)
+ }
+ app.activate()
+ XCTAssertTrue(calendarEventWindow.exists, "Expected the Apple Calendar event editor to remain open")
let calendarStartDate = element("calendarEventEditor.start.date")
XCTAssertTrue(calendarStartDate.waitForExistenceIfNeeded(timeout: 5))
- if calendarStartDate.isHittable {
- calendarStartDate.click()
- } else {
- XCTAssertFalse(
- calendarStartDate.frame.isEmpty,
- "Expected the Apple Calendar start-date control to have a visible frame"
- )
- calendarStartDate.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
- }
+ let dateButtonHittable = XCTNSPredicateExpectation(
+ predicate: NSPredicate(format: "hittable == true"),
+ object: calendarStartDate
+ )
+ XCTAssertEqual(XCTWaiter.wait(for: [dateButtonHittable], timeout: 5), .completed)
+ calendarStartDate.click()
XCTAssertTrue(element("calendarEventEditor.start.calendar").waitForExistenceIfNeeded(timeout: 3))
app.typeKey(.escape, modifierFlags: [])
calendarEventTitle.click()