Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions Sources/Dayline/App/DaylineApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions Sources/Dayline/Models/AppleCalendarSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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
}
146 changes: 138 additions & 8 deletions Sources/Dayline/Models/CalendarEventItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,12 +25,23 @@ 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?

/// 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 an unrelated or calendar URL.
var hasMeetingLink: Bool {
meetingURL != nil
}

/// Calendar names contributing this event after cross-calendar deduplication.
let sourceCalendarNames: [String]

Expand All @@ -44,7 +57,9 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
startDate: Date,
endDate: Date,
location: String?,
isAllDay: Bool = false,
calendarURL: URL?,
meetingURL: URL? = nil,
openURL: URL?,
sourceCalendarNames: [String] = [],
sourceIDs: [String] = [],
Expand All @@ -55,8 +70,10 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
self.startDate = startDate
self.endDate = endDate
self.location = location
self.calendarURL = calendarURL
self.openURL = openURL
self.isAllDay = isAllDay
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
Expand All @@ -72,6 +89,104 @@ 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 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.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
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 {
Expand Down Expand Up @@ -118,14 +233,17 @@ struct CalendarEventItem: Identifiable, Equatable, Sendable {
}
}
}
let meetingURL = existing.meetingURL ?? event.meetingURL
merged[index] = CalendarEventItem(
id: existing.id,
title: existing.title,
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,
meetingURL: meetingURL,
openURL: meetingURL ?? existing.openURL ?? event.openURL,
sourceCalendarNames: names,
sourceIDs: sourceIDs,
deduplicationKey: key
Expand All @@ -142,20 +260,31 @@ 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,
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.endDate > todayStart && $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.endDate > todayStart && $0.startDate < tomorrowStart }
.prefix(todayAllDayLimit)
.map { $0 },
allDayTomorrow: merged
.filter { $0.isAllDay && $0.endDate > tomorrowStart && $0.startDate < dayAfterTomorrow }
.prefix(tomorrowAllDayLimit)
.map { $0 }
)
}
Expand All @@ -179,11 +308,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)
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/Dayline/Models/MenuControlID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading